From 821dd3b6284715018528d73345f30f52340c9c52 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 15:16:40 +0530 Subject: [PATCH 01/39] feat(account-keychain): implement admin access keys --- .../src/precompiles/account_keychain.rs | 22 +- .../src/account_keychain/dispatch.rs | 13 +- .../precompiles/src/account_keychain/mod.rs | 305 +++++++++++++++++- crates/primitives/src/transaction/envelope.rs | 1 + .../src/transaction/key_authorization.rs | 31 +- .../src/transaction/tempo_transaction.rs | 1 + crates/revm/src/handler.rs | 165 ++++++++-- crates/transaction-pool/src/tempo_pool.rs | 3 + tips/tip-1049.md | 20 +- 9 files changed, 519 insertions(+), 42 deletions(-) diff --git a/crates/contracts/src/precompiles/account_keychain.rs b/crates/contracts/src/precompiles/account_keychain.rs index 28ff0d9a2a..7142c683ee 100644 --- a/crates/contracts/src/precompiles/account_keychain.rs +++ b/crates/contracts/src/precompiles/account_keychain.rs @@ -2,9 +2,10 @@ pub use IAccountKeychain::{ IAccountKeychainErrors as AccountKeychainError, IAccountKeychainEvents as AccountKeychainEvent, - authorizeKey_0Call as legacyAuthorizeKeyCall, authorizeKey_1Call as authorizeKeyCall, - authorizeKey_2Call as authorizeKeyWithWitnessCall, getAllowedCallsReturn, - getRemainingLimitWithPeriodCall, getRemainingLimitWithPeriodReturn as getRemainingLimitReturn, + authorizeAdminKeyCall, authorizeKey_0Call as legacyAuthorizeKeyCall, + authorizeKey_1Call as authorizeKeyCall, authorizeKey_2Call as authorizeKeyWithWitnessCall, + getAllowedCallsReturn, getRemainingLimitWithPeriodCall, + getRemainingLimitWithPeriodReturn as getRemainingLimitReturn, }; crate::sol! { @@ -78,6 +79,9 @@ crate::sol! { /// Emitted when a new key is authorized event KeyAuthorized(address indexed account, address indexed publicKey, uint8 signatureType, uint64 expiry); + /// Emitted when a new admin key is authorized. + event AdminKeyAuthorized(address indexed account, address indexed publicKey); + /// Emitted when a key is revoked event KeyRevoked(address indexed account, address indexed publicKey); @@ -126,6 +130,14 @@ crate::sol! { bytes32 witness ) external; + /// Authorize a new admin key for the caller's account. + /// @dev The witness must not be burned for the caller's account. bytes32(0) is valid. + function authorizeAdminKey( + address keyId, + SignatureType signatureType, + bytes32 witness + ) external; + /// Burn a TIP-1053 key-authorization witness without authorizing a key. /// @dev Callable only by the account admin key. function burnKeyAuthorizationWitness(bytes32 witness) external; @@ -198,6 +210,9 @@ crate::sol! { /// Returns whether a TIP-1053 key-authorization witness has been manually burned. function isKeyAuthorizationWitnessBurned(address account, bytes32 witness) external view returns (bool); + /// Returns true if `keyId` is the root key or an active admin key for `account`. + function isAdminKey(address account, address keyId) external view returns (bool); + /// Get the key used in the current transaction /// @return The keyId used in the current transaction function getTransactionKey() external view returns (address); @@ -216,6 +231,7 @@ crate::sol! { error SignatureTypeMismatch(uint8 expected, uint8 actual); error CallNotAllowed(); error InvalidCallScope(); + error InvalidKeyId(); error InvalidKeyAuthorizationWitness(); error KeyAuthorizationWitnessAlreadyBurned(); error LegacyAuthorizeKeySelectorChanged(bytes4 newSelector); diff --git a/crates/precompiles/src/account_keychain/dispatch.rs b/crates/precompiles/src/account_keychain/dispatch.rs index 16f6572aa8..5f9aaf3781 100644 --- a/crates/precompiles/src/account_keychain/dispatch.rs +++ b/crates/precompiles/src/account_keychain/dispatch.rs @@ -26,6 +26,10 @@ const T5_ADDED: &[[u8; 4]] = &[ IAccountKeychain::burnKeyAuthorizationWitnessCall::SELECTOR, IAccountKeychain::isKeyAuthorizationWitnessBurnedCall::SELECTOR, ]; +const T6_ADDED: &[[u8; 4]] = &[ + IAccountKeychain::authorizeAdminKeyCall::SELECTOR, + IAccountKeychain::isAdminKeyCall::SELECTOR, +]; impl Precompile for AccountKeychain { fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { @@ -40,6 +44,7 @@ impl Precompile for AccountKeychain { .with_added(T3_ADDED) .with_dropped(T3_DROPPED), SelectorSchedule::new(TempoHardfork::T5).with_added(T5_ADDED), + SelectorSchedule::new(TempoHardfork::T6).with_added(T6_ADDED), ], IAccountKeychainCalls::abi_decode, |call| match call { @@ -92,6 +97,11 @@ impl Precompile for AccountKeychain { ) }) } + IAccountKeychainCalls::authorizeAdminKey(call) => { + mutate_void(call, msg_sender, |sender, c| { + self.authorize_admin_key(sender, c.keyId, c.signatureType, Some(c.witness)) + }) + } IAccountKeychainCalls::burnKeyAuthorizationWitness(call) => { mutate_void(call, msg_sender, |sender, c| { self.burn_key_authorization_witness(sender, c) @@ -128,6 +138,7 @@ impl Precompile for AccountKeychain { IAccountKeychainCalls::isKeyAuthorizationWitnessBurned(call) => { view(call, |c| self.is_key_authorization_witness_burned(c)) } + IAccountKeychainCalls::isAdminKey(call) => view(call, |c| self.is_admin_key(c)), IAccountKeychainCalls::getTransactionKey(call) => { view(call, |c| self.get_transaction_key(c, msg_sender)) } @@ -154,7 +165,7 @@ mod tests { #[test] fn test_account_keychain_selector_coverage() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); StorageCtx::enter(&mut storage, || { let mut fee_manager = AccountKeychain::new(); let selectors: Vec<_> = IAccountKeychainCalls::SELECTORS diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 2e98d98378..189531290a 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -17,7 +17,7 @@ pub use tempo_contracts::precompiles::{ IAccountKeychain::{ CallScope, KeyInfo, KeyRestrictions, SelectorRule, SignatureType, TokenLimit, burnKeyAuthorizationWitnessCall, getAllowedCallsCall, getKeyCall, getRemainingLimitCall, - getRemainingLimitWithPeriodCall, getTransactionKeyCall, + getRemainingLimitWithPeriodCall, getTransactionKeyCall, isAdminKeyCall, isKeyAuthorizationWitnessBurnedCall, removeAllowedCallsCall, revokeKeyCall, setAllowedCallsCall, updateSpendingLimitCall, }, @@ -54,6 +54,7 @@ pub fn is_constrained_tip20_selector(selector: [u8; 4]) -> bool { /// - bytes 1-8: expiry (u64, little-endian) /// - byte 9: enforce_limits (bool) /// - byte 10: is_revoked (bool) +/// - byte 11: is_admin (bool) #[derive(Debug, Clone, Default, PartialEq, Eq, Storable)] pub struct AuthorizedKey { /// Signature type: 0 = secp256k1, 1 = P256, 2 = WebAuthn @@ -65,6 +66,8 @@ pub struct AuthorizedKey { /// Whether this key has been revoked. Once revoked, a key cannot be re-authorized /// with the same key_id. This prevents replay attacks. pub is_revoked: bool, + /// Whether this key has admin privileges for keychain management. + pub is_admin: bool, } /// Account Keychain contract for managing authorized keys (session keys, spending limits). @@ -217,6 +220,9 @@ impl AccountKeychain { if key_id == Address::ZERO { return Err(AccountKeychainError::zero_public_key().into()); } + if self.storage.spec().is_t6() && key_id == msg_sender { + return Err(AccountKeychainError::invalid_key_id().into()); + } // T0+: Expiry must be in the future (also catches expiry == 0 which means "key doesn't exist") if self.storage.spec().is_t0() { @@ -288,6 +294,7 @@ impl AccountKeychain { expiry: config.expiry, enforce_limits: config.enforceLimits, is_revoked: false, + is_admin: false, }; self.keys[msg_sender][key_id].write(new_key)?; @@ -325,6 +332,74 @@ impl AccountKeychain { Ok(()) } + /// Registers a new unrestricted admin access key. Only newly authorized key IDs can become + /// admin keys; existing or previously revoked keys must not be upgraded in place. + pub fn authorize_admin_key( + &mut self, + msg_sender: Address, + key_id: Address, + signature_type: SignatureType, + witness: Option, + ) -> Result<()> { + self.ensure_admin_caller(msg_sender)?; + + if key_id == Address::ZERO { + return Err(AccountKeychainError::zero_public_key().into()); + } + if key_id == msg_sender { + return Err(AccountKeychainError::invalid_key_id().into()); + } + + let existing_key = self.keys[msg_sender][key_id].read()?; + if existing_key.expiry > 0 { + return Err(AccountKeychainError::key_already_exists().into()); + } + if existing_key.is_revoked { + return Err(AccountKeychainError::key_already_revoked().into()); + } + + if let Some(witness) = witness { + self.ensure_key_authorization_witness_not_burned(msg_sender, witness)?; + } + + let signature_type = match signature_type { + SignatureType::Secp256k1 => 0, + SignatureType::P256 => 1, + SignatureType::WebAuthn => 2, + _ => return Err(AccountKeychainError::invalid_signature_type().into()), + }; + + self.keys[msg_sender][key_id].write(AuthorizedKey { + signature_type, + expiry: u64::MAX, + enforce_limits: false, + is_revoked: false, + is_admin: true, + })?; + + if let Some(witness) = witness { + self.emit_event(AccountKeychainEvent::KeyAuthorizationWitness( + IAccountKeychain::KeyAuthorizationWitness { + account: msg_sender, + witness, + }, + ))?; + } + + self.emit_event(AccountKeychainEvent::key_authorized( + msg_sender, + key_id, + signature_type, + u64::MAX, + ))?; + self.emit_event(AccountKeychainEvent::AdminKeyAuthorized( + IAccountKeychain::AdminKeyAuthorized { + account: msg_sender, + publicKey: key_id, + }, + )) + } + /// Burns a TIP-1053 witness without authorizing a key. pub fn burn_key_authorization_witness( &mut self, @@ -344,6 +419,9 @@ impl AccountKeychain { /// - `KeyNotFound` — no key registered with this ID pub fn revoke_key(&mut self, msg_sender: Address, call: revokeKeyCall) -> Result<()> { self.ensure_admin_caller(msg_sender)?; + if self.storage.spec().is_t6() && call.keyId == msg_sender { + return Err(AccountKeychainError::invalid_key_id().into()); + } let key = self.keys[msg_sender][call.keyId].read()?; @@ -385,6 +463,9 @@ impl AccountKeychain { let current_timestamp = self.storage.timestamp().saturating_to::(); let mut key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; + if self.storage.spec().is_t6() && key.is_admin { + return Err(AccountKeychainError::invalid_key_id().into()); + } // If this key had unlimited spending (enforce_limits=false), enable limits now if !key.enforce_limits { @@ -499,7 +580,10 @@ impl AccountKeychain { self.ensure_admin_caller(msg_sender)?; let current_timestamp = self.storage.timestamp().saturating_to::(); - self.load_active_key(msg_sender, call.keyId, current_timestamp)?; + let key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; + if self.storage.spec().is_t6() && key.is_admin { + return Err(AccountKeychainError::invalid_key_id().into()); + } let key_hash = Self::spending_limit_key(msg_sender, call.keyId); let scopes = call.scopes; @@ -526,7 +610,10 @@ impl AccountKeychain { self.ensure_admin_caller(msg_sender)?; let current_timestamp = self.storage.timestamp().saturating_to::(); - self.load_active_key(msg_sender, call.keyId, current_timestamp)?; + let key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; + if self.storage.spec().is_t6() && key.is_admin { + return Err(AccountKeychainError::invalid_key_id().into()); + } let key_hash = Self::spending_limit_key(msg_sender, call.keyId); let current_mode = self.key_scopes[key_hash].is_scoped.read()?; @@ -622,6 +709,11 @@ impl AccountKeychain { self.key_authorization_witnesses[call.account][call.witness].read() } + /// Returns true for the root key or for an active admin access key. + pub fn is_admin_key(&self, call: isAdminKeyCall) -> Result { + self.is_admin_key_for(call.account, call.keyId) + } + /// Returns the access key used to authorize the current transaction (`Address::ZERO` = root key). pub fn get_transaction_key( &self, @@ -1004,7 +1096,11 @@ impl AccountKeychain { fn ensure_admin_caller(&self, msg_sender: Address) -> Result<()> { let transaction_key = self.transaction_key.t_read()?; if !transaction_key.is_zero() { - return Err(AccountKeychainError::unauthorized_caller().into()); + if !self.storage.spec().is_t6() + || !self.is_admin_key_for(msg_sender, transaction_key)? + { + return Err(AccountKeychainError::unauthorized_caller().into()); + } } if self.storage.spec().is_t2() { @@ -1017,6 +1113,17 @@ impl AccountKeychain { Ok(()) } + /// Internal predicate for root/admin status. + pub fn is_admin_key_for(&self, account: Address, key_id: Address) -> Result { + if key_id == account { + return Ok(true); + } + + let current_timestamp = self.storage.timestamp().saturating_to::(); + let key = self.keys[account][key_id].read()?; + Ok(key.expiry != 0 && !key.is_revoked && current_timestamp < key.expiry && key.is_admin) + } + fn ensure_key_authorization_witness_not_burned( &self, account: Address, @@ -1472,10 +1579,199 @@ mod tests { } } + fn assert_invalid_key_id(error: TempoPrecompileError) { + match error { + TempoPrecompileError::AccountKeychainError(e) => { + assert!( + matches!(e, AccountKeychainError::InvalidKeyId(_)), + "Expected InvalidKeyId error, got: {e:?}" + ); + } + _ => panic!("Expected AccountKeychainError, got: {error:?}"), + } + } + fn unrestricted_restrictions() -> KeyRestrictions { tempo_alloy::provider::keychain::KeyRestrictions::default().into() } + #[test] + fn test_t6_root_authorizes_admin_key() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let account = Address::random(); + let admin_key = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + + keychain.authorize_admin_key(account, admin_key, SignatureType::P256, None)?; + + let key = keychain.keys[account][admin_key].read()?; + assert_eq!(key.signature_type, SignatureType::P256 as u8); + assert_eq!(key.expiry, u64::MAX); + assert!(!key.enforce_limits); + assert!(!key.is_revoked); + assert!(key.is_admin); + assert!(keychain.is_admin_key(isAdminKeyCall { + account, + keyId: account + })?); + assert!(keychain.is_admin_key(isAdminKeyCall { + account, + keyId: admin_key + })?); + + Ok(()) + }) + } + + #[test] + fn test_t6_admin_key_can_authorize_and_revoke_keys() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let account = Address::random(); + let admin_key = Address::random(); + let child_key = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + keychain.authorize_admin_key(account, admin_key, SignatureType::Secp256k1, None)?; + + keychain.set_transaction_key(admin_key)?; + authorize_key( + &mut keychain, + account, + authorizeKeyCall { + keyId: child_key, + signatureType: SignatureType::WebAuthn, + config: unrestricted_restrictions(), + }, + )?; + assert!(keychain.keys[account][child_key].read()?.expiry > 0); + + keychain.revoke_key(account, revokeKeyCall { keyId: admin_key })?; + assert!(!keychain.is_admin_key(isAdminKeyCall { + account, + keyId: admin_key + })?); + + Ok(()) + }) + } + + #[test] + fn test_t6_non_admin_key_cannot_authorize_keys() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let account = Address::random(); + let access_key = Address::random(); + let child_key = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + authorize_key( + &mut keychain, + account, + authorizeKeyCall { + keyId: access_key, + signatureType: SignatureType::Secp256k1, + config: unrestricted_restrictions(), + }, + )?; + + keychain.set_transaction_key(access_key)?; + let result = authorize_key( + &mut keychain, + account, + authorizeKeyCall { + keyId: child_key, + signatureType: SignatureType::Secp256k1, + config: unrestricted_restrictions(), + }, + ); + + assert_unauthorized_error(result.expect_err("non-admin key must not authorize keys")); + Ok(()) + }) + } + + #[test] + fn test_t6_admin_key_restrictions_and_root_slot_rejected() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let account = Address::random(); + let admin_key = Address::random(); + let token = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + keychain.authorize_admin_key(account, admin_key, SignatureType::Secp256k1, None)?; + + assert_invalid_key_id( + keychain + .update_spending_limit( + account, + updateSpendingLimitCall { + keyId: admin_key, + token, + newLimit: U256::from(1), + }, + ) + .expect_err("admin keys cannot receive spending limits"), + ); + + assert_invalid_key_id( + keychain + .revoke_key(account, revokeKeyCall { keyId: account }) + .expect_err("root key cannot be revoked"), + ); + + assert_invalid_key_id( + keychain + .authorize_admin_key(account, account, SignatureType::Secp256k1, None) + .expect_err("root key cannot be registered as an admin access key"), + ); + + Ok(()) + }) + } + + #[test] + fn test_t6_existing_key_cannot_become_admin() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let account = Address::random(); + let access_key = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + authorize_key( + &mut keychain, + account, + authorizeKeyCall { + keyId: access_key, + signatureType: SignatureType::Secp256k1, + config: unrestricted_restrictions(), + }, + )?; + + let result = + keychain.authorize_admin_key(account, access_key, SignatureType::Secp256k1, None); + assert_eq!( + result.expect_err("existing key must not become admin"), + AccountKeychainError::key_already_exists().into() + ); + + Ok(()) + }) + } + #[test] fn test_t5_authorize_key_with_witness_does_not_burn_and_allows_reuse() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); @@ -3534,6 +3830,7 @@ mod tests { expiry: u64::MAX, enforce_limits: true, is_revoked: false, + is_admin: false, })?; keychain.spending_limits[limit_key][token].write(SpendingLimitState { remaining: U256::from(90), diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 9ca212b21b..de14f378ac 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -1024,6 +1024,7 @@ mod tests { limits, allowed_calls: None, witness: None, + admin_account: None, }, signature: PrimitiveSignature::Secp256k1(Signature::test_signature()), }), diff --git a/crates/primitives/src/transaction/key_authorization.rs b/crates/primitives/src/transaction/key_authorization.rs index 02f45eb6e9..6fd94f984b 100644 --- a/crates/primitives/src/transaction/key_authorization.rs +++ b/crates/primitives/src/transaction/key_authorization.rs @@ -163,9 +163,10 @@ impl From for AbiSelectorRule { /// Key authorization for provisioning access keys /// /// Used in TempoTransaction to add a new key to the AccountKeychain precompile. -/// The transaction must be signed by the root key to authorize adding this access key. +/// The transaction must be signed by the root key, or by an active admin key when authorizing for +/// the admin key's account. /// -/// RLP encoding: `[chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?]` +/// RLP encoding: `[chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?, admin_account?]` /// - Non-optional fields come first, followed by optional (trailing) fields /// - `expiry`: `None` (omitted or 0x80) = key never expires, `Some(timestamp)` = expires at timestamp /// - `limits`: `None` (omitted or 0x80) = unlimited spending, `Some([])` = no spending, `Some([...])` = specific limits @@ -216,6 +217,12 @@ pub struct KeyAuthorization { /// `None` means no witness. `Some(witness)` means the witness field is present, including when /// `witness == B256::ZERO`. pub witness: Option, + + /// Account this admin authorization targets. + /// + /// `None` means this authorization creates a non-admin access key. `Some(account)` means this + /// authorization creates an admin key for `account`. + pub admin_account: Option
, } impl KeyAuthorization { @@ -230,6 +237,7 @@ impl KeyAuthorization { limits: None, allowed_calls: None, witness: None, + admin_account: None, } } @@ -274,6 +282,17 @@ impl KeyAuthorization { self.witness } + /// Convert this authorization into an account-bound admin-key authorization. + pub fn into_admin(mut self, account: Address) -> Self { + self.admin_account = Some(account); + self + } + + /// Returns whether this authorization creates an admin key. + pub fn is_admin(&self) -> bool { + self.admin_account.is_some() + } + /// Computes the authorization message hash for this key authorization. pub fn signature_hash(&self) -> B256 { let mut buf = Vec::new(); @@ -310,7 +329,10 @@ impl KeyAuthorization { /// Returns whether this authorization can be encoded with the legacy pre-T3 ABI. pub fn is_legacy_compatible(&self) -> bool { - !(self.has_periodic_limits() || self.has_call_scopes() || self.has_witness()) + !(self.has_periodic_limits() + || self.has_call_scopes() + || self.has_witness() + || self.admin_account.is_some()) } /// Convert the key authorization into a [`SignedKeyAuthorization`] with a signature. @@ -418,6 +440,7 @@ impl<'a> arbitrary::Arbitrary<'a> for KeyAuthorization { limits: u.arbitrary()?, allowed_calls: u.arbitrary()?, witness: u.arbitrary::>()?.map(B256::from), + admin_account: u.arbitrary()?, }) } } @@ -559,6 +582,7 @@ mod tests { limits, allowed_calls: None, witness: None, + admin_account: None, } } @@ -787,6 +811,7 @@ mod tests { limits: None, allowed_calls: None, witness: None, + admin_account: None, } } diff --git a/crates/primitives/src/transaction/tempo_transaction.rs b/crates/primitives/src/transaction/tempo_transaction.rs index 4657e6f19e..0c9cffeeeb 100644 --- a/crates/primitives/src/transaction/tempo_transaction.rs +++ b/crates/primitives/src/transaction/tempo_transaction.rs @@ -2160,6 +2160,7 @@ mod compact_tests { }]), allowed_calls: None, witness: None, + admin_account: None, }, signature: PrimitiveSignature::P256(P256SignatureWithPreHash { r: b256!("0x1111111111111111111111111111111111111111111111111111111111111111"), diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 972ae01b66..489173e9ab 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1147,6 +1147,54 @@ where } if let Some(key_auth) = tempo_tx_env.key_authorization.as_ref() { + let access_key_addr = if let Some(override_key_id) = tempo_tx_env.override_key_id { + override_key_id + } else { + keychain_sig + .key_id(&tempo_tx_env.signature_hash) + .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? + }; + + if cfg.spec.is_t6() && access_key_addr != key_auth.key_id { + let stored_key_expiry = StorageCtx::enter_precompile( + journal, + block, + cfg, + tx, + |mut keychain: AccountKeychain| { + let sig_type = spec + .is_t1() + .then_some(keychain_sig.signature.signature_type().into()); + + let key = keychain + .validate_keychain_authorization( + *user_address, + access_key_addr, + block.timestamp().to::(), + sig_type, + ) + .map_err(|e| TempoInvalidTransaction::KeychainValidationFailed { + reason: format!("{e:?}"), + })?; + + if !key.is_admin { + return Err( + TempoInvalidTransaction::AccessKeyCannotAuthorizeOtherKeys + .into(), + ); + } + + keychain + .set_transaction_key(access_key_addr) + .map_err(|e| EVMError::Custom(e.to_string()))?; + + Ok::<_, EVMError<_, TempoInvalidTransaction>>(key.expiry) + }, + )?; + + evm.key_expiry = Some(stored_key_expiry); + } + // If this is a same tx auth+use, validate that spending limit is enough to cover the fee. // // `collectFeePreTx` would not validate the spending limit because the key is not authorized yet and we are not setting the transient key_id. @@ -1220,6 +1268,36 @@ where } } + if cfg.spec.is_t6() + && let Some(tempo_tx_env) = tx.tempo_tx_env.as_ref() + && let Some(key_auth) = tempo_tx_env.key_authorization.as_ref() + { + let auth_signer = key_auth + .recover_signer() + .map_err(|_| TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed)?; + if auth_signer != tx.caller { + let is_admin = StorageCtx::enter_precompile( + journal, + block, + cfg, + tx, + |keychain: AccountKeychain| { + keychain + .is_admin_key_for(tx.caller, auth_signer) + .map_err(|e| EVMError::Custom(e.to_string())) + }, + )?; + + if !is_admin { + return Err(TempoInvalidTransaction::KeyAuthorizationNotSignedByRoot { + expected: tx.caller, + actual: auth_signer, + } + .into()); + } + } + } + // Collect fees for the transaction. if !gas_balance_spending.is_zero() { let checkpoint = journal.checkpoint(); @@ -1375,14 +1453,23 @@ where allowedCalls: precompile_allowed_calls, }; - // Call precompile to authorize the key (same phase as nonce increment) - let result = keychain.authorize_key( - tx.caller, - access_key_addr, - signature_type, - config, - key_auth.witness(), - ); + // Call precompile to authorize the key (same phase as nonce increment). + let result = if key_auth.is_admin() { + keychain.authorize_admin_key( + tx.caller, + access_key_addr, + signature_type, + key_auth.witness(), + ) + } else { + keychain.authorize_key( + tx.caller, + access_key_addr, + signature_type, + config, + key_auth.witness(), + ) + }; match result { // all is good, we can do execution. @@ -1612,8 +1699,8 @@ where } if let Some(key_auth) = &aa_env.key_authorization { - // Check if this TX is using a Keychain signature (access key) - // Access keys cannot authorize new keys UNLESS it's the same key being authorized (same-tx auth+use) + // Check if this TX is using a Keychain signature (access key). Non-admin access + // keys cannot authorize other keys; T6 admin keys can. if let Some(keychain_sig) = aa_env.signature.as_keychain() { // Use override_key_id if provided (for gas estimation), otherwise recover from signature let access_key_addr = if let Some(override_key_id) = aa_env.override_key_id { @@ -1625,8 +1712,8 @@ where .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? }; - // Only allow if authorizing the same key that's being used (same-tx auth+use) - if access_key_addr != key_auth.key_id { + let same_tx_auth_use = access_key_addr == key_auth.key_id; + if !same_tx_auth_use && !cfg.spec.is_t6() { return Err( TempoInvalidTransaction::AccessKeyCannotAuthorizeOtherKeys.into() ); @@ -1643,23 +1730,55 @@ where } } - // Validate that the KeyAuthorization is signed by the root account - let root_account = &tx.caller; + if key_auth.admin_account.is_some() { + if !cfg.spec.is_t6() { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: "admin key authorization fields are not active before T6" + .to_string(), + } + .into()); + } - // Recover the signer of the KeyAuthorization - let auth_signer = key_auth.recover_signer().map_err(|_| { - TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed - })?; + if key_auth.expiry.is_some() + || key_auth.limits.is_some() + || key_auth.allowed_calls.is_some() + { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: "admin key authorizations cannot carry expiry, limits, or call scopes" + .to_string(), + } + .into()); + } - // Verify the KeyAuthorization is signed by the root account - if auth_signer != *root_account { - return Err(TempoInvalidTransaction::KeyAuthorizationNotSignedByRoot { - expected: *root_account, - actual: auth_signer, + if key_auth.admin_account != Some(tx.caller) { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: "admin key authorization account mismatch".to_string(), + } + .into()); + } + } + + if cfg.spec.is_t6() && key_auth.key_id == tx.caller { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: "key authorization key_id cannot equal account".to_string(), } .into()); } + if !cfg.spec.is_t6() { + let auth_signer = key_auth.recover_signer().map_err(|_| { + TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed + })?; + + if auth_signer != tx.caller { + return Err(TempoInvalidTransaction::KeyAuthorizationNotSignedByRoot { + expected: tx.caller, + actual: auth_signer, + } + .into()); + } + } + // Validate KeyAuthorization chain_id. // T1C+: chain_id must exactly match (wildcard 0 is no longer allowed). // Pre-T1C: chain_id == 0 allows replay on any chain (wildcard). diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index f10d446c40..9285e007ad 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -1379,6 +1379,7 @@ mod tests { expiry: u64::MAX, enforce_limits: true, is_revoked: false, + is_admin: false, })?; let limit_key = AccountKeychain::spending_limit_key(account, key_id); keychain.spending_limits[limit_key][fee_token].write(limit_state)?; @@ -2370,6 +2371,7 @@ mod tests { expiry: u64::MAX, enforce_limits: true, is_revoked: false, + is_admin: false, }) }) .unwrap(); @@ -2405,6 +2407,7 @@ mod tests { expiry: u64::MAX, enforce_limits: false, is_revoked: false, + is_admin: false, }) }) .unwrap(); diff --git a/tips/tip-1049.md b/tips/tip-1049.md index 132cd51008..c5be1ad3dd 100644 --- a/tips/tip-1049.md +++ b/tips/tip-1049.md @@ -59,16 +59,19 @@ A new function on the `AccountKeychain` precompile authorizes an admin key for t /// @notice Authorizes an admin key for the caller's account. /// @param keyId The key identifier (address derived from public key) /// @param signatureType 0: secp256k1, 1: P256, 2: WebAuthn +/// @param witness TIP-1053 key-authorization witness for this authorization function authorizeAdminKey( address keyId, - SignatureType signatureType + SignatureType signatureType, + bytes32 witness ) external; ``` - Guarded by `ensure_admin_caller`. +- MUST reject if `witness` is already burned for `account`. `bytes32(0)` is a valid witness value. - MUST reject `keyId == account` with `AccountKeychainError::InvalidKeyId`, including attempts to authorize the root EOA key as an admin access key. - MUST reject if `keys[account][keyId]` is already registered with `AccountKeychainError::KeyAlreadyExists`; previously revoked key IDs MUST reject with `AccountKeychainError::KeyAlreadyRevoked`. Only newly authorized keys can be marked as admin. -- On success, sets `keys[account][keyId].is_admin = true` and emits `AdminKeyAuthorized`. +- On success, sets `keys[account][keyId].is_admin = true` and emits `AdminKeyAuthorized`. It also emits the TIP-1053 witness event for `witness`. ### `AdminKeyAuthorized` Event @@ -140,17 +143,18 @@ Revocation of an admin key does NOT automatically revoke keys that it authorized ## Transaction Encoding -`KeyAuthorization` remains an RLP list. TIP-1049 adds two trailing optional fields: +`KeyAuthorization` remains an RLP list. TIP-1053 adds `witness?`; TIP-1049 adds the `admin_account?` trailing optional field after it: ``` -rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, is_admin?, account?]) +rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?, admin_account?]) ``` -- If `is_admin` is omitted or `false`, `account` MUST be omitted and the authorization creates a non-admin key. -- If `is_admin` is `true`, `account` MUST be present and MUST equal the target account being modified. -- If `is_admin` is `true`, `expiry`, `limits`, and `allowed_calls` MUST be omitted or encoded as empty optional values because admin keys carry no restrictions. +- If `admin_account` is omitted, the authorization creates a non-admin key. +- If `admin_account` is present, the authorization creates an admin key and `admin_account` MUST equal the target account being modified. +- If `admin_account` is present, `expiry`, `limits`, and `allowed_calls` MUST be omitted or encoded as empty optional values because admin keys carry no restrictions. +- If `admin_account` is present, `witness` MAY be present or omitted for the transaction-encoded authorization. ABI calls to `authorizeAdminKey` always provide a `bytes32 witness`, including `bytes32(0)`. -The target `account` and `is_admin` fields are part of the signed RLP payload. This binds admin key authorizations to a target account and prevents replaying an unrestricted non-admin authorization as an admin authorization. +The target `admin_account` and `witness` fields are part of the signed RLP payload. This binds admin key authorizations to a target account and prevents replaying an unrestricted non-admin authorization as an admin authorization. # Invariants From 2ee0242a749000bae42f60bd4a82e744ce172808 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 15:29:53 +0530 Subject: [PATCH 02/39] test(account-keychain): cover admin access key authorization --- .../precompiles/src/account_keychain/mod.rs | 42 +++++++ .../src/transaction/key_authorization.rs | 29 +++++ crates/revm/src/handler.rs | 111 ++++++++++++++++++ 3 files changed, 182 insertions(+) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 189531290a..d67ce0f1fa 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -1627,6 +1627,48 @@ mod tests { }) } + #[test] + fn test_t6_authorize_admin_key_with_witness_checks_burned_state() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let account = Address::random(); + let first_admin_key = Address::random(); + let second_admin_key = Address::random(); + let witness = B256::repeat_byte(0x65); + + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + + keychain.authorize_admin_key( + account, + first_admin_key, + SignatureType::Secp256k1, + Some(witness), + )?; + assert!(!keychain.is_key_authorization_witness_burned( + isKeyAuthorizationWitnessBurnedCall { account, witness } + )?); + + keychain.burn_key_authorization_witness( + account, + burnKeyAuthorizationWitnessCall { witness }, + )?; + let result = keychain.authorize_admin_key( + account, + second_admin_key, + SignatureType::Secp256k1, + Some(witness), + ); + assert_eq!( + result.expect_err("burned witness must not authorize admin key"), + AccountKeychainError::key_authorization_witness_already_burned().into() + ); + + Ok(()) + }) + } + #[test] fn test_t6_admin_key_can_authorize_and_revoke_keys() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); diff --git a/crates/primitives/src/transaction/key_authorization.rs b/crates/primitives/src/transaction/key_authorization.rs index 6fd94f984b..ea48f5ab7a 100644 --- a/crates/primitives/src/transaction/key_authorization.rs +++ b/crates/primitives/src/transaction/key_authorization.rs @@ -629,6 +629,35 @@ mod tests { assert_eq!(reencoded, encoded); } + #[test] + fn test_admin_account_roundtrip_and_signature_binding() { + let account = Address::repeat_byte(0x11); + let other_account = Address::repeat_byte(0x22); + let key_id = Address::repeat_byte(0x33); + let witness = B256::repeat_byte(0x44); + + let normal = KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, key_id) + .with_witness(witness); + let admin = normal.clone().into_admin(account); + let other_admin = normal.clone().into_admin(other_account); + + assert!(!normal.is_admin()); + assert!(admin.is_admin()); + assert_eq!(admin.admin_account, Some(account)); + assert!(!admin.is_legacy_compatible()); + + let mut encoded = Vec::new(); + admin.encode(&mut encoded); + let decoded = + ::decode(&mut encoded.as_slice()).expect("decode auth"); + assert_eq!(decoded, admin); + assert_eq!(decoded.witness(), Some(witness)); + assert_eq!(decoded.admin_account, Some(account)); + + assert_ne!(admin.signature_hash(), normal.signature_hash()); + assert_ne!(admin.signature_hash(), other_admin.signature_hash()); + } + #[test] fn test_witness_encoding_preserves_prior_absent_trailing_fields() { let witness = B256::repeat_byte(0x53); diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 489173e9ab..de8fde2364 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -5021,6 +5021,117 @@ mod tests { }); } + #[test] + fn test_t6_admin_key_authorization_fields_rejected_before_t6() { + let (signer, user) = generate_keypair(); + let key = Address::random(); + let signed = sign_key_auth( + &signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, key).into_admin(user), + ); + let (mut evm, h) = make_evm(user, key, Some(signed), TempoHardfork::T5, None, false); + + let result = h.validate_env(&mut evm); + assert!( + matches!( + &result, + Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) + if reason.contains("not active before T6") + ), + "admin key authorization fields should be rejected before T6, got: {result:?}" + ); + } + + #[test] + fn test_t6_admin_key_authorization_rejects_account_mismatch() { + let (signer, user) = generate_keypair(); + let key = Address::random(); + let wrong_account = Address::random(); + let signed = sign_key_auth( + &signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, key) + .into_admin(wrong_account), + ); + let (mut evm, h) = make_evm(user, key, Some(signed), TempoHardfork::T6, None, false); + + let result = h.validate_env(&mut evm); + assert!( + matches!( + &result, + Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) + if reason.contains("account mismatch") + ), + "admin key authorization should be bound to tx.caller, got: {result:?}" + ); + } + + #[test] + fn test_t6_admin_key_authorization_rejects_restrictions() { + let (signer, user) = generate_keypair(); + let key = Address::random(); + let signed = sign_key_auth( + &signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, key) + .with_expiry(u64::MAX) + .into_admin(user), + ); + let (mut evm, h) = make_evm(user, key, Some(signed), TempoHardfork::T6, None, false); + + let result = h.validate_env(&mut evm); + assert!( + matches!( + &result, + Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) + if reason.contains("cannot carry expiry") + ), + "admin key authorization should reject restrictions, got: {result:?}" + ); + } + + #[test] + fn test_t6_admin_access_key_can_authorize_different_admin_key() { + let (admin_signer, admin_key) = generate_keypair(); + let user = Address::random(); + let child_key = Address::random(); + let signed = sign_key_auth( + &admin_signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, child_key) + .into_admin(user), + ); + let (mut evm, h) = make_evm( + user, + admin_key, + Some(signed), + TempoHardfork::T6, + None, + false, + ); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { + let mut keychain = AccountKeychain::new(); + keychain + .authorize_admin_key(user, admin_key, PrecompileSignatureType::Secp256k1, None) + .expect("root authorizes admin key"); + }); + + let result = + h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); + assert!( + result.is_ok(), + "admin access key should authorize a different admin key, got: {result:?}" + ); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { + let keychain = AccountKeychain::new(); + assert!( + keychain + .is_admin_key_for(user, child_key) + .expect("admin key status read succeeds"), + "child key should be registered as admin" + ); + }); + } + #[test] fn test_keychain_signature_with_valid_authorized_key() { let (mut evm, h) = make_evm( From 6f6977f70210f9390188ee5020e12114faf79a9a Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 15:46:15 +0530 Subject: [PATCH 03/39] fix(revm): allow admin auth across key types --- crates/revm/src/handler.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index de8fde2364..abe4ceaa76 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1719,7 +1719,8 @@ where ); } - if cfg.spec.is_t3() + if same_tx_auth_use + && cfg.spec.is_t3() && key_auth.key_type != keychain_sig.signature.signature_type() { return Err(TempoInvalidTransaction::KeychainValidationFailed { @@ -5095,7 +5096,7 @@ mod tests { let child_key = Address::random(); let signed = sign_key_auth( &admin_signer, - KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, child_key) + KeyAuthorization::unrestricted(1, SignatureType::WebAuthn, child_key) .into_admin(user), ); let (mut evm, h) = make_evm( From 21df74af3f1bdefec450b37e2c1a70b5f82ad3cb Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 15:57:42 +0530 Subject: [PATCH 04/39] test(node): add admin access key e2e coverage --- .../node/tests/it/tempo_transaction/local.rs | 220 +++++++++++++++++- 1 file changed, 219 insertions(+), 1 deletion(-) diff --git a/crates/node/tests/it/tempo_transaction/local.rs b/crates/node/tests/it/tempo_transaction/local.rs index 39f50a388b..52939a16f2 100644 --- a/crates/node/tests/it/tempo_transaction/local.rs +++ b/crates/node/tests/it/tempo_transaction/local.rs @@ -28,7 +28,8 @@ use tempo_chainspec::{hardfork::TempoHardfork, spec::TEMPO_T1_BASE_FEE}; use tempo_contracts::precompiles::{ DEFAULT_FEE_TOKEN, account_keychain::IAccountKeychain::{ - IAccountKeychainInstance, burnKeyAuthorizationWitnessCall, revokeKeyCall, + IAccountKeychainInstance, authorizeAdminKeyCall, burnKeyAuthorizationWitnessCall, + revokeKeyCall, }, }; use tempo_precompiles::{ @@ -38,6 +39,7 @@ use tempo_precompiles::{ use tempo_primitives::{ TempoTransaction, TempoTxEnvelope, transaction::{ + KeyAuthorization, SignedKeyAuthorization, tempo_transaction::Call, tt_signature::{KeychainSignature, PrimitiveSignature, TempoSignature, WebAuthnSignature}, tt_signed::AASigned, @@ -50,6 +52,36 @@ fn test_secp256k1_access_key_signature() -> TempoSignature { TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature())) } +fn create_admin_key_authorization( + signer: &impl SignerSync, + admin_account: Address, + key_id: Address, + chain_id: u64, +) -> eyre::Result { + let key_auth = KeyAuthorization::unrestricted( + chain_id, + tempo_primitives::SignatureType::Secp256k1, + key_id, + ) + .into_admin(admin_account); + let signature = signer.sign_hash_sync(&key_auth.signature_hash())?; + Ok(key_auth.into_signed(PrimitiveSignature::Secp256k1(signature))) +} + +fn authorize_admin_key_call(key_id: Address, witness: B256) -> Call { + Call { + to: ACCOUNT_KEYCHAIN_ADDRESS.into(), + value: U256::ZERO, + input: authorizeAdminKeyCall { + keyId: key_id, + signatureType: tempo_contracts::precompiles::IAccountKeychain::SignatureType::Secp256k1, + witness, + } + .abi_encode() + .into(), + } +} + /// Single-node local test environment with direct node access. pub(crate) struct Localnet { pub setup: SingleNodeSetup, @@ -1496,6 +1528,192 @@ async fn test_key_authorization_witness_burn_evicts_pending_replay() -> eyre::Re Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn test_t6_authorize_admin_key_abi_e2e() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let mut setup = TestNodeBuilder::new().build_with_node_access().await?; + let root_signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC).build()?; + let root_addr = root_signer.address(); + let provider = ProviderBuilder::new_with_network::() + .wallet(root_signer.clone()) + .connect_http(setup.node.rpc_url()); + let chain_id = provider.get_chain_id().await?; + + let admin_signer = PrivateKeySigner::random(); + let admin_key = admin_signer.address(); + let witness = B256::repeat_byte(0xa1); + let nonce = provider.get_transaction_count(root_addr).await?; + let tx = create_basic_aa_tx( + chain_id, + nonce, + vec![authorize_admin_key_call(admin_key, witness)], + 2_000_000, + ); + + let sig = sign_aa_tx_secp256k1(&tx, &root_signer)?; + submit_and_mine_aa_tx(&mut setup, tx, sig).await?; + + let keychain = IAccountKeychainInstance::new(ACCOUNT_KEYCHAIN_ADDRESS, &provider); + assert!( + keychain.isAdminKey(root_addr, admin_key).call().await?, + "ABI authorizeAdminKey should register an active admin key" + ); + assert!( + !keychain + .isKeyAuthorizationWitnessBurned(root_addr, witness) + .call() + .await?, + "authorizeAdminKey should check but not burn the witness" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_t6_inline_admin_key_authorization_e2e() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let mut setup = TestNodeBuilder::new().build_with_node_access().await?; + let root_signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC).build()?; + let root_addr = root_signer.address(); + let provider = ProviderBuilder::new_with_network::() + .wallet(root_signer.clone()) + .connect_http(setup.node.rpc_url()); + let chain_id = provider.get_chain_id().await?; + + let admin_key = PrivateKeySigner::random().address(); + let admin_auth = create_admin_key_authorization(&root_signer, root_addr, admin_key, chain_id)?; + let nonce = provider.get_transaction_count(root_addr).await?; + let mut tx = create_basic_aa_tx( + chain_id, + nonce, + vec![create_balance_of_call(root_addr)], + 2_000_000, + ); + tx.key_authorization = Some(admin_auth); + + let sig = sign_aa_tx_secp256k1(&tx, &root_signer)?; + submit_and_mine_aa_tx(&mut setup, tx, sig).await?; + + let keychain = IAccountKeychainInstance::new(ACCOUNT_KEYCHAIN_ADDRESS, &provider); + assert!( + keychain.isAdminKey(root_addr, admin_key).call().await?, + "inline admin_account authorization should register an active admin key" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_t6_admin_key_authorizes_child_admin_key_e2e() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let mut setup = TestNodeBuilder::new().build_with_node_access().await?; + let root_signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC).build()?; + let root_addr = root_signer.address(); + let provider = ProviderBuilder::new_with_network::() + .wallet(root_signer.clone()) + .connect_http(setup.node.rpc_url()); + let chain_id = provider.get_chain_id().await?; + + let admin_signer = PrivateKeySigner::random(); + let admin_key = admin_signer.address(); + let child_admin_key = PrivateKeySigner::random().address(); + + let root_nonce = provider.get_transaction_count(root_addr).await?; + let root_tx = create_basic_aa_tx( + chain_id, + root_nonce, + vec![authorize_admin_key_call(admin_key, B256::repeat_byte(0xa2))], + 2_000_000, + ); + let root_sig = sign_aa_tx_secp256k1(&root_tx, &root_signer)?; + submit_and_mine_aa_tx(&mut setup, root_tx, root_sig).await?; + + let admin_signed_auth = + create_admin_key_authorization(&admin_signer, root_addr, child_admin_key, chain_id)?; + let mut admin_tx = create_basic_aa_tx( + chain_id, + provider.get_transaction_count(root_addr).await?, + vec![create_balance_of_call(root_addr)], + 2_000_000, + ); + admin_tx.key_authorization = Some(admin_signed_auth); + let admin_sig = sign_aa_tx_with_secp256k1_access_key(&admin_tx, &admin_signer, root_addr)?; + submit_and_mine_aa_tx(&mut setup, admin_tx, admin_sig).await?; + + let keychain = IAccountKeychainInstance::new(ACCOUNT_KEYCHAIN_ADDRESS, &provider); + assert!( + keychain + .isAdminKey(root_addr, child_admin_key) + .call() + .await?, + "admin access key should authorize a different admin key end-to-end" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_t6_admin_key_authorization_cross_account_replay_rejected_e2e() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let mut setup = TestNodeBuilder::new().build_with_node_access().await?; + let alice_signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC).build()?; + let alice_addr = alice_signer.address(); + let bob_signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC) + .index(1)? + .build()?; + let bob_addr = bob_signer.address(); + let provider = ProviderBuilder::new() + .wallet(alice_signer.clone()) + .connect_http(setup.node.rpc_url()); + let chain_id = provider.get_chain_id().await?; + + fund_address_with( + &mut setup, + &provider, + &alice_signer, + alice_addr, + bob_addr, + rand_funding_amount(), + DEFAULT_FEE_TOKEN, + chain_id, + ) + .await?; + + let replayed_admin_key = PrivateKeySigner::random().address(); + let alice_bound_auth = + create_admin_key_authorization(&alice_signer, alice_addr, replayed_admin_key, chain_id)?; + let mut replay_tx = create_basic_aa_tx( + chain_id, + provider.get_transaction_count(bob_addr).await?, + vec![create_balance_of_call(bob_addr)], + 2_000_000, + ); + replay_tx.key_authorization = Some(alice_bound_auth); + let replay_sig = sign_aa_tx_secp256k1(&replay_tx, &bob_signer)?; + let replay_envelope: TempoTxEnvelope = replay_tx.into_signed(replay_sig).into(); + + let result = setup + .node + .rpc + .inject_tx(replay_envelope.encoded_2718().into()) + .await; + assert!( + result.is_err(), + "admin_account-bound authorization for Alice must not be accepted by Bob" + ); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("account mismatch") || err.contains("KeychainValidationFailed"), + "expected account mismatch rejection, got: {err}" + ); + + Ok(()) +} + /// Verifies that transactions signed with a revoked access key cannot be executed. #[tokio::test] async fn test_aa_keychain_revocation_toctou_dos() -> eyre::Result<()> { From 1a7026a739847bd5295fb23058276f650cae017a Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 17:56:31 +0530 Subject: [PATCH 05/39] fix(account-keychain): bind admin-signed key auths --- crates/primitives/src/transaction/envelope.rs | 3 +- .../src/transaction/key_authorization.rs | 146 ++++++++++++++++-- .../src/transaction/tempo_transaction.rs | 3 +- crates/revm/src/handler.rs | 125 ++++++++++++++- tips/tip-1049.md | 15 +- 5 files changed, 263 insertions(+), 29 deletions(-) diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index de14f378ac..df1a0869be 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -1024,7 +1024,8 @@ mod tests { limits, allowed_calls: None, witness: None, - admin_account: None, + is_admin: false, + account: None, }, signature: PrimitiveSignature::Secp256k1(Signature::test_signature()), }), diff --git a/crates/primitives/src/transaction/key_authorization.rs b/crates/primitives/src/transaction/key_authorization.rs index ea48f5ab7a..c2c596f19f 100644 --- a/crates/primitives/src/transaction/key_authorization.rs +++ b/crates/primitives/src/transaction/key_authorization.rs @@ -166,7 +166,7 @@ impl From for AbiSelectorRule { /// The transaction must be signed by the root key, or by an active admin key when authorizing for /// the admin key's account. /// -/// RLP encoding: `[chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?, admin_account?]` +/// RLP encoding: `[chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?, is_admin?, account?]` /// - Non-optional fields come first, followed by optional (trailing) fields /// - `expiry`: `None` (omitted or 0x80) = key never expires, `Some(timestamp)` = expires at timestamp /// - `limits`: `None` (omitted or 0x80) = unlimited spending, `Some([])` = no spending, `Some([...])` = specific limits @@ -174,8 +174,7 @@ impl From for AbiSelectorRule { /// `Some([])` = scoped with no allowed calls, `Some([...])` = scoped calls /// - `witness`: `None` (canonically omitted) = no TIP-1053 witness, /// `Some(bytes32)` = arbitrary signed witness checked against the account's burned set. -#[derive(Clone, Debug, PartialEq, Eq, Hash, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable)] -#[rlp(trailing(canonical))] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] #[cfg_attr(test, reth_codecs::add_arbitrary_tests(rlp))] @@ -218,11 +217,15 @@ pub struct KeyAuthorization { /// `witness == B256::ZERO`. pub witness: Option, - /// Account this admin authorization targets. + /// Whether this authorization creates an admin access key. + #[cfg_attr(feature = "serde", serde(default))] + pub is_admin: bool, + + /// Account this authorization targets. /// - /// `None` means this authorization creates a non-admin access key. `Some(account)` means this - /// authorization creates an admin key for `account`. - pub admin_account: Option
, + /// Required for admin-signed authorizations and admin-key creation so signatures cannot be + /// replayed across accounts that share the same admin key. + pub account: Option
, } impl KeyAuthorization { @@ -237,7 +240,8 @@ impl KeyAuthorization { limits: None, allowed_calls: None, witness: None, - admin_account: None, + is_admin: false, + account: None, } } @@ -284,13 +288,20 @@ impl KeyAuthorization { /// Convert this authorization into an account-bound admin-key authorization. pub fn into_admin(mut self, account: Address) -> Self { - self.admin_account = Some(account); + self.is_admin = true; + self.account = Some(account); + self + } + + /// Bind this authorization to a target account without making the authorized key admin. + pub fn with_account(mut self, account: Address) -> Self { + self.account = Some(account); self } /// Returns whether this authorization creates an admin key. pub fn is_admin(&self) -> bool { - self.admin_account.is_some() + self.is_admin } /// Computes the authorization message hash for this key authorization. @@ -332,7 +343,8 @@ impl KeyAuthorization { !(self.has_periodic_limits() || self.has_call_scopes() || self.has_witness() - || self.admin_account.is_some()) + || self.is_admin + || self.account.is_some()) } /// Convert the key authorization into a [`SignedKeyAuthorization`] with a signature. @@ -440,7 +452,8 @@ impl<'a> arbitrary::Arbitrary<'a> for KeyAuthorization { limits: u.arbitrary()?, allowed_calls: u.arbitrary()?, witness: u.arbitrary::>()?.map(B256::from), - admin_account: u.arbitrary()?, + is_admin: u.arbitrary()?, + account: u.arbitrary()?, }) } } @@ -478,6 +491,90 @@ mod rlp { use super::*; use alloy_rlp::{Decodable, Encodable}; + #[derive( + Clone, Debug, PartialEq, Eq, Hash, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable, + )] + #[rlp(trailing(canonical))] + struct KeyAuthorizationWire { + chain_id: u64, + key_type: SignatureType, + key_id: Address, + expiry: Option, + limits: Option>, + allowed_calls: Option>, + witness: Option, + is_admin: Option, + account: Option
, + } + + impl From<&KeyAuthorization> for KeyAuthorizationWire { + fn from(value: &KeyAuthorization) -> Self { + let KeyAuthorization { + chain_id, + key_type, + key_id, + expiry, + limits, + allowed_calls, + witness, + is_admin, + account, + } = value; + + Self { + chain_id: *chain_id, + key_type: *key_type, + key_id: *key_id, + expiry: *expiry, + limits: limits.clone(), + allowed_calls: allowed_calls.clone(), + witness: *witness, + is_admin: is_admin.then_some(NonZeroU64::MIN), + account: *account, + } + } + } + + impl TryFrom for KeyAuthorization { + type Error = alloy_rlp::Error; + + fn try_from(value: KeyAuthorizationWire) -> alloy_rlp::Result { + if value.is_admin.is_some_and(|marker| marker.get() != 1) { + return Err(alloy_rlp::Error::Custom( + "invalid admin key authorization marker", + )); + } + + Ok(Self { + chain_id: value.chain_id, + key_type: value.key_type, + key_id: value.key_id, + expiry: value.expiry, + limits: value.limits, + allowed_calls: value.allowed_calls, + witness: value.witness, + is_admin: value.is_admin.is_some(), + account: value.account, + }) + } + } + + impl Decodable for KeyAuthorization { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + KeyAuthorizationWire::decode(buf).and_then(TryInto::try_into) + } + } + + impl Encodable for KeyAuthorization { + fn encode(&self, out: &mut dyn alloy_rlp::BufMut) { + KeyAuthorizationWire::from(self).encode(out); + } + + fn length(&self) -> usize { + KeyAuthorizationWire::from(self).length() + } + } + #[derive( Clone, Debug, PartialEq, Eq, Hash, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable, )] @@ -582,7 +679,8 @@ mod tests { limits, allowed_calls: None, witness: None, - admin_account: None, + is_admin: false, + account: None, } } @@ -630,7 +728,7 @@ mod tests { } #[test] - fn test_admin_account_roundtrip_and_signature_binding() { + fn test_account_roundtrip_and_signature_binding() { let account = Address::repeat_byte(0x11); let other_account = Address::repeat_byte(0x22); let key_id = Address::repeat_byte(0x33); @@ -640,11 +738,18 @@ mod tests { .with_witness(witness); let admin = normal.clone().into_admin(account); let other_admin = normal.clone().into_admin(other_account); + let account_bound = normal.clone().with_account(account); + let other_account_bound = normal.clone().with_account(other_account); assert!(!normal.is_admin()); assert!(admin.is_admin()); - assert_eq!(admin.admin_account, Some(account)); + assert!(admin.is_admin); + assert_eq!(admin.account, Some(account)); assert!(!admin.is_legacy_compatible()); + assert!(!account_bound.is_admin()); + assert!(!account_bound.is_admin); + assert_eq!(account_bound.account, Some(account)); + assert!(!account_bound.is_legacy_compatible()); let mut encoded = Vec::new(); admin.encode(&mut encoded); @@ -652,10 +757,16 @@ mod tests { ::decode(&mut encoded.as_slice()).expect("decode auth"); assert_eq!(decoded, admin); assert_eq!(decoded.witness(), Some(witness)); - assert_eq!(decoded.admin_account, Some(account)); + assert!(decoded.is_admin); + assert_eq!(decoded.account, Some(account)); assert_ne!(admin.signature_hash(), normal.signature_hash()); assert_ne!(admin.signature_hash(), other_admin.signature_hash()); + assert_ne!(account_bound.signature_hash(), normal.signature_hash()); + assert_ne!( + account_bound.signature_hash(), + other_account_bound.signature_hash() + ); } #[test] @@ -840,7 +951,8 @@ mod tests { limits: None, allowed_calls: None, witness: None, - admin_account: None, + is_admin: false, + account: None, } } diff --git a/crates/primitives/src/transaction/tempo_transaction.rs b/crates/primitives/src/transaction/tempo_transaction.rs index 0c9cffeeeb..805840ba58 100644 --- a/crates/primitives/src/transaction/tempo_transaction.rs +++ b/crates/primitives/src/transaction/tempo_transaction.rs @@ -2160,7 +2160,8 @@ mod compact_tests { }]), allowed_calls: None, witness: None, - admin_account: None, + is_admin: false, + account: None, }, signature: PrimitiveSignature::P256(P256SignatureWithPreHash { r: b256!("0x1111111111111111111111111111111111111111111111111111111111111111"), diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index abe4ceaa76..86fdb9f9c8 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1276,6 +1276,13 @@ where .recover_signer() .map_err(|_| TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed)?; if auth_signer != tx.caller { + if key_auth.account != Some(tx.caller) { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: "admin-signed key authorization account mismatch".to_string(), + } + .into()); + } + let is_admin = StorageCtx::enter_precompile( journal, block, @@ -1731,15 +1738,17 @@ where } } - if key_auth.admin_account.is_some() { + if key_auth.is_admin || key_auth.account.is_some() { if !cfg.spec.is_t6() { return Err(TempoInvalidTransaction::KeychainValidationFailed { - reason: "admin key authorization fields are not active before T6" + reason: "T6 key authorization fields are not active before T6" .to_string(), } .into()); } + } + if key_auth.is_admin() { if key_auth.expiry.is_some() || key_auth.limits.is_some() || key_auth.allowed_calls.is_some() @@ -1751,7 +1760,7 @@ where .into()); } - if key_auth.admin_account != Some(tx.caller) { + if key_auth.account != Some(tx.caller) { return Err(TempoInvalidTransaction::KeychainValidationFailed { reason: "admin key authorization account mismatch".to_string(), } @@ -5133,6 +5142,116 @@ mod tests { }); } + #[test] + fn test_t6_admin_access_key_non_admin_authorization_requires_account_binding() { + let (admin_signer, admin_key) = generate_keypair(); + let user = Address::random(); + let child_key = Address::random(); + let signed = sign_key_auth( + &admin_signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, child_key), + ); + let (mut evm, h) = make_evm( + user, + admin_key, + Some(signed), + TempoHardfork::T6, + None, + false, + ); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { + let mut keychain = AccountKeychain::new(); + keychain + .authorize_admin_key(user, admin_key, PrecompileSignatureType::Secp256k1, None) + .expect("root authorizes admin key"); + }); + + let result = + h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); + assert!( + matches!( + &result, + Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) + if reason.contains("admin-signed key authorization account mismatch") + ), + "admin-signed non-admin authorization without account binding should fail, got: {result:?}" + ); + } + + #[test] + fn test_t6_admin_access_key_non_admin_authorization_rejects_account_replay() { + use tempo_precompiles::account_keychain::getKeyCall; + + let (admin_signer, admin_key) = generate_keypair(); + let alice = Address::random(); + let bob = Address::random(); + let child_key = Address::random(); + let signed = sign_key_auth( + &admin_signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, child_key) + .with_account(alice), + ); + + let (mut alice_evm, alice_handler) = make_evm( + alice, + admin_key, + Some(signed.clone()), + TempoHardfork::T6, + None, + false, + ); + StorageCtx::enter_ctx(&mut alice_evm.inner.ctx, || { + let mut keychain = AccountKeychain::new(); + keychain + .authorize_admin_key(alice, admin_key, PrecompileSignatureType::Secp256k1, None) + .expect("root authorizes Alice admin key"); + }); + + let alice_result = alice_handler + .validate_against_state_and_deduct_caller(&mut alice_evm, &mut Default::default()); + assert!( + alice_result.is_ok(), + "account-bound admin-signed non-admin authorization should pass for Alice, got: {alice_result:?}" + ); + StorageCtx::enter_ctx(&mut alice_evm.inner.ctx, || { + let keychain = AccountKeychain::new(); + let key = keychain + .get_key(getKeyCall { + account: alice, + keyId: child_key, + }) + .expect("child key read succeeds"); + assert_eq!(key.keyId, child_key, "child key should be registered"); + assert!( + !keychain + .is_admin_key_for(alice, child_key) + .expect("admin key status read succeeds"), + "child key should not be admin" + ); + }); + + let (mut bob_evm, bob_handler) = + make_evm(bob, admin_key, Some(signed), TempoHardfork::T6, None, false); + StorageCtx::enter_ctx(&mut bob_evm.inner.ctx, || { + let mut keychain = AccountKeychain::new(); + keychain + .authorize_admin_key(bob, admin_key, PrecompileSignatureType::Secp256k1, None) + .expect("root authorizes Bob admin key"); + }); + + let bob_result = bob_handler + .validate_against_state_and_deduct_caller(&mut bob_evm, &mut Default::default()); + assert!( + matches!( + &bob_result, + Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) + if reason.contains("admin-signed key authorization account mismatch") + ), + "Alice-bound authorization should not replay for Bob, got: {bob_result:?}" + ); + } + #[test] fn test_keychain_signature_with_valid_authorized_key() { let (mut evm, h) = make_evm( diff --git a/tips/tip-1049.md b/tips/tip-1049.md index c5be1ad3dd..be586d4d24 100644 --- a/tips/tip-1049.md +++ b/tips/tip-1049.md @@ -143,18 +143,19 @@ Revocation of an admin key does NOT automatically revoke keys that it authorized ## Transaction Encoding -`KeyAuthorization` remains an RLP list. TIP-1053 adds `witness?`; TIP-1049 adds the `admin_account?` trailing optional field after it: +`KeyAuthorization` remains an RLP list. TIP-1053 adds `witness?`; TIP-1049 adds the `is_admin?` and `account?` trailing optional fields after it: ``` -rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?, admin_account?]) +rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?, is_admin?, account?]) ``` -- If `admin_account` is omitted, the authorization creates a non-admin key. -- If `admin_account` is present, the authorization creates an admin key and `admin_account` MUST equal the target account being modified. -- If `admin_account` is present, `expiry`, `limits`, and `allowed_calls` MUST be omitted or encoded as empty optional values because admin keys carry no restrictions. -- If `admin_account` is present, `witness` MAY be present or omitted for the transaction-encoded authorization. ABI calls to `authorizeAdminKey` always provide a `bytes32 witness`, including `bytes32(0)`. +- If `is_admin` is omitted, the authorization creates a non-admin key. +- If `is_admin` is `true`, the authorization creates an admin key and `account` MUST be present and equal the target account being modified. +- If the authorization is signed by an admin access key, `account` MUST be present and equal the target account being modified, even when `is_admin` is omitted. +- If `is_admin` is `true`, `expiry`, `limits`, and `allowed_calls` MUST be omitted or encoded as empty optional values because admin keys carry no restrictions. +- If `account` is present, `witness` MAY be present or omitted for the transaction-encoded authorization. ABI calls to `authorizeAdminKey` always provide a `bytes32 witness`, including `bytes32(0)`. -The target `admin_account` and `witness` fields are part of the signed RLP payload. This binds admin key authorizations to a target account and prevents replaying an unrestricted non-admin authorization as an admin authorization. +The target `account`, `is_admin`, and `witness` fields are part of the signed RLP payload. This binds admin-signed key authorizations to a target account and prevents replaying an unrestricted non-admin authorization as an admin authorization. # Invariants From 53153f03815794abd0537302d043c0cdd55e75ff Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 18:27:27 +0530 Subject: [PATCH 06/39] fix(account-keychain): preserve admin signer context --- crates/revm/src/handler.rs | 161 +++++++++++++++++++++++++++++++------ 1 file changed, 138 insertions(+), 23 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 86fdb9f9c8..96847123b2 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1155,7 +1155,9 @@ where .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? }; - if cfg.spec.is_t6() && access_key_addr != key_auth.key_id { + let same_tx_auth_use = access_key_addr == key_auth.key_id; + + if cfg.spec.is_t6() && !same_tx_auth_use { let stored_key_expiry = StorageCtx::enter_precompile( journal, block, @@ -1200,6 +1202,7 @@ where // `collectFeePreTx` would not validate the spending limit because the key is not authorized yet and we are not setting the transient key_id. if !gas_balance_spending.is_zero() && fee_payer == tx.caller + && same_tx_auth_use && let Some(limits) = key_auth.limits.as_ref() { let remaining = limits @@ -1515,30 +1518,43 @@ where }; } - // If this is a same tx auth+use, we need to set the transient key_id and decrement the fee from the spending limit. - if tempo_tx_env.signature.is_keychain() { - StorageCtx::enter_precompile( - journal, - block, - cfg, - tx, - |mut keychain: AccountKeychain| { - keychain - .set_transaction_key(key_auth.key_id) - .map_err(|e| EVMError::Custom(e.to_string()))?; + // If this is a same tx auth+use, set the transient key_id to the newly authorized + // key and decrement the fee from its spending limit. Admin delegation must keep the + // actual signer as the transaction key. + if let Some(keychain_sig) = tempo_tx_env.signature.as_keychain() { + let access_key_addr = if let Some(override_key_id) = tempo_tx_env.override_key_id { + override_key_id + } else { + keychain_sig + .key_id(&tempo_tx_env.signature_hash) + .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? + }; - if evm.collected_fee.is_zero() { - return Ok(()); - } + let same_tx_auth_use = access_key_addr == key_auth.key_id; + if same_tx_auth_use { + StorageCtx::enter_precompile( + journal, + block, + cfg, + tx, + |mut keychain: AccountKeychain| { + keychain + .set_transaction_key(key_auth.key_id) + .map_err(|e| EVMError::Custom(e.to_string()))?; - keychain - .authorize_transfer(fee_payer, fee_token, evm.collected_fee) - .map_err(|err| match err { - TempoPrecompileError::Fatal(err) => EVMError::Custom(err), - err => FeePaymentError::Other(err.to_string()).into(), - }) - }, - )?; + if evm.collected_fee.is_zero() { + return Ok(()); + } + + keychain + .authorize_transfer(fee_payer, fee_token, evm.collected_fee) + .map_err(|err| match err { + TempoPrecompileError::Fatal(err) => EVMError::Custom(err), + err => FeePaymentError::Other(err.to_string()).into(), + }) + }, + )?; + } } } @@ -5252,6 +5268,105 @@ mod tests { ); } + #[test] + fn test_t6_admin_delegation_does_not_apply_child_fee_limit() { + let (admin_signer, admin_key) = generate_keypair(); + let user = Address::random(); + let child_key = Address::random(); + let gas_limit = 100_000; + let fee = U256::from(gas_limit); + let child_spending_limit = fee - U256::ONE; + + let signed = sign_key_auth( + &admin_signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, child_key) + .with_limits(vec![PrimTokenLimit { + token: DEFAULT_FEE_TOKEN, + limit: child_spending_limit, + period: 60, + }]) + .with_account(user), + ); + let (mut evm, h) = make_evm( + user, + admin_key, + Some(signed), + TempoHardfork::T6, + None, + false, + ); + evm.inner.ctx.tx.inner.gas_limit = gas_limit; + evm.inner.ctx.tx.inner.gas_price = 1_000_000_000_000; + evm.inner.ctx.tx.inner.gas_priority_fee = Some(1_000_000_000_000); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { + TIP20Setup::path_usd(user) + .with_issuer(user) + .with_mint(user, fee * U256::from(2)) + .apply() + .expect("pathUSD setup succeeds"); + + let mut keychain = AccountKeychain::new(); + keychain + .authorize_admin_key(user, admin_key, PrecompileSignatureType::Secp256k1, None) + .expect("root authorizes admin key"); + }); + + let result = + h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); + assert!( + result.is_ok(), + "admin delegation should not precharge fees against child key limits, got: {result:?}" + ); + } + + #[test] + fn test_t6_admin_delegation_preserves_admin_transaction_key() { + use tempo_precompiles::account_keychain::getTransactionKeyCall; + + let (admin_signer, admin_key) = generate_keypair(); + let user = Address::random(); + let child_key = Address::random(); + let signed = sign_key_auth( + &admin_signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, child_key) + .with_account(user), + ); + let (mut evm, h) = make_evm( + user, + admin_key, + Some(signed), + TempoHardfork::T6, + None, + false, + ); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { + let mut keychain = AccountKeychain::new(); + keychain + .authorize_admin_key(user, admin_key, PrecompileSignatureType::Secp256k1, None) + .expect("root authorizes admin key"); + }); + + let result = + h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); + assert!( + result.is_ok(), + "admin delegation should pass, got: {result:?}" + ); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { + let keychain = AccountKeychain::new(); + let transaction_key = keychain + .get_transaction_key(getTransactionKeyCall {}, user) + .expect("transaction key read succeeds"); + assert_eq!( + transaction_key, admin_key, + "admin delegation must preserve the signer key as transaction key" + ); + }); + } + #[test] fn test_keychain_signature_with_valid_authorized_key() { let (mut evm, h) = make_evm( From 8c3313e13530db0c2593040a6c9090c92320a4f1 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 19:33:03 +0530 Subject: [PATCH 07/39] fix(account-keychain): validate admin sidecar key type --- crates/revm/src/handler.rs | 49 +++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 96847123b2..7248f412d4 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1286,19 +1286,24 @@ where .into()); } - let is_admin = StorageCtx::enter_precompile( + let admin_key = StorageCtx::enter_precompile( journal, block, cfg, tx, |keychain: AccountKeychain| { keychain - .is_admin_key_for(tx.caller, auth_signer) + .validate_keychain_authorization( + tx.caller, + auth_signer, + block.timestamp().to::(), + Some(key_auth.signature.signature_type().into()), + ) .map_err(|e| EVMError::Custom(e.to_string())) }, )?; - if !is_admin { + if !admin_key.is_admin { return Err(TempoInvalidTransaction::KeyAuthorizationNotSignedByRoot { expected: tx.caller, actual: auth_signer, @@ -5195,6 +5200,44 @@ mod tests { ); } + #[test] + fn test_t6_admin_key_authorization_rejects_admin_signature_type_mismatch() { + let (admin_signer, admin_key) = generate_keypair(); + let user = Address::random(); + let child_key = Address::random(); + let signed = sign_key_auth( + &admin_signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, child_key) + .with_account(user), + ); + let (mut evm, h) = make_evm( + user, + admin_key, + Some(signed), + TempoHardfork::T6, + None, + false, + ); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { + let mut keychain = AccountKeychain::new(); + keychain + .authorize_admin_key(user, admin_key, PrecompileSignatureType::WebAuthn, None) + .expect("root authorizes WebAuthn admin key"); + }); + + let result = + h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); + assert!( + matches!( + &result, + Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) + if reason.contains("SignatureTypeMismatch") + ), + "admin-signed key authorization should reject sidecar signature type mismatch, got: {result:?}" + ); + } + #[test] fn test_t6_admin_access_key_non_admin_authorization_rejects_account_replay() { use tempo_precompiles::account_keychain::getKeyCall; From eb3b8873ff57f45b8b7665af15a402ec3dbaa680 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 20:18:27 +0530 Subject: [PATCH 08/39] fix(account-keychain): price admin key auth validation --- crates/revm/src/handler.rs | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 7248f412d4..4323e3eb4f 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -365,6 +365,12 @@ fn calculate_key_authorization_gas( let sstore_cost = gas_params.get(GasId::sstore_set_without_load_cost()); let mut regular_gas = sig_gas + sload_cost + sstore_cost * num_sstores + BUFFER; + // T6 account-bound authorizations may be signed by an existing admin key instead of the + // root key. Charge one worst-case cold read for validating that admin signer row. + if spec.is_t6() && (key_auth.is_admin || key_auth.account.is_some()) { + total_gas += sload_cost; + } + if has_t5_witness { regular_gas += sload_cost + KEY_AUTH_T5_WITNESS_EVENT_BUFFER; } @@ -3317,6 +3323,48 @@ mod tests { "T5 witness authorization does not add state gas" ); + let t6_gas_params = crate::gas_params::tempo_gas_params(TempoHardfork::T6); + let t6_sload = + t6_gas_params.warm_storage_read_cost() + t6_gas_params.cold_storage_additional_cost(); + let base_t6_key_auth = create_key_auth(0); + let mut account_bound_t6_key_auth = create_key_auth(0); + account_bound_t6_key_auth.authorization = account_bound_t6_key_auth + .authorization + .with_account(Address::random()); + let mut admin_t6_key_auth = create_key_auth(0); + admin_t6_key_auth.authorization = admin_t6_key_auth + .authorization + .into_admin(Address::random()); + + let (base_t6_gas, base_t6_state_gas) = + calculate_key_authorization_gas(&base_t6_key_auth, &t6_gas_params, TempoHardfork::T6); + let (account_bound_t6_gas, account_bound_t6_state_gas) = calculate_key_authorization_gas( + &account_bound_t6_key_auth, + &t6_gas_params, + TempoHardfork::T6, + ); + let (admin_t6_gas, admin_t6_state_gas) = + calculate_key_authorization_gas(&admin_t6_key_auth, &t6_gas_params, TempoHardfork::T6); + + assert_eq!( + account_bound_t6_gas - base_t6_gas, + t6_sload, + "T6 account-bound authorization charges one admin-signer cold read" + ); + assert_eq!( + admin_t6_gas - base_t6_gas, + t6_sload, + "T6 admin authorization charges one admin-signer cold read" + ); + assert_eq!( + account_bound_t6_state_gas, base_t6_state_gas, + "T6 admin-signer read does not add state gas" + ); + assert_eq!( + admin_t6_state_gas, base_t6_state_gas, + "T6 admin-signer read does not add state gas" + ); + let scoped = SignedKeyAuthorization { authorization: KeyAuthorization::unrestricted( 1, From 3be88b71c03045df258507e00cbafdb4bfc899e7 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 20:25:00 +0530 Subject: [PATCH 09/39] fix(account-keychain): reject root key restrictions --- .../precompiles/src/account_keychain/mod.rs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index d67ce0f1fa..d82c5335b0 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -461,6 +461,10 @@ impl AccountKeychain { ) -> Result<()> { self.ensure_admin_caller(msg_sender)?; + if self.storage.spec().is_t6() && call.keyId == msg_sender { + return Err(AccountKeychainError::invalid_key_id().into()); + } + let current_timestamp = self.storage.timestamp().saturating_to::(); let mut key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; if self.storage.spec().is_t6() && key.is_admin { @@ -579,6 +583,10 @@ impl AccountKeychain { self.ensure_admin_caller(msg_sender)?; + if self.storage.spec().is_t6() && call.keyId == msg_sender { + return Err(AccountKeychainError::invalid_key_id().into()); + } + let current_timestamp = self.storage.timestamp().saturating_to::(); let key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; if self.storage.spec().is_t6() && key.is_admin { @@ -609,6 +617,10 @@ impl AccountKeychain { ) -> Result<()> { self.ensure_admin_caller(msg_sender)?; + if self.storage.spec().is_t6() && call.keyId == msg_sender { + return Err(AccountKeychainError::invalid_key_id().into()); + } + let current_timestamp = self.storage.timestamp().saturating_to::(); let key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; if self.storage.spec().is_t6() && key.is_admin { @@ -1783,6 +1795,82 @@ mod tests { }) } + #[test] + fn test_t6_restriction_mutators_reject_root_slot() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let account = Address::random(); + let token = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + + assert_invalid_key_id( + keychain + .update_spending_limit( + account, + updateSpendingLimitCall { + keyId: account, + token, + newLimit: U256::from(1), + }, + ) + .expect_err("root slot cannot receive spending limits"), + ); + + assert_invalid_key_id( + keychain + .set_allowed_calls( + account, + setAllowedCallsCall { + keyId: account, + scopes: vec![CallScope { + target: Address::random(), + selectorRules: vec![], + }], + }, + ) + .expect_err("root slot cannot receive call scopes"), + ); + + assert_invalid_key_id( + keychain + .remove_allowed_calls( + account, + removeAllowedCallsCall { + keyId: account, + target: Address::random(), + }, + ) + .expect_err("root slot cannot remove call scopes"), + ); + + keychain.keys[account][account].write(AuthorizedKey { + signature_type: SignatureType::Secp256k1 as u8, + expiry: u64::MAX, + enforce_limits: false, + is_revoked: false, + is_admin: false, + })?; + + assert_invalid_key_id( + keychain + .update_spending_limit( + account, + updateSpendingLimitCall { + keyId: account, + token, + newLimit: U256::from(1), + }, + ) + .expect_err("legacy self-key row cannot receive spending limits"), + ); + + Ok(()) + }) + } + #[test] fn test_t6_existing_key_cannot_become_admin() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); From ed9182f7130c2a7f0ae95d82e5989208f8ea8eae Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 20:37:30 +0530 Subject: [PATCH 10/39] fix(txpool): evict revoked key authorization signers --- crates/transaction-pool/src/paused.rs | 87 +++++++++++++++++-- crates/transaction-pool/src/tempo_pool.rs | 98 +++++++++++++++++++++- crates/transaction-pool/src/transaction.rs | 20 +++++ 3 files changed, 198 insertions(+), 7 deletions(-) diff --git a/crates/transaction-pool/src/paused.rs b/crates/transaction-pool/src/paused.rs index 8c6a4306a3..e129363c43 100644 --- a/crates/transaction-pool/src/paused.rs +++ b/crates/transaction-pool/src/paused.rs @@ -219,23 +219,37 @@ impl PausedFeeTokenPool { for meta in self.by_token.values_mut() { let before = meta.entries.len(); meta.entries.retain(|entry| { + let key_authorization_subject = (!revoked_keys.is_empty()) + .then(|| entry.tx.transaction.key_authorization_signer_subject()) + .flatten(); + let Some(subject) = entry.tx.transaction.keychain_subject() else { let Some(witness_subject) = entry.tx.transaction.key_authorization_witness_subject() else { - return true; + return !key_authorization_subject + .as_ref() + .is_some_and(|subject| subject.matches_revoked(revoked_keys)); }; - return !key_authorization_witness_burns - .get(&witness_subject.account) - .is_some_and(|witnesses| witnesses.contains(&witness_subject.witness)); + return !key_authorization_subject + .as_ref() + .is_some_and(|subject| subject.matches_revoked(revoked_keys)) + && !key_authorization_witness_burns + .get(&witness_subject.account) + .is_some_and(|witnesses| witnesses.contains(&witness_subject.witness)); }; let matches_limit_update = subject.matches_spending_limit_update(spending_limit_updates); let sender_paid = matches_limit_update && entry.tx.transaction.is_sender_paid_fee(); - if subject.matches_revoked(revoked_keys) || (sender_paid && matches_limit_update) { + if subject.matches_revoked(revoked_keys) + || key_authorization_subject + .as_ref() + .is_some_and(|subject| subject.matches_revoked(revoked_keys)) + || (sender_paid && matches_limit_update) + { return false; } @@ -564,6 +578,69 @@ mod tests { ); } + #[test] + fn test_evict_invalidated_with_revoked_key_authorization_signer() { + let mut pool = PausedFeeTokenPool::new(); + let user_address = Address::random(); + let fee_token = Address::random(); + let admin_signer = PrivateKeySigner::random(); + let admin_key = alloy_signer::Signer::address(&admin_signer); + let other_signer = PrivateKeySigner::random(); + + let key_authorization = |signer: &PrivateKeySigner| { + let authorization = + KeyAuthorization::unrestricted(42431, SignatureType::Secp256k1, Address::random()) + .with_account(user_address); + let signature = signer + .sign_hash_sync(&authorization.signature_hash()) + .expect("key authorization signing should succeed"); + authorization.into_signed(PrimitiveSignature::Secp256k1(signature)) + }; + + let matching = Arc::new(wrap_valid_tx( + TxBuilder::aa(user_address) + .fee_token(fee_token) + .key_authorization(key_authorization(&admin_signer)) + .build(), + TransactionOrigin::External, + )); + let untouched = Arc::new(wrap_valid_tx( + TxBuilder::aa(user_address) + .nonce(1) + .fee_token(fee_token) + .key_authorization(key_authorization(&other_signer)) + .build(), + TransactionOrigin::External, + )); + + pool.insert_batch( + fee_token, + vec![ + PausedEntry { + tx: matching, + valid_before: None, + }, + PausedEntry { + tx: untouched, + valid_before: None, + }, + ], + ); + + let mut revoked_keys = RevokedKeys::new(); + revoked_keys.insert(user_address, admin_key); + + let evicted = pool.evict_invalidated( + &revoked_keys, + &SpendingLimitUpdates::new(), + &SpendingLimitUpdates::new(), + &AddressMap::default(), + ); + + assert_eq!(evicted, 1); + assert_eq!(pool.len(), 1); + } + #[test] fn test_contains() { let mut pool = PausedFeeTokenPool::new(); diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index 9285e007ad..22d7cb0132 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -208,11 +208,18 @@ where // Avoid recovering key ids unless a keychain invalidation can use them. if has_keychain_subject_updates { let keychain_subject = tx.transaction.keychain_subject(); + let key_authorization_subject = (!updates.revoked_keys.is_empty()) + .then(|| tx.transaction.key_authorization_signer_subject()) + .flatten(); // Check 1: Revoked keychain keys if !updates.revoked_keys.is_empty() - && let Some(ref subject) = keychain_subject - && subject.matches_revoked(&updates.revoked_keys) + && (keychain_subject + .as_ref() + .is_some_and(|subject| subject.matches_revoked(&updates.revoked_keys)) + || key_authorization_subject + .as_ref() + .is_some_and(|subject| subject.matches_revoked(&updates.revoked_keys))) { to_remove.push(*tx.hash()); revoked_count += 1; @@ -2022,6 +2029,93 @@ mod tests { assert!(pool.get(untouched.hash()).is_some()); } + #[tokio::test] + async fn evicts_transactions_with_revoked_key_authorization_signer() { + let sender = Address::random(); + let admin_signer = PrivateKeySigner::random(); + let admin_key = alloy_signer::Signer::address(&admin_signer); + let other_signer = PrivateKeySigner::random(); + + let key_authorization = |signer: &PrivateKeySigner| { + let authorization = + KeyAuthorization::unrestricted(42431, SignatureType::Secp256k1, Address::random()) + .with_account(sender); + let signature = signer + .sign_hash_sync(&authorization.signature_hash()) + .expect("key authorization signing should succeed"); + authorization.into_signed(PrimitiveSignature::Secp256k1(signature)) + }; + + let matching = crate::test_utils::TxBuilder::aa(sender) + .nonce(0) + .key_authorization(key_authorization(&admin_signer)) + .build(); + let untouched = crate::test_utils::TxBuilder::aa(sender) + .nonce(1) + .key_authorization(key_authorization(&other_signer)) + .build(); + + let provider = MockEthProvider::::new() + .with_chain_spec(std::sync::Arc::unwrap_or_clone(MODERATO.clone())); + provider.add_account(sender, ExtendedAccount::new(matching.nonce(), U256::MAX)); + provider.add_block( + B256::random(), + Block { + header: TempoHeader { + inner: Header { + gas_limit: TEMPO_T1_TX_GAS_LIMIT_CAP, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + ); + + let inner = + EthTransactionValidatorBuilder::new(provider.clone(), TempoEvmConfig::mainnet()) + .disable_balance_check() + .build(InMemoryBlobStore::default()); + let amm_cache = + AmmLiquidityCache::new(provider).expect("failed to setup AmmLiquidityCache"); + let validator = TempoTransactionValidator::new( + inner, + crate::validator::DEFAULT_AA_VALID_AFTER_MAX_SECS, + crate::validator::DEFAULT_MAX_TEMPO_AUTHORIZATIONS, + amm_cache, + ); + + let (executor, _task) = TransactionValidationTaskExecutor::new(validator); + let protocol_pool = Pool::new( + executor, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + PoolConfig::default(), + ); + let pool = TempoTransactionPool::new(protocol_pool, AA2dPool::new(Default::default())); + + for pooled in [&matching, &untouched] { + let validated = TransactionValidationOutcome::Valid { + balance: *pooled.cost(), + state_nonce: pooled.nonce(), + bytecode_hash: None, + transaction: ValidTransaction::new(pooled.clone(), None), + propagate: true, + authorities: None, + }; + pool.add_validated_transaction(TransactionOrigin::External, validated) + .expect("transaction should be admitted"); + } + + let mut updates = crate::maintain::TempoPoolUpdates::new(); + updates.revoked_keys.insert(sender, admin_key); + + let evicted = pool.evict_invalidated_transactions(&updates); + assert_eq!(evicted, vec![*matching.hash()]); + assert!(pool.get(matching.hash()).is_none()); + assert!(pool.get(untouched.hash()).is_some()); + } + /// Eviction must match sub-policy IDs against compound policies. /// When a token uses a compound policy, and a sub-policy event fires, /// the eviction comparison must detect the match. diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 34007fea4e..d45f637712 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -172,6 +172,26 @@ impl TempoPooledTransaction { }) } + /// 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 + /// revoked while the transaction is still in the pool, the transaction must be revalidated. + pub fn key_authorization_signer_subject(&self) -> Option { + let aa_tx = self.inner().as_aa()?; + let key_authorization = aa_tx.tx().key_authorization.as_ref()?; + let key_id = key_authorization.recover_signer().ok()?; + let account = key_authorization + .authorization + .account + .unwrap_or(*self.sender_ref()); + let fee_token = self.effective_fee_token(); + Some(KeychainSubject { + account, + key_id, + fee_token, + }) + } + /// Extracts the TIP-1053 key-authorization witness carried by this transaction, if any. pub fn key_authorization_witness_subject(&self) -> Option { let aa_tx = self.inner().as_aa()?; From c8b62374721aeea3c3730f69e245aeeff9837a46 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 20:40:19 +0530 Subject: [PATCH 11/39] chore(keychain): address admin key review nits --- .changelog/admin-access-keys.md | 7 ++ .../src/account_keychain/dispatch.rs | 4 +- .../precompiles/src/account_keychain/mod.rs | 99 ++++++++++--------- crates/revm/src/handler.rs | 4 +- 4 files changed, 62 insertions(+), 52 deletions(-) create mode 100644 .changelog/admin-access-keys.md diff --git a/.changelog/admin-access-keys.md b/.changelog/admin-access-keys.md new file mode 100644 index 0000000000..75f1eb44a4 --- /dev/null +++ b/.changelog/admin-access-keys.md @@ -0,0 +1,7 @@ +--- +tempo-contracts: minor +tempo-primitives: minor +tempo-alloy: minor +--- + +Added T6 admin access key support for account keychain authorization and SDK transaction builders. diff --git a/crates/precompiles/src/account_keychain/dispatch.rs b/crates/precompiles/src/account_keychain/dispatch.rs index 5f9aaf3781..f9a02b25ad 100644 --- a/crates/precompiles/src/account_keychain/dispatch.rs +++ b/crates/precompiles/src/account_keychain/dispatch.rs @@ -138,7 +138,9 @@ impl Precompile for AccountKeychain { IAccountKeychainCalls::isKeyAuthorizationWitnessBurned(call) => { view(call, |c| self.is_key_authorization_witness_burned(c)) } - IAccountKeychainCalls::isAdminKey(call) => view(call, |c| self.is_admin_key(c)), + IAccountKeychainCalls::isAdminKey(call) => { + view(call, |c| self.is_admin_key(c.account, c.keyId)) + } IAccountKeychainCalls::getTransactionKey(call) => { view(call, |c| self.get_transaction_key(c, msg_sender)) } diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index d82c5335b0..19320da830 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -1,8 +1,8 @@ //! [Account keychain] precompile for managing session keys and spending limits. //! //! Each account can authorize secondary keys (session keys) with per-token spending caps, -//! signature type constraints, and expiry. The main key (address zero) retains full control -//! and is the only key allowed to authorize, revoke, or update other keys. +//! signature type constraints, and expiry. The main key (address zero) retains full control; +//! T6 admin keys can also manage other access keys. //! //! [Account keychain]: @@ -17,7 +17,7 @@ pub use tempo_contracts::precompiles::{ IAccountKeychain::{ CallScope, KeyInfo, KeyRestrictions, SelectorRule, SignatureType, TokenLimit, burnKeyAuthorizationWitnessCall, getAllowedCallsCall, getKeyCall, getRemainingLimitCall, - getRemainingLimitWithPeriodCall, getTransactionKeyCall, isAdminKeyCall, + getRemainingLimitWithPeriodCall, getTransactionKeyCall, isKeyAuthorizationWitnessBurnedCall, removeAllowedCallsCall, revokeKeyCall, setAllowedCallsCall, updateSpendingLimitCall, }, @@ -70,6 +70,40 @@ pub struct AuthorizedKey { pub is_admin: bool, } +#[derive(Debug, Clone, Copy)] +struct StoredSignatureType(u8); + +impl StoredSignatureType { + #[inline] + const fn as_u8(self) -> u8 { + self.0 + } +} + +impl TryFrom for StoredSignatureType { + type Error = crate::error::TempoPrecompileError; + + fn try_from(value: SignatureType) -> std::result::Result { + match value { + SignatureType::Secp256k1 => Ok(Self(0)), + SignatureType::P256 => Ok(Self(1)), + SignatureType::WebAuthn => Ok(Self(2)), + _ => Err(AccountKeychainError::invalid_signature_type().into()), + } + } +} + +impl From for SignatureType { + fn from(value: StoredSignatureType) -> Self { + match value.0 { + 0 => SignatureType::Secp256k1, + 1 => SignatureType::P256, + 2 => SignatureType::WebAuthn, + _ => SignatureType::Secp256k1, + } + } +} + /// Account Keychain contract for managing authorized keys (session keys, spending limits). /// /// The struct fields define the on-chain storage layout; the `#[contract]` macro generates the @@ -194,7 +228,7 @@ impl AccountKeychain { } /// Registers a new access key with signature type, expiry, and optional per-token spending - /// limits. Only callable with the account's main key (not a session key). + /// limits. Only callable with the account's main key or, on T6+, an admin access key. /// /// # Errors /// - `UnauthorizedCaller` — only the main key can authorize/revoke and, for contract @@ -203,6 +237,7 @@ impl AccountKeychain { /// - `ExpiryInPast` — expiry must be in the future (enforced since T0) /// - `KeyAlreadyExists` — a key with this ID is already registered /// - `KeyAlreadyRevoked` — revoked keys cannot be re-authorized + /// - `InvalidKeyId` — on T6+, `keyId` cannot be the account root key /// - `InvalidSignatureType` — must be Secp256k1, P256, or WebAuthn pub fn authorize_key( &mut self, @@ -243,13 +278,7 @@ impl AccountKeychain { return Err(AccountKeychainError::key_already_revoked().into()); } - // Convert SignatureType enum to u8 for storage - let signature_type = match signature_type { - SignatureType::Secp256k1 => 0, - SignatureType::P256 => 1, - SignatureType::WebAuthn => 2, - _ => return Err(AccountKeychainError::invalid_signature_type().into()), - }; + let signature_type = StoredSignatureType::try_from(signature_type)?.as_u8(); // TIP-1011 fields are hardfork-gated at T3, so reject them before mutating state. let allowed_call_configs = if is_t3 { @@ -362,12 +391,7 @@ impl AccountKeychain { self.ensure_key_authorization_witness_not_burned(msg_sender, witness)?; } - let signature_type = match signature_type { - SignatureType::Secp256k1 => 0, - SignatureType::P256 => 1, - SignatureType::WebAuthn => 2, - _ => return Err(AccountKeychainError::invalid_signature_type().into()), - }; + let signature_type = StoredSignatureType::try_from(signature_type)?.as_u8(); self.keys[msg_sender][key_id].write(AuthorizedKey { signature_type, @@ -467,7 +491,7 @@ impl AccountKeychain { let current_timestamp = self.storage.timestamp().saturating_to::(); let mut key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; - if self.storage.spec().is_t6() && key.is_admin { + if key.is_admin { return Err(AccountKeychainError::invalid_key_id().into()); } @@ -516,16 +540,8 @@ impl AccountKeychain { }); } - // Convert u8 signature_type to SignatureType enum - let signature_type = match key.signature_type { - 0 => SignatureType::Secp256k1, - 1 => SignatureType::P256, - 2 => SignatureType::WebAuthn, - _ => SignatureType::Secp256k1, // Default fallback - }; - Ok(KeyInfo { - signatureType: signature_type, + signatureType: StoredSignatureType(key.signature_type).into(), keyId: call.keyId, expiry: key.expiry, enforceLimits: key.enforce_limits, @@ -589,7 +605,7 @@ impl AccountKeychain { let current_timestamp = self.storage.timestamp().saturating_to::(); let key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; - if self.storage.spec().is_t6() && key.is_admin { + if key.is_admin { return Err(AccountKeychainError::invalid_key_id().into()); } @@ -623,7 +639,7 @@ impl AccountKeychain { let current_timestamp = self.storage.timestamp().saturating_to::(); let key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; - if self.storage.spec().is_t6() && key.is_admin { + if key.is_admin { return Err(AccountKeychainError::invalid_key_id().into()); } @@ -722,10 +738,6 @@ impl AccountKeychain { } /// Returns true for the root key or for an active admin access key. - pub fn is_admin_key(&self, call: isAdminKeyCall) -> Result { - self.is_admin_key_for(call.account, call.keyId) - } - /// Returns the access key used to authorize the current transaction (`Address::ZERO` = root key). pub fn get_transaction_key( &self, @@ -1108,9 +1120,7 @@ impl AccountKeychain { fn ensure_admin_caller(&self, msg_sender: Address) -> Result<()> { let transaction_key = self.transaction_key.t_read()?; if !transaction_key.is_zero() { - if !self.storage.spec().is_t6() - || !self.is_admin_key_for(msg_sender, transaction_key)? - { + if !self.storage.spec().is_t6() || !self.is_admin_key(msg_sender, transaction_key)? { return Err(AccountKeychainError::unauthorized_caller().into()); } } @@ -1126,7 +1136,7 @@ impl AccountKeychain { } /// Internal predicate for root/admin status. - pub fn is_admin_key_for(&self, account: Address, key_id: Address) -> Result { + pub fn is_admin_key(&self, account: Address, key_id: Address) -> Result { if key_id == account { return Ok(true); } @@ -1626,14 +1636,8 @@ mod tests { assert!(!key.enforce_limits); assert!(!key.is_revoked); assert!(key.is_admin); - assert!(keychain.is_admin_key(isAdminKeyCall { - account, - keyId: account - })?); - assert!(keychain.is_admin_key(isAdminKeyCall { - account, - keyId: admin_key - })?); + assert!(keychain.is_admin_key(account, account)?); + assert!(keychain.is_admin_key(account, admin_key)?); Ok(()) }) @@ -1707,10 +1711,7 @@ mod tests { assert!(keychain.keys[account][child_key].read()?.expiry > 0); keychain.revoke_key(account, revokeKeyCall { keyId: admin_key })?; - assert!(!keychain.is_admin_key(isAdminKeyCall { - account, - keyId: admin_key - })?); + assert!(!keychain.is_admin_key(account, admin_key)?); Ok(()) }) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 4323e3eb4f..0494f40225 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -5204,7 +5204,7 @@ mod tests { let keychain = AccountKeychain::new(); assert!( keychain - .is_admin_key_for(user, child_key) + .is_admin_key(user, child_key) .expect("admin key status read succeeds"), "child key should be registered as admin" ); @@ -5332,7 +5332,7 @@ mod tests { assert_eq!(key.keyId, child_key, "child key should be registered"); assert!( !keychain - .is_admin_key_for(alice, child_key) + .is_admin_key(alice, child_key) .expect("admin key status read succeeds"), "child key should not be admin" ); From b92785ee53aae8d30ab5ab864c25db25d775fb9e Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 22:30:00 +0530 Subject: [PATCH 12/39] fix(revm): enforce key authorization account binding --- crates/revm/src/handler.rs | 57 +++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 0494f40225..af5178ce3b 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -58,8 +58,8 @@ use tempo_precompiles::{ use tempo_primitives::{ TempoAddressExt, transaction::{ - PrimitiveSignature, SignatureType, TEMPO_EXPIRING_NONCE_KEY, TempoSignature, - calc_gas_balance_spending, validate_calls, + PrimitiveSignature, SignatureType, SignedKeyAuthorization, TEMPO_EXPIRING_NONCE_KEY, + TempoSignature, calc_gas_balance_spending, validate_calls, }, }; @@ -143,6 +143,25 @@ fn tempo_signature_verification_gas(signature: &TempoSignature) -> u64 { } } +fn validate_t6_key_authorization_account_binding( + key_auth: &SignedKeyAuthorization, + caller: Address, +) -> Result<(), TempoInvalidTransaction> { + if key_auth.account.is_some_and(|account| account != caller) { + let reason = if key_auth.is_admin() { + "admin key authorization account mismatch" + } else { + "key authorization account mismatch" + }; + + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: reason.to_string(), + }); + } + + Ok(()) +} + /// Counts the scope storage rows that pay the dynamic SSTORE-set path for the active spec. /// /// T3 keeps the broader all-persisted-rows accounting from current main. T4 narrows this to rows @@ -1284,8 +1303,11 @@ where let auth_signer = key_auth .recover_signer() .map_err(|_| TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed)?; + + validate_t6_key_authorization_account_binding(key_auth, tx.caller)?; + if auth_signer != tx.caller { - if key_auth.account != Some(tx.caller) { + if key_auth.account.is_none() { return Err(TempoInvalidTransaction::KeychainValidationFailed { reason: "admin-signed key authorization account mismatch".to_string(), } @@ -1775,6 +1797,10 @@ where } } + if cfg.spec.is_t6() { + validate_t6_key_authorization_account_binding(key_auth, tx.caller)?; + } + if key_auth.is_admin() { if key_auth.expiry.is_some() || key_auth.limits.is_some() @@ -5144,6 +5170,29 @@ mod tests { ); } + #[test] + fn test_t6_root_key_authorization_rejects_account_mismatch() { + let (signer, user) = generate_keypair(); + let key = Address::random(); + let wrong_account = Address::random(); + let signed = sign_key_auth( + &signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, key) + .with_account(wrong_account), + ); + let (mut evm, h) = make_evm(user, key, Some(signed), TempoHardfork::T6, None, false); + + let result = h.validate_env(&mut evm); + assert!( + matches!( + &result, + Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) + if reason.contains("key authorization account mismatch") + ), + "root-signed key authorization should be bound to tx.caller, got: {result:?}" + ); + } + #[test] fn test_t6_admin_key_authorization_rejects_restrictions() { let (signer, user) = generate_keypair(); @@ -5353,7 +5402,7 @@ mod tests { matches!( &bob_result, Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) - if reason.contains("admin-signed key authorization account mismatch") + if reason.contains("key authorization account mismatch") ), "Alice-bound authorization should not replay for Bob, got: {bob_result:?}" ); From 3a9287ead6cab893cc04d037725711d1809ae517 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 26 May 2026 22:53:00 +0530 Subject: [PATCH 13/39] fix(txpool): invalidate stale key authorizations --- crates/revm/src/handler.rs | 4 +- crates/transaction-pool/src/maintain.rs | 64 ++++++++++++ crates/transaction-pool/src/paused.rs | 109 ++++++++++++++++++-- crates/transaction-pool/src/tempo_pool.rs | 113 ++++++++++++++++++++- crates/transaction-pool/src/transaction.rs | 67 +++++++++--- 5 files changed, 336 insertions(+), 21 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index af5178ce3b..ee7a5e015e 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1327,7 +1327,9 @@ where block.timestamp().to::(), Some(key_auth.signature.signature_type().into()), ) - .map_err(|e| EVMError::Custom(e.to_string())) + .map_err(|e| TempoInvalidTransaction::KeychainValidationFailed { + reason: format!("{e:?}"), + }) }, )?; diff --git a/crates/transaction-pool/src/maintain.rs b/crates/transaction-pool/src/maintain.rs index 7b405ccf93..e90fbdf5b7 100644 --- a/crates/transaction-pool/src/maintain.rs +++ b/crates/transaction-pool/src/maintain.rs @@ -46,6 +46,11 @@ pub struct TempoPoolUpdates { /// Revoked keychain keys. /// Indexed by account for efficient lookup. pub revoked_keys: RevokedKeys, + /// Inline key authorization target-key status changes. + /// + /// A pending inline authorization for `(account, key)` is stale once another transaction + /// authorizes, admin-authorizes, or revokes that same key. + pub key_authorization_target_changes: RevokedKeys, /// Spending limit changes. /// When a spending limit changes, transactions from that key paying with that token /// may become unexecutable if the new limit is below their value. @@ -106,6 +111,7 @@ impl TempoPoolUpdates { pub fn is_empty(&self) -> bool { self.expired_txs.is_empty() && self.revoked_keys.is_empty() + && self.key_authorization_target_changes.is_empty() && self.spending_limit_changes.is_empty() && self.validator_token_changes.is_empty() && self.user_token_changes.is_empty() @@ -139,6 +145,19 @@ impl TempoPoolUpdates { match AccountKeychainPoolEvent::decode(log) { Some(AccountKeychainPoolEvent::KeyRevoked(event)) => { updates.revoked_keys.insert(event.account, event.publicKey); + updates + .key_authorization_target_changes + .insert(event.account, event.publicKey); + } + Some(AccountKeychainPoolEvent::KeyAuthorized(event)) => { + updates + .key_authorization_target_changes + .insert(event.account, event.publicKey); + } + Some(AccountKeychainPoolEvent::AdminKeyAuthorized(event)) => { + updates + .key_authorization_target_changes + .insert(event.account, event.publicKey); } Some(AccountKeychainPoolEvent::SpendingLimitUpdated(event)) => { updates.spending_limit_changes.insert( @@ -224,6 +243,7 @@ impl TempoPoolUpdates { /// 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() + || !self.key_authorization_target_changes.is_empty() || !self.validator_token_changes.is_empty() || !self.user_token_changes.is_empty() || !self.blacklist_additions.is_empty() @@ -242,6 +262,10 @@ impl TempoPoolUpdates { /// Transaction-pool relevant subset of `IAccountKeychain::IAccountKeychainEvents`. enum AccountKeychainPoolEvent { + /// [`IAccountKeychain::KeyAuthorized`] log. + KeyAuthorized(IAccountKeychain::KeyAuthorized), + /// [`IAccountKeychain::AdminKeyAuthorized`] log. + AdminKeyAuthorized(IAccountKeychain::AdminKeyAuthorized), /// [`IAccountKeychain::KeyRevoked`] log. KeyRevoked(IAccountKeychain::KeyRevoked), /// [`IAccountKeychain::SpendingLimitUpdated`] log. @@ -256,6 +280,12 @@ impl AccountKeychainPoolEvent { /// Decodes only account-keychain events used by transaction-pool maintenance. fn decode(log: &Log) -> Option { match first_topic(log)? { + IAccountKeychain::KeyAuthorized::SIGNATURE_HASH => { + decode_event(log).map(Self::KeyAuthorized) + } + IAccountKeychain::AdminKeyAuthorized::SIGNATURE_HASH => { + decode_event(log).map(Self::AdminKeyAuthorized) + } IAccountKeychain::KeyRevoked::SIGNATURE_HASH => decode_event(log).map(Self::KeyRevoked), IAccountKeychain::SpendingLimitUpdated::SIGNATURE_HASH => { decode_event(log).map(Self::SpendingLimitUpdated) @@ -769,11 +799,13 @@ where // 7. Evict hard keychain invalidations from paused pool // Ignore spending_limit_spends here: AccessKeySpend only proves partial limit consumption, and paused txs are fully revalidated on unpause. if !updates.revoked_keys.is_empty() + || !updates.key_authorization_target_changes.is_empty() || !updates.spending_limit_changes.is_empty() || !updates.key_authorization_witness_burns.is_empty() { state.paused_pool.evict_invalidated( &updates.revoked_keys, + &updates.key_authorization_target_changes, &updates.spending_limit_changes, &updates.key_authorization_witness_burns, ); @@ -851,6 +883,8 @@ where debug!( target: "txpool", revoked_keys = updates.revoked_keys.len(), + key_authorization_target_changes = + updates.key_authorization_target_changes.len(), spending_limit_changes = updates.spending_limit_changes.len(), spending_limit_spends = updates.spending_limit_spends.len(), validator_token_changes = updates.validator_token_changes.len(), @@ -1138,6 +1172,36 @@ mod tests { #[test] fn account_keychain_decode_matches_generated_event_decoders() { + let log = event_log( + ACCOUNT_KEYCHAIN_ADDRESS, + IAccountKeychain::KeyAuthorized { + account: Address::random(), + publicKey: Address::random(), + signatureType: 0, + expiry: u64::MAX, + }, + ); + assert_decodes_like_generated!( + AccountKeychainPoolEvent, + KeyAuthorized, + IAccountKeychain::KeyAuthorized, + log + ); + + let log = event_log( + ACCOUNT_KEYCHAIN_ADDRESS, + IAccountKeychain::AdminKeyAuthorized { + account: Address::random(), + publicKey: Address::random(), + }, + ); + assert_decodes_like_generated!( + AccountKeychainPoolEvent, + AdminKeyAuthorized, + IAccountKeychain::AdminKeyAuthorized, + log + ); + let log = event_log( ACCOUNT_KEYCHAIN_ADDRESS, IAccountKeychain::KeyRevoked { diff --git a/crates/transaction-pool/src/paused.rs b/crates/transaction-pool/src/paused.rs index e129363c43..f81740c709 100644 --- a/crates/transaction-pool/src/paused.rs +++ b/crates/transaction-pool/src/paused.rs @@ -205,10 +205,12 @@ impl PausedFeeTokenPool { pub fn evict_invalidated( &mut self, revoked_keys: &RevokedKeys, + key_authorization_target_changes: &RevokedKeys, spending_limit_updates: &SpendingLimitUpdates, key_authorization_witness_burns: &AddressMap, ) -> usize { if revoked_keys.is_empty() + && key_authorization_target_changes.is_empty() && spending_limit_updates.is_empty() && key_authorization_witness_burns.is_empty() { @@ -216,25 +218,40 @@ impl PausedFeeTokenPool { } let mut count = 0; + let has_keychain_subject_updates = + !revoked_keys.is_empty() || !spending_limit_updates.is_empty(); + let has_key_authorization_target_updates = !key_authorization_target_changes.is_empty(); for meta in self.by_token.values_mut() { let before = meta.entries.len(); meta.entries.retain(|entry| { let key_authorization_subject = (!revoked_keys.is_empty()) .then(|| entry.tx.transaction.key_authorization_signer_subject()) .flatten(); + let key_authorization_target = has_key_authorization_target_updates + .then(|| entry.tx.transaction.key_authorization_target_subject()) + .flatten(); - let Some(subject) = entry.tx.transaction.keychain_subject() else { + let keychain_subject = has_keychain_subject_updates + .then(|| entry.tx.transaction.keychain_subject()) + .flatten(); + let Some(subject) = keychain_subject else { let Some(witness_subject) = entry.tx.transaction.key_authorization_witness_subject() else { return !key_authorization_subject .as_ref() - .is_some_and(|subject| subject.matches_revoked(revoked_keys)); + .is_some_and(|subject| subject.matches_revoked(revoked_keys)) + && !key_authorization_target.as_ref().is_some_and(|subject| { + subject.matches_key_update(key_authorization_target_changes) + }); }; return !key_authorization_subject .as_ref() .is_some_and(|subject| subject.matches_revoked(revoked_keys)) + && !key_authorization_target.as_ref().is_some_and(|subject| { + subject.matches_key_update(key_authorization_target_changes) + }) && !key_authorization_witness_burns .get(&witness_subject.account) .is_some_and(|witnesses| witnesses.contains(&witness_subject.witness)); @@ -248,6 +265,9 @@ impl PausedFeeTokenPool { || key_authorization_subject .as_ref() .is_some_and(|subject| subject.matches_revoked(revoked_keys)) + || key_authorization_target.as_ref().is_some_and(|subject| { + subject.matches_key_update(key_authorization_target_changes) + }) || (sender_paid && matches_limit_update) { return false; @@ -470,7 +490,12 @@ mod tests { let mut updates = SpendingLimitUpdates::new(); updates.insert(user_address, key_id, Some(fee_token)); - let evicted = pool.evict_invalidated(&RevokedKeys::new(), &updates, &AddressMap::default()); + let evicted = pool.evict_invalidated( + &RevokedKeys::new(), + &RevokedKeys::new(), + &updates, + &AddressMap::default(), + ); assert_eq!( evicted, 1, @@ -504,7 +529,12 @@ mod tests { let mut updates = SpendingLimitUpdates::new(); updates.insert(user_address, key_id, Some(fee_token)); - let evicted = pool.evict_invalidated(&RevokedKeys::new(), &updates, &AddressMap::default()); + let evicted = pool.evict_invalidated( + &RevokedKeys::new(), + &RevokedKeys::new(), + &updates, + &AddressMap::default(), + ); assert_eq!(evicted, 0, "Sponsored keychain tx should not be evicted"); assert_eq!(pool.len(), 1); @@ -564,8 +594,12 @@ mod tests { .or_insert_with(B256Set::default) .insert(burned_witness); - let evicted = - pool.evict_invalidated(&RevokedKeys::new(), &SpendingLimitUpdates::new(), &burned); + let evicted = pool.evict_invalidated( + &RevokedKeys::new(), + &RevokedKeys::new(), + &SpendingLimitUpdates::new(), + &burned, + ); assert_eq!(evicted, 1); assert_eq!(pool.len(), 1); @@ -632,7 +666,70 @@ mod tests { let evicted = pool.evict_invalidated( &revoked_keys, + &RevokedKeys::new(), &SpendingLimitUpdates::new(), + &AddressMap::default(), + ); + + assert_eq!(evicted, 1); + assert_eq!(pool.len(), 1); + } + + #[test] + fn test_evict_invalidated_with_key_authorization_target_change() { + let mut pool = PausedFeeTokenPool::new(); + let user_address = Address::random(); + let fee_token = Address::random(); + let signer = PrivateKeySigner::random(); + let target_key = Address::random(); + let other_key = Address::random(); + + let key_authorization = |key_id| { + let authorization = + KeyAuthorization::unrestricted(42431, SignatureType::Secp256k1, key_id) + .with_account(user_address); + let signature = signer + .sign_hash_sync(&authorization.signature_hash()) + .expect("key authorization signing should succeed"); + authorization.into_signed(PrimitiveSignature::Secp256k1(signature)) + }; + + let matching = Arc::new(wrap_valid_tx( + TxBuilder::aa(user_address) + .fee_token(fee_token) + .key_authorization(key_authorization(target_key)) + .build(), + TransactionOrigin::External, + )); + let untouched = Arc::new(wrap_valid_tx( + TxBuilder::aa(user_address) + .nonce(1) + .fee_token(fee_token) + .key_authorization(key_authorization(other_key)) + .build(), + TransactionOrigin::External, + )); + + pool.insert_batch( + fee_token, + vec![ + PausedEntry { + tx: matching, + valid_before: None, + }, + PausedEntry { + tx: untouched, + valid_before: None, + }, + ], + ); + + let mut target_changes = RevokedKeys::new(); + target_changes.insert(user_address, target_key); + + let evicted = pool.evict_invalidated( + &RevokedKeys::new(), + &target_changes, &SpendingLimitUpdates::new(), &AddressMap::default(), ); diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index 22d7cb0132..8ce452ad16 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -192,6 +192,7 @@ where let mut to_remove = Vec::new(); let mut revoked_count = 0; + let mut key_authorization_target_count = 0; let mut spending_limit_count = 0; let mut spending_limit_spend_count = 0; let mut key_authorization_witness_count = 0; @@ -201,16 +202,23 @@ where let mut unwhitelisted_count = 0; let mut insolvent_fee_payer_count = 0; let has_keychain_subject_updates = updates.has_keychain_subject_updates(); + let has_key_authorization_target_updates = + !updates.key_authorization_target_changes.is_empty(); let mut fee_balance_cache: HashMap<(Address, Address), U256> = HashMap::default(); let all_txs = self.all_transactions(); for tx in all_txs.pending.iter().chain(all_txs.queued.iter()) { // Avoid recovering key ids unless a keychain invalidation can use them. - if has_keychain_subject_updates { - let keychain_subject = tx.transaction.keychain_subject(); + if has_keychain_subject_updates || has_key_authorization_target_updates { + let keychain_subject = has_keychain_subject_updates + .then(|| tx.transaction.keychain_subject()) + .flatten(); let key_authorization_subject = (!updates.revoked_keys.is_empty()) .then(|| tx.transaction.key_authorization_signer_subject()) .flatten(); + let key_authorization_target = has_key_authorization_target_updates + .then(|| tx.transaction.key_authorization_target_subject()) + .flatten(); // Check 1: Revoked keychain keys if !updates.revoked_keys.is_empty() @@ -226,6 +234,17 @@ where continue; } + // Check 1b: Inline key authorization target status changes + if !updates.key_authorization_target_changes.is_empty() + && key_authorization_target.as_ref().is_some_and(|subject| { + subject.matches_key_update(&updates.key_authorization_target_changes) + }) + { + to_remove.push(*tx.hash()); + key_authorization_target_count += 1; + continue; + } + // Check 2: Spending limit updates // Only evict if the transaction's fee token matches the token whose limit changed. if !updates.spending_limit_changes.is_empty() @@ -440,6 +459,7 @@ where target: "txpool", total = to_remove.len(), revoked_count, + key_authorization_target_count, spending_limit_count, spending_limit_spend_count, key_authorization_witness_count, @@ -2116,6 +2136,95 @@ mod tests { assert!(pool.get(untouched.hash()).is_some()); } + #[tokio::test] + async fn evicts_transactions_with_stale_key_authorization_target() { + let sender = Address::random(); + let signer = PrivateKeySigner::random(); + let target_key = Address::random(); + let other_key = Address::random(); + + let key_authorization = |key_id| { + let authorization = + KeyAuthorization::unrestricted(42431, SignatureType::Secp256k1, key_id) + .with_account(sender); + let signature = signer + .sign_hash_sync(&authorization.signature_hash()) + .expect("key authorization signing should succeed"); + authorization.into_signed(PrimitiveSignature::Secp256k1(signature)) + }; + + let matching = crate::test_utils::TxBuilder::aa(sender) + .nonce(0) + .key_authorization(key_authorization(target_key)) + .build(); + let untouched = crate::test_utils::TxBuilder::aa(sender) + .nonce(1) + .key_authorization(key_authorization(other_key)) + .build(); + + let provider = MockEthProvider::::new() + .with_chain_spec(std::sync::Arc::unwrap_or_clone(MODERATO.clone())); + provider.add_account(sender, ExtendedAccount::new(matching.nonce(), U256::MAX)); + provider.add_block( + B256::random(), + Block { + header: TempoHeader { + inner: Header { + gas_limit: TEMPO_T1_TX_GAS_LIMIT_CAP, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + ); + + let inner = + EthTransactionValidatorBuilder::new(provider.clone(), TempoEvmConfig::mainnet()) + .disable_balance_check() + .build(InMemoryBlobStore::default()); + let amm_cache = + AmmLiquidityCache::new(provider).expect("failed to setup AmmLiquidityCache"); + let validator = TempoTransactionValidator::new( + inner, + crate::validator::DEFAULT_AA_VALID_AFTER_MAX_SECS, + crate::validator::DEFAULT_MAX_TEMPO_AUTHORIZATIONS, + amm_cache, + ); + + let (executor, _task) = TransactionValidationTaskExecutor::new(validator); + let protocol_pool = Pool::new( + executor, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + PoolConfig::default(), + ); + let pool = TempoTransactionPool::new(protocol_pool, AA2dPool::new(Default::default())); + + for pooled in [&matching, &untouched] { + let validated = TransactionValidationOutcome::Valid { + balance: *pooled.cost(), + state_nonce: pooled.nonce(), + bytecode_hash: None, + transaction: ValidTransaction::new(pooled.clone(), None), + propagate: true, + authorities: None, + }; + pool.add_validated_transaction(TransactionOrigin::External, validated) + .expect("transaction should be admitted"); + } + + let mut updates = crate::maintain::TempoPoolUpdates::new(); + updates + .key_authorization_target_changes + .insert(sender, target_key); + + let evicted = pool.evict_invalidated_transactions(&updates); + assert_eq!(evicted, vec![*matching.hash()]); + assert!(pool.get(matching.hash()).is_none()); + assert!(pool.get(untouched.hash()).is_some()); + } + /// Eviction must match sub-policy IDs against compound policies. /// When a token uses a compound policy, and a sub-policy event fires, /// the eviction comparison must detect the match. diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index d45f637712..4e1e406abf 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -55,6 +55,10 @@ pub struct TempoPooledTransaction { /// Used by `keychain_subject()` so pool maintenance matches against the same token /// that was validated without requiring state access. resolved_fee_token: OnceLock
, + /// Cached keychain subject for the signer of an inline `KeyAuthorization`. + key_authorization_signer_subject: OnceLock>, + /// Cached target key of an inline `KeyAuthorization`. + key_authorization_target_subject: OnceLock>, /// Cached TIP20 balance storage slot for the fee payer. /// /// Stores `(fee_token, balance_slot)` so the payload builder's state-aware iterator @@ -92,6 +96,8 @@ impl TempoPooledTransaction { tx_env: OnceLock::new(), key_expiry: OnceLock::new(), resolved_fee_token: OnceLock::new(), + key_authorization_signer_subject: OnceLock::new(), + key_authorization_target_subject: OnceLock::new(), fee_balance_slot: OnceLock::new(), } } @@ -177,18 +183,39 @@ impl TempoPooledTransaction { /// Used for revocation matching: if the access key that signed an inline authorization is /// revoked while the transaction is still in the pool, the transaction must be revalidated. pub fn key_authorization_signer_subject(&self) -> Option { - let aa_tx = self.inner().as_aa()?; - let key_authorization = aa_tx.tx().key_authorization.as_ref()?; - let key_id = key_authorization.recover_signer().ok()?; - let account = key_authorization - .authorization - .account - .unwrap_or(*self.sender_ref()); - let fee_token = self.effective_fee_token(); - Some(KeychainSubject { - account, - key_id, - fee_token, + *self.key_authorization_signer_subject.get_or_init(|| { + let aa_tx = self.inner().as_aa()?; + let key_authorization = aa_tx.tx().key_authorization.as_ref()?; + let key_id = key_authorization.recover_signer().ok()?; + let account = key_authorization + .authorization + .account + .unwrap_or(*self.sender_ref()); + let fee_token = self.effective_fee_token(); + Some(KeychainSubject { + account, + key_id, + fee_token, + }) + }) + } + + /// Extracts the target key of an inline `KeyAuthorization`. + /// + /// Used for matching pending authorizations against key status changes emitted by + /// already-included authorizations or revocations. + pub fn key_authorization_target_subject(&self) -> Option { + *self.key_authorization_target_subject.get_or_init(|| { + let aa_tx = self.inner().as_aa()?; + let key_authorization = aa_tx.tx().key_authorization.as_ref()?; + let account = key_authorization + .authorization + .account + .unwrap_or(*self.sender_ref()); + Some(KeyAuthorizationTargetSubject { + account, + key_id: key_authorization.authorization.key_id, + }) }) } @@ -1296,6 +1323,15 @@ pub struct KeyAuthorizationWitnessSubject { pub witness: B256, } +/// Target key identity extracted from an inline key authorization. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct KeyAuthorizationTargetSubject { + /// The account that owns the target key. + pub account: Address, + /// The key being authorized. + pub key_id: Address, +} + impl KeychainSubject { /// Returns true if this subject matches any of the revoked keys. /// @@ -1316,3 +1352,10 @@ impl KeychainSubject { spending_limit_updates.contains(self.account, self.key_id, self.fee_token) } } + +impl KeyAuthorizationTargetSubject { + /// Returns true if this target key is affected by a key status update. + pub fn matches_key_update(&self, key_updates: &RevokedKeys) -> bool { + key_updates.contains(self.account, self.key_id) + } +} From f72bbdd63ab79246e3baaea03f4d95439c3993a4 Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Tue, 26 May 2026 23:32:04 +0200 Subject: [PATCH 14/39] fix(account-keychain): address clippy and ABI checks --- crates/precompiles/src/account_keychain/mod.rs | 16 ++++++++-------- crates/revm/src/handler.rs | 11 ++++------- tips/verify/lib/tempo-std | 2 +- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 19320da830..f2a07740fd 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -96,10 +96,10 @@ impl TryFrom for StoredSignatureType { impl From for SignatureType { fn from(value: StoredSignatureType) -> Self { match value.0 { - 0 => SignatureType::Secp256k1, - 1 => SignatureType::P256, - 2 => SignatureType::WebAuthn, - _ => SignatureType::Secp256k1, + 0 => Self::Secp256k1, + 1 => Self::P256, + 2 => Self::WebAuthn, + _ => Self::Secp256k1, } } } @@ -1119,10 +1119,10 @@ impl AccountKeychain { /// If origin is not seeded (zero), admin ops are rejected. fn ensure_admin_caller(&self, msg_sender: Address) -> Result<()> { let transaction_key = self.transaction_key.t_read()?; - if !transaction_key.is_zero() { - if !self.storage.spec().is_t6() || !self.is_admin_key(msg_sender, transaction_key)? { - return Err(AccountKeychainError::unauthorized_caller().into()); - } + if !transaction_key.is_zero() + && (!self.storage.spec().is_t6() || !self.is_admin_key(msg_sender, transaction_key)?) + { + return Err(AccountKeychainError::unauthorized_caller().into()); } if self.storage.spec().is_t2() { diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index ee7a5e015e..6e70344782 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1789,14 +1789,11 @@ where } } - if key_auth.is_admin || key_auth.account.is_some() { - if !cfg.spec.is_t6() { - return Err(TempoInvalidTransaction::KeychainValidationFailed { - reason: "T6 key authorization fields are not active before T6" - .to_string(), - } - .into()); + if (key_auth.is_admin || key_auth.account.is_some()) && !cfg.spec.is_t6() { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: "T6 key authorization fields are not active before T6".to_string(), } + .into()); } if cfg.spec.is_t6() { diff --git a/tips/verify/lib/tempo-std b/tips/verify/lib/tempo-std index 96ed91b1da..a5dc040647 160000 --- a/tips/verify/lib/tempo-std +++ b/tips/verify/lib/tempo-std @@ -1 +1 @@ -Subproject commit 96ed91b1da6ca41c987aba0470cadb0f0107be43 +Subproject commit a5dc04064787d8be6109dbbb3d3add74b7c0dbbb From 27366d5b154d6acd56fd43a701923f517388e4c4 Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Tue, 26 May 2026 23:49:36 +0200 Subject: [PATCH 15/39] chore: roll back tempo-std submodule bump --- tips/verify/lib/tempo-std | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/verify/lib/tempo-std b/tips/verify/lib/tempo-std index a5dc040647..96ed91b1da 160000 --- a/tips/verify/lib/tempo-std +++ b/tips/verify/lib/tempo-std @@ -1 +1 @@ -Subproject commit a5dc04064787d8be6109dbbb3d3add74b7c0dbbb +Subproject commit 96ed91b1da6ca41c987aba0470cadb0f0107be43 From f13fb1242218fe880282a8019a3d27c78ffecc16 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 27 May 2026 14:42:23 +0530 Subject: [PATCH 16/39] fix(revm): tighten T6 key authorization validation --- crates/revm/src/handler.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 6e70344782..e9153a5c58 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -143,6 +143,11 @@ fn tempo_signature_verification_gas(signature: &TempoSignature) -> u64 { } } +/// Validates the account binding carried by T6 key authorizations. +/// +/// T6 allows existing admin keys to sign `KeyAuthorization`s for an account. Any authorization +/// that names an account must be bound to the transaction caller so the signed payload cannot be +/// replayed against another account where the same admin key is also authorized. fn validate_t6_key_authorization_account_binding( key_auth: &SignedKeyAuthorization, caller: Address, @@ -1182,6 +1187,9 @@ where let same_tx_auth_use = access_key_addr == key_auth.key_id; + // T6 adds admin delegation: the keychain signer may be an existing admin key that + // authorizes a different child key. Earlier forks only allow same-tx auth+use, and + // `validate_env` rejects non-matching key IDs before this state-aware phase. if cfg.spec.is_t6() && !same_tx_auth_use { let stored_key_expiry = StorageCtx::enter_precompile( journal, @@ -1189,9 +1197,7 @@ where cfg, tx, |mut keychain: AccountKeychain| { - let sig_type = spec - .is_t1() - .then_some(keychain_sig.signature.signature_type().into()); + let sig_type = Some(keychain_sig.signature.signature_type().into()); let key = keychain .validate_keychain_authorization( @@ -1296,16 +1302,19 @@ where } } + // T6 defers `KeyAuthorization` signer checks from `validate_env` to this state-aware phase: + // root-signed authorizations remain valid, while admin-signed authorizations must name the + // caller account and prove that the sidecar signer is an active admin key for that account. if cfg.spec.is_t6() && let Some(tempo_tx_env) = tx.tempo_tx_env.as_ref() && let Some(key_auth) = tempo_tx_env.key_authorization.as_ref() { + validate_t6_key_authorization_account_binding(key_auth, tx.caller)?; + let auth_signer = key_auth .recover_signer() .map_err(|_| TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed)?; - validate_t6_key_authorization_account_binding(key_auth, tx.caller)?; - if auth_signer != tx.caller { if key_auth.account.is_none() { return Err(TempoInvalidTransaction::KeychainValidationFailed { From 2089d9b33ecba4a8948f6ac22193747ec2fa80d1 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 27 May 2026 14:57:14 +0530 Subject: [PATCH 17/39] fix(revm): avoid pre-T6 access key recovery in state validation --- crates/revm/src/handler.rs | 116 +++++++++++++++++++++---------------- 1 file changed, 65 insertions(+), 51 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index e9153a5c58..d46618f9ca 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1177,56 +1177,65 @@ where } if let Some(key_auth) = tempo_tx_env.key_authorization.as_ref() { - let access_key_addr = if let Some(override_key_id) = tempo_tx_env.override_key_id { - override_key_id - } else { - keychain_sig - .key_id(&tempo_tx_env.signature_hash) - .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? - }; - - let same_tx_auth_use = access_key_addr == key_auth.key_id; - // T6 adds admin delegation: the keychain signer may be an existing admin key that // authorizes a different child key. Earlier forks only allow same-tx auth+use, and // `validate_env` rejects non-matching key IDs before this state-aware phase. - if cfg.spec.is_t6() && !same_tx_auth_use { - let stored_key_expiry = StorageCtx::enter_precompile( - journal, - block, - cfg, - tx, - |mut keychain: AccountKeychain| { - let sig_type = Some(keychain_sig.signature.signature_type().into()); - - let key = keychain - .validate_keychain_authorization( - *user_address, - access_key_addr, - block.timestamp().to::(), - sig_type, - ) - .map_err(|e| TempoInvalidTransaction::KeychainValidationFailed { - reason: format!("{e:?}"), - })?; - - if !key.is_admin { - return Err( - TempoInvalidTransaction::AccessKeyCannotAuthorizeOtherKeys - .into(), - ); - } + let same_tx_auth_use = if cfg.spec.is_t6() { + let access_key_addr = + if let Some(override_key_id) = tempo_tx_env.override_key_id { + override_key_id + } else { + keychain_sig + .key_id(&tempo_tx_env.signature_hash) + .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? + }; - keychain - .set_transaction_key(access_key_addr) - .map_err(|e| EVMError::Custom(e.to_string()))?; + let same_tx_auth_use = access_key_addr == key_auth.key_id; - Ok::<_, EVMError<_, TempoInvalidTransaction>>(key.expiry) - }, - )?; + if !same_tx_auth_use { + let stored_key_expiry = StorageCtx::enter_precompile( + journal, + block, + cfg, + tx, + |mut keychain: AccountKeychain| { + let sig_type = Some(keychain_sig.signature.signature_type().into()); + + let key = keychain + .validate_keychain_authorization( + *user_address, + access_key_addr, + block.timestamp().to::(), + sig_type, + ) + .map_err(|e| { + TempoInvalidTransaction::KeychainValidationFailed { + reason: format!("{e:?}"), + } + })?; + + if !key.is_admin { + return Err( + TempoInvalidTransaction::AccessKeyCannotAuthorizeOtherKeys + .into(), + ); + } + + keychain + .set_transaction_key(access_key_addr) + .map_err(|e| EVMError::Custom(e.to_string()))?; + + Ok::<_, EVMError<_, TempoInvalidTransaction>>(key.expiry) + }, + )?; + + evm.key_expiry = Some(stored_key_expiry); + } - evm.key_expiry = Some(stored_key_expiry); - } + same_tx_auth_use + } else { + true + }; // If this is a same tx auth+use, validate that spending limit is enough to cover the fee. // @@ -1566,15 +1575,20 @@ where // key and decrement the fee from its spending limit. Admin delegation must keep the // actual signer as the transaction key. if let Some(keychain_sig) = tempo_tx_env.signature.as_keychain() { - let access_key_addr = if let Some(override_key_id) = tempo_tx_env.override_key_id { - override_key_id + let same_tx_auth_use = if cfg.spec.is_t6() { + let access_key_addr = + if let Some(override_key_id) = tempo_tx_env.override_key_id { + override_key_id + } else { + keychain_sig + .key_id(&tempo_tx_env.signature_hash) + .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? + }; + + access_key_addr == key_auth.key_id } else { - keychain_sig - .key_id(&tempo_tx_env.signature_hash) - .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? + true }; - - let same_tx_auth_use = access_key_addr == key_auth.key_id; if same_tx_auth_use { StorageCtx::enter_precompile( journal, From 6e9b042fbed114fd40f957b0fb9f850ca7514a73 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 27 May 2026 15:14:42 +0530 Subject: [PATCH 18/39] fix(precompiles): store signature type as enum --- .../precompiles/src/account_keychain/mod.rs | 54 +++++++++---------- crates/transaction-pool/src/tempo_pool.rs | 3 +- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index f2a07740fd..d3bc23059a 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -57,8 +57,8 @@ pub fn is_constrained_tip20_selector(selector: [u8; 4]) -> bool { /// - byte 11: is_admin (bool) #[derive(Debug, Clone, Default, PartialEq, Eq, Storable)] pub struct AuthorizedKey { - /// Signature type: 0 = secp256k1, 1 = P256, 2 = WebAuthn - pub signature_type: u8, + /// Signature type used by this key. + pub signature_type: StoredSignatureType, /// Block timestamp when key expires pub expiry: u64, /// Whether to enforce spending limits for this key @@ -70,14 +70,13 @@ pub struct AuthorizedKey { pub is_admin: bool, } -#[derive(Debug, Clone, Copy)] -struct StoredSignatureType(u8); - -impl StoredSignatureType { - #[inline] - const fn as_u8(self) -> u8 { - self.0 - } +#[repr(u8)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Storable)] +pub enum StoredSignatureType { + #[default] + Secp256k1, + P256, + WebAuthn, } impl TryFrom for StoredSignatureType { @@ -85,9 +84,9 @@ impl TryFrom for StoredSignatureType { fn try_from(value: SignatureType) -> std::result::Result { match value { - SignatureType::Secp256k1 => Ok(Self(0)), - SignatureType::P256 => Ok(Self(1)), - SignatureType::WebAuthn => Ok(Self(2)), + SignatureType::Secp256k1 => Ok(Self::Secp256k1), + SignatureType::P256 => Ok(Self::P256), + SignatureType::WebAuthn => Ok(Self::WebAuthn), _ => Err(AccountKeychainError::invalid_signature_type().into()), } } @@ -95,11 +94,10 @@ impl TryFrom for StoredSignatureType { impl From for SignatureType { fn from(value: StoredSignatureType) -> Self { - match value.0 { - 0 => Self::Secp256k1, - 1 => Self::P256, - 2 => Self::WebAuthn, - _ => Self::Secp256k1, + match value { + StoredSignatureType::Secp256k1 => Self::Secp256k1, + StoredSignatureType::P256 => Self::P256, + StoredSignatureType::WebAuthn => Self::WebAuthn, } } } @@ -278,7 +276,7 @@ impl AccountKeychain { return Err(AccountKeychainError::key_already_revoked().into()); } - let signature_type = StoredSignatureType::try_from(signature_type)?.as_u8(); + let signature_type = StoredSignatureType::try_from(signature_type)?; // TIP-1011 fields are hardfork-gated at T3, so reject them before mutating state. let allowed_call_configs = if is_t3 { @@ -354,7 +352,7 @@ impl AccountKeychain { self.emit_event(AccountKeychainEvent::key_authorized( msg_sender, key_id, - signature_type, + signature_type as u8, config.expiry, ))?; @@ -391,7 +389,7 @@ impl AccountKeychain { self.ensure_key_authorization_witness_not_burned(msg_sender, witness)?; } - let signature_type = StoredSignatureType::try_from(signature_type)?.as_u8(); + let signature_type = StoredSignatureType::try_from(signature_type)?; self.keys[msg_sender][key_id].write(AuthorizedKey { signature_type, @@ -413,7 +411,7 @@ impl AccountKeychain { self.emit_event(AccountKeychainEvent::key_authorized( msg_sender, key_id, - signature_type, + signature_type as u8, u64::MAX, ))?; self.emit_event(AccountKeychainEvent::AdminKeyAuthorized( @@ -541,7 +539,7 @@ impl AccountKeychain { } Ok(KeyInfo { - signatureType: StoredSignatureType(key.signature_type).into(), + signatureType: key.signature_type.into(), keyId: call.keyId, expiry: key.expiry, enforceLimits: key.enforce_limits, @@ -1226,10 +1224,10 @@ impl AccountKeychain { // Validate that the signature type matches the key type stored in the keychain // Only check if expected_sig_type is provided (T1+ hardfork) if let Some(sig_type) = expected_sig_type - && key.signature_type != sig_type + && key.signature_type as u8 != sig_type { return Err(AccountKeychainError::signature_type_mismatch( - key.signature_type, + key.signature_type as u8, sig_type, ) .into()); @@ -1631,7 +1629,7 @@ mod tests { keychain.authorize_admin_key(account, admin_key, SignatureType::P256, None)?; let key = keychain.keys[account][admin_key].read()?; - assert_eq!(key.signature_type, SignatureType::P256 as u8); + assert_eq!(key.signature_type, StoredSignatureType::P256); assert_eq!(key.expiry, u64::MAX); assert!(!key.enforce_limits); assert!(!key.is_revoked); @@ -1848,7 +1846,7 @@ mod tests { ); keychain.keys[account][account].write(AuthorizedKey { - signature_type: SignatureType::Secp256k1 as u8, + signature_type: StoredSignatureType::Secp256k1, expiry: u64::MAX, enforce_limits: false, is_revoked: false, @@ -3957,7 +3955,7 @@ mod tests { let limit_key = AccountKeychain::spending_limit_key(eoa, access_key); keychain.keys[eoa][access_key].write(AuthorizedKey { - signature_type: SignatureType::Secp256k1 as u8, + signature_type: StoredSignatureType::Secp256k1, expiry: u64::MAX, enforce_limits: true, is_revoked: false, diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index 8ce452ad16..a453277a21 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -1473,10 +1473,11 @@ mod tests { .setup_storage(TempoHardfork::default(), || { let mut keychain = AccountKeychain::new(); keychain.keys[account][key_id].write(AuthorizedKey { - signature_type: 0, + signature_type: StoredSignatureType::Secp256k1, expiry: u64::MAX, enforce_limits: true, is_revoked: false, + is_admin: false, })?; let limit_key = AccountKeychain::spending_limit_key(account, key_id); keychain.spending_limits[limit_key][fee_token].write(SpendingLimitState { From 8cbb647294029249ac532908fad565c76c7957f5 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 27 May 2026 15:24:30 +0530 Subject: [PATCH 19/39] chore: address keychain review comments --- .../precompiles/src/account_keychain/mod.rs | 1 + crates/transaction-pool/src/tempo_pool.rs | 10 +++--- crates/transaction-pool/src/transaction.rs | 36 +++++++++---------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index d3bc23059a..58d43cdc5f 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -412,6 +412,7 @@ impl AccountKeychain { msg_sender, key_id, signature_type as u8, + // Admin keys never expire; they must be revoked explicitly. u64::MAX, ))?; self.emit_event(AccountKeychainEvent::AdminKeyAuthorized( diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index a453277a21..efcc09cf22 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -1357,7 +1357,9 @@ mod tests { use tempo_evm::TempoEvmConfig; use tempo_precompiles::{ PATH_USD_ADDRESS, - account_keychain::{AccountKeychain, AuthorizedKey, SpendingLimitState}, + account_keychain::{ + AccountKeychain, AuthorizedKey, SpendingLimitState, StoredSignatureType, + }, tip20::slots as tip20_slots, tip403_registry::{CompoundPolicyData, PolicyData, TIP403Registry}, }; @@ -1402,7 +1404,7 @@ mod tests { .setup_storage(setup_spec, || { let mut keychain = AccountKeychain::new(); keychain.keys[account][key_id].write(AuthorizedKey { - signature_type: 0, + signature_type: StoredSignatureType::Secp256k1, expiry: u64::MAX, enforce_limits: true, is_revoked: false, @@ -2571,7 +2573,7 @@ mod tests { provider .setup_storage(TempoHardfork::default(), || { AccountKeychain::new().keys[account][key_id].write(AuthorizedKey { - signature_type: 0, + signature_type: StoredSignatureType::Secp256k1, expiry: u64::MAX, enforce_limits: true, is_revoked: false, @@ -2607,7 +2609,7 @@ mod tests { provider .setup_storage(TempoHardfork::default(), || { AccountKeychain::new().keys[account][key_id].write(AuthorizedKey { - signature_type: 0, + signature_type: StoredSignatureType::Secp256k1, expiry: u64::MAX, enforce_limits: false, is_revoked: false, diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 4e1e406abf..071d793668 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -1314,24 +1314,6 @@ pub struct KeychainSubject { pub fee_token: Address, } -/// Key-authorization witness identity extracted from an AA transaction. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct KeyAuthorizationWitnessSubject { - /// The account whose key-authorization witness is carried or burned. - pub account: Address, - /// The TIP-1053 witness. - pub witness: B256, -} - -/// Target key identity extracted from an inline key authorization. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct KeyAuthorizationTargetSubject { - /// The account that owns the target key. - pub account: Address, - /// The key being authorized. - pub key_id: Address, -} - impl KeychainSubject { /// Returns true if this subject matches any of the revoked keys. /// @@ -1353,6 +1335,24 @@ impl KeychainSubject { } } +/// Key-authorization witness identity extracted from an AA transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct KeyAuthorizationWitnessSubject { + /// The account whose key-authorization witness is carried or burned. + pub account: Address, + /// The TIP-1053 witness. + pub witness: B256, +} + +/// Target key identity extracted from an inline key authorization. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct KeyAuthorizationTargetSubject { + /// The account that owns the target key. + pub account: Address, + /// The key being authorized. + pub key_id: Address, +} + impl KeyAuthorizationTargetSubject { /// Returns true if this target key is affected by a key status update. pub fn matches_key_update(&self, key_updates: &RevokedKeys) -> bool { From 742cc033c4f02d733d60d7b5f064a155c8e6e5ed Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 27 May 2026 17:26:20 +0530 Subject: [PATCH 20/39] fix(account-keychain): make key auth account binding signer-based --- .../src/transaction/key_authorization.rs | 4 +- crates/revm/src/handler.rs | 55 ++++++++++++++++--- tips/tip-1049.md | 15 ++++- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/crates/primitives/src/transaction/key_authorization.rs b/crates/primitives/src/transaction/key_authorization.rs index c2c596f19f..1931ab6681 100644 --- a/crates/primitives/src/transaction/key_authorization.rs +++ b/crates/primitives/src/transaction/key_authorization.rs @@ -223,8 +223,8 @@ pub struct KeyAuthorization { /// Account this authorization targets. /// - /// Required for admin-signed authorizations and admin-key creation so signatures cannot be - /// replayed across accounts that share the same admin key. + /// Required for admin-signed authorizations so signatures cannot be replayed across accounts + /// that share the same admin key. Root-signed authorizations may omit it. pub account: Option
, } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index d46618f9ca..57ada295ae 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -391,7 +391,7 @@ fn calculate_key_authorization_gas( // T6 account-bound authorizations may be signed by an existing admin key instead of the // root key. Charge one worst-case cold read for validating that admin signer row. - if spec.is_t6() && (key_auth.is_admin || key_auth.account.is_some()) { + if spec.is_t6() && key_auth.account.is_some() { total_gas += sload_cost; } @@ -1834,13 +1834,6 @@ where } .into()); } - - if key_auth.account != Some(tx.caller) { - return Err(TempoInvalidTransaction::KeychainValidationFailed { - reason: "admin key authorization account mismatch".to_string(), - } - .into()); - } } if cfg.spec.is_t6() && key_auth.key_id == tx.caller { @@ -3383,6 +3376,8 @@ mod tests { admin_t6_key_auth.authorization = admin_t6_key_auth .authorization .into_admin(Address::random()); + let mut unbound_admin_t6_key_auth = create_key_auth(0); + unbound_admin_t6_key_auth.authorization.is_admin = true; let (base_t6_gas, base_t6_state_gas) = calculate_key_authorization_gas(&base_t6_key_auth, &t6_gas_params, TempoHardfork::T6); @@ -3393,6 +3388,11 @@ mod tests { ); let (admin_t6_gas, admin_t6_state_gas) = calculate_key_authorization_gas(&admin_t6_key_auth, &t6_gas_params, TempoHardfork::T6); + let (unbound_admin_t6_gas, unbound_admin_t6_state_gas) = calculate_key_authorization_gas( + &unbound_admin_t6_key_auth, + &t6_gas_params, + TempoHardfork::T6, + ); assert_eq!( account_bound_t6_gas - base_t6_gas, @@ -3402,7 +3402,11 @@ mod tests { assert_eq!( admin_t6_gas - base_t6_gas, t6_sload, - "T6 admin authorization charges one admin-signer cold read" + "T6 account-bound admin authorization charges one admin-signer cold read" + ); + assert_eq!( + unbound_admin_t6_gas, base_t6_gas, + "T6 root-signed admin authorization without account does not charge admin-signer read" ); assert_eq!( account_bound_t6_state_gas, base_t6_state_gas, @@ -3412,6 +3416,10 @@ mod tests { admin_t6_state_gas, base_t6_state_gas, "T6 admin-signer read does not add state gas" ); + assert_eq!( + unbound_admin_t6_state_gas, base_t6_state_gas, + "T6 unbound admin authorization does not add state gas" + ); let scoped = SignedKeyAuthorization { authorization: KeyAuthorization::unrestricted( @@ -5192,6 +5200,35 @@ mod tests { ); } + #[test] + fn test_t6_root_admin_key_authorization_allows_omitted_account() { + let (signer, user) = generate_keypair(); + let key = Address::random(); + let mut key_auth = KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, key); + key_auth.is_admin = true; + assert_eq!(key_auth.account, None); + + let signed = sign_key_auth(&signer, key_auth); + let (mut evm, h) = make_evm(user, key, Some(signed), TempoHardfork::T6, None, false); + + let result = + h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); + assert!( + result.is_ok(), + "root-signed admin key authorization should not require account, got: {result:?}" + ); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { + let keychain = AccountKeychain::new(); + assert!( + keychain + .is_admin_key(user, key) + .expect("admin key status read succeeds"), + "root-signed admin key should be registered as admin" + ); + }); + } + #[test] fn test_t6_root_key_authorization_rejects_account_mismatch() { let (signer, user) = generate_keypair(); diff --git a/tips/tip-1049.md b/tips/tip-1049.md index be586d4d24..ac841d89ed 100644 --- a/tips/tip-1049.md +++ b/tips/tip-1049.md @@ -149,12 +149,21 @@ Revocation of an admin key does NOT automatically revoke keys that it authorized rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?, is_admin?, account?]) ``` -- If `is_admin` is omitted, the authorization creates a non-admin key. -- If `is_admin` is `true`, the authorization creates an admin key and `account` MUST be present and equal the target account being modified. -- If the authorization is signed by an admin access key, `account` MUST be present and equal the target account being modified, even when `is_admin` is omitted. +- If `is_admin` is omitted or `false`, the authorization creates a non-admin key. +- If `is_admin` is `true`, the authorization creates an admin key. +- If the authorization signer is not the target account's root key, `account` MUST be present and equal the target account being modified. +- If `account` is present, it MUST equal the target account being modified. - If `is_admin` is `true`, `expiry`, `limits`, and `allowed_calls` MUST be omitted or encoded as empty optional values because admin keys carry no restrictions. - If `account` is present, `witness` MAY be present or omitted for the transaction-encoded authorization. ABI calls to `authorizeAdminKey` always provide a `bytes32 witness`, including `bytes32(0)`. +In root/admin terms: + +- Root key authorizes non-admin key: `account` MAY be omitted. If present, it MUST equal the target account. +- Root key authorizes admin key: `account` MAY be omitted. If present, it MUST equal the target account. +- Admin key authorizes non-admin key: `account` MUST be present and MUST equal the target account. +- Admin key authorizes admin key: `account` MUST be present and MUST equal the target account. +- Non-admin access key authorizes any key: invalid. + The target `account`, `is_admin`, and `witness` fields are part of the signed RLP payload. This binds admin-signed key authorizations to a target account and prevents replaying an unrestricted non-admin authorization as an admin authorization. # Invariants From b3f0cbc946d70055fd1c592319534bea1132cdbd Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 27 May 2026 17:34:08 +0530 Subject: [PATCH 21/39] fix(account-keychain): charge admin key auth event buffer --- crates/revm/src/handler.rs | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 57ada295ae..47ef1445ba 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -84,8 +84,8 @@ const KEY_AUTH_BASE_GAS: u64 = 27_000; /// Gas per spending limit in KeyAuthorization const KEY_AUTH_PER_LIMIT_GAS: u64 = 22_000; -/// Extra buffer for the second LOG3 emitted by T5 witness-bearing key authorizations. -const KEY_AUTH_T5_WITNESS_EVENT_BUFFER: u64 = 1_500; +/// Rounded buffer for each extra LOG3/no-data event emitted by key authorizations. +const KEY_AUTH_EXTRA_EVENT_BUFFER: u64 = 1_500; /// Gas cost for expiring nonce transactions (replay check + insert). /// @@ -366,7 +366,7 @@ fn calculate_key_authorization_gas( // T1B+: Accurate gas matching actual precompile storage operations. // authorize_key does: 1 SLOAD (read existing key) + 1 SSTORE (write key) // + N SSTOREs (one per spending limit) + 2k buffer (TSTORE + keccak + event) - // T5 witness authorizations emit one additional LOG3 event with no data. + // T5 witness and T6 admin authorizations emit additional LOG3 events with no data. const BUFFER: u64 = 2_000; let sload_cost = gas_params.warm_storage_read_cost() + gas_params.cold_storage_additional_cost(); @@ -392,11 +392,15 @@ fn calculate_key_authorization_gas( // T6 account-bound authorizations may be signed by an existing admin key instead of the // root key. Charge one worst-case cold read for validating that admin signer row. if spec.is_t6() && key_auth.account.is_some() { - total_gas += sload_cost; + regular_gas += sload_cost; } if has_t5_witness { - regular_gas += sload_cost + KEY_AUTH_T5_WITNESS_EVENT_BUFFER; + regular_gas += sload_cost + KEY_AUTH_EXTRA_EVENT_BUFFER; + } + + if spec.is_t6() && key_auth.is_admin() { + regular_gas += KEY_AUTH_EXTRA_EVENT_BUFFER; } // T4+: include extra gas for call scopes configuration @@ -3355,7 +3359,7 @@ mod tests { assert_eq!( witness_t5_gas - base_t5_gas, - t5_sload + KEY_AUTH_T5_WITNESS_EVENT_BUFFER, + t5_sload + KEY_AUTH_EXTRA_EVENT_BUFFER, "T5 witness adds one burned-witness SLOAD and one event" ); assert_eq!( @@ -3401,12 +3405,18 @@ mod tests { ); assert_eq!( admin_t6_gas - base_t6_gas, - t6_sload, - "T6 account-bound admin authorization charges one admin-signer cold read" + t6_sload + KEY_AUTH_EXTRA_EVENT_BUFFER, + "T6 account-bound admin authorization charges one admin-signer cold read and one extra event buffer" + ); + assert_eq!( + admin_t6_gas - account_bound_t6_gas, + KEY_AUTH_EXTRA_EVENT_BUFFER, + "T6 admin authorization pays one extra event buffer over non-admin account-bound authorization" ); assert_eq!( - unbound_admin_t6_gas, base_t6_gas, - "T6 root-signed admin authorization without account does not charge admin-signer read" + unbound_admin_t6_gas - base_t6_gas, + KEY_AUTH_EXTRA_EVENT_BUFFER, + "T6 root-signed admin authorization without account charges only the extra event buffer" ); assert_eq!( account_bound_t6_state_gas, base_t6_state_gas, From 259efd59733bca9273182f54af61d38bf299e5e7 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 27 May 2026 20:25:54 +0530 Subject: [PATCH 22/39] refactor(revm): pipeline keychain authorization validation --- crates/revm/src/handler.rs | 228 ++++++++++++++++++------------------- 1 file changed, 113 insertions(+), 115 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 47ef1445ba..54d10e6a69 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -167,6 +167,13 @@ fn validate_t6_key_authorization_account_binding( Ok(()) } +#[derive(Debug, Clone, Copy)] +struct LoadedTxAccessKey { + key_id: Address, + is_admin: bool, + signature_type: u8, +} + /// Counts the scope storage rows that pay the dynamic SSTORE-set path for the active spec. /// /// T3 keeps the broader all-persisted-rows accounting from current main. T4 narrows this to rows @@ -1162,8 +1169,10 @@ where // Note: Signature verification happens during recover_signer() before entering the pool // Note: Transaction parameter validation (priority fee, time window) happens in validate_env() - // For Keychain signatures, validate that the keychain is authorized in the precompile - // before fee collection so existing-key fee charges can consume spending limits. + // For Keychain signatures, validate the acting access key before fee collection when it + // already exists. Same-tx auth+use is the exception: that key is registered only after fees + // are collected, so fee-limit validation uses the inline authorization payload instead. + let mut loaded_tx_access_key = None; if let Some(tempo_tx_env) = tx.tempo_tx_env.as_ref() && let Some(keychain_sig) = tempo_tx_env.signature.as_keychain() { @@ -1180,101 +1189,37 @@ where .into()); } - if let Some(key_auth) = tempo_tx_env.key_authorization.as_ref() { - // T6 adds admin delegation: the keychain signer may be an existing admin key that - // authorizes a different child key. Earlier forks only allow same-tx auth+use, and - // `validate_env` rejects non-matching key IDs before this state-aware phase. - let same_tx_auth_use = if cfg.spec.is_t6() { - let access_key_addr = - if let Some(override_key_id) = tempo_tx_env.override_key_id { - override_key_id - } else { - keychain_sig - .key_id(&tempo_tx_env.signature_hash) - .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? - }; - - let same_tx_auth_use = access_key_addr == key_auth.key_id; - - if !same_tx_auth_use { - let stored_key_expiry = StorageCtx::enter_precompile( - journal, - block, - cfg, - tx, - |mut keychain: AccountKeychain| { - let sig_type = Some(keychain_sig.signature.signature_type().into()); - - let key = keychain - .validate_keychain_authorization( - *user_address, - access_key_addr, - block.timestamp().to::(), - sig_type, - ) - .map_err(|e| { - TempoInvalidTransaction::KeychainValidationFailed { - reason: format!("{e:?}"), - } - })?; - - if !key.is_admin { - return Err( - TempoInvalidTransaction::AccessKeyCannotAuthorizeOtherKeys - .into(), - ); - } - - keychain - .set_transaction_key(access_key_addr) - .map_err(|e| EVMError::Custom(e.to_string()))?; - - Ok::<_, EVMError<_, TempoInvalidTransaction>>(key.expiry) - }, - )?; - - evm.key_expiry = Some(stored_key_expiry); - } + // Use override_key_id if provided (for gas estimation), otherwise recover from signature. + let access_key_addr = if let Some(override_key_id) = tempo_tx_env.override_key_id { + override_key_id + } else { + keychain_sig + .key_id(&tempo_tx_env.signature_hash) + .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? + }; - same_tx_auth_use + let key_auth = tempo_tx_env.key_authorization.as_ref(); + // Classify whether this keychain-signed tx is using the same access key that the + // inline authorization registers. Before T6, keychain-signed transactions with inline + // key authorizations must have this shape because access keys cannot authorize other + // keys; root-signed key authorizations are handled outside this keychain-signer path. + let same_tx_auth_use = key_auth.is_some_and(|key_auth| { + if cfg.spec.is_t6() { + access_key_addr == key_auth.key_id } else { true - }; - - // If this is a same tx auth+use, validate that spending limit is enough to cover the fee. - // - // `collectFeePreTx` would not validate the spending limit because the key is not authorized yet and we are not setting the transient key_id. - if !gas_balance_spending.is_zero() - && fee_payer == tx.caller - && same_tx_auth_use - && let Some(limits) = key_auth.limits.as_ref() - { - let remaining = limits - .iter() - .rev() - .find(|limit| limit.token == fee_token) - .map(|limit| limit.limit) - .unwrap_or_default(); - - if gas_balance_spending > remaining { - return Err( - FeePaymentError::Other("SpendingLimitExceeded".to_string()).into() - ); - } } - } else { - // Use override_key_id if provided (for gas estimation), otherwise recover from signature. - let access_key_addr = if let Some(override_key_id) = tempo_tx_env.override_key_id { - override_key_id - } else { - // Get the access key address (recovered during pool validation and cached) - keychain_sig - .key_id(&tempo_tx_env.signature_hash) - .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? - }; + }); + + let should_validate_existing_tx_key = + key_auth.is_none() || (cfg.spec.is_t6() && !same_tx_auth_use); - // If this transaction is using an already-authorized key, validate the signature against stored key and set the transient key_id. - let stored_key_expiry = StorageCtx::enter_precompile( + if should_validate_existing_tx_key { + // Existing-key path: + // - ordinary keychain txs must validate the acting access key before fees are paid + // - T6 delegated key authorizations also validate the acting key here, then reuse + // the loaded admin/signature-type facts below when the sidecar signer is the same key + let (stored_key_expiry, loaded_key) = StorageCtx::enter_precompile( journal, block, cfg, @@ -1285,9 +1230,8 @@ where // type to authenticate as a key registered with a different type. // Only validate signature type on T1+ to maintain backward compatibility // with historical blocks during re-execution. - let sig_type = spec - .is_t1() - .then_some(keychain_sig.signature.signature_type().into()); + let tx_sig_type = keychain_sig.signature.signature_type().into(); + let sig_type = (key_auth.is_some() || spec.is_t1()).then_some(tx_sig_type); let key = keychain .validate_keychain_authorization( @@ -1300,6 +1244,14 @@ where reason: format!("{e:?}"), })?; + // T6 adds admin delegation: a keychain signer may authorize a different + // child key only if the acting transaction key is itself an active admin key. + if key_auth.is_some() && !same_tx_auth_use && !key.is_admin { + return Err( + TempoInvalidTransaction::AccessKeyCannotAuthorizeOtherKeys.into() + ); + } + // Set the transaction key in the keychain precompile. // The TIP20 precompile will read this during fee collection and // execution to enforce spending limits for existing keys. @@ -1307,11 +1259,43 @@ where .set_transaction_key(access_key_addr) .map_err(|e| EVMError::Custom(e.to_string()))?; - Ok::<_, EVMError<_, TempoInvalidTransaction>>(key.expiry) + Ok::<_, EVMError<_, TempoInvalidTransaction>>(( + key.expiry, + LoadedTxAccessKey { + key_id: access_key_addr, + is_admin: key.is_admin, + signature_type: key.signature_type as u8, + }, + )) }, )?; evm.key_expiry = Some(stored_key_expiry); + loaded_tx_access_key = Some(loaded_key); + } + + if let Some(key_auth) = key_auth { + // Same-tx auth+use path: the access key does not exist in storage yet, so the fee + // check must use the inline limits directly. `collectFeePreTx` cannot enforce this + // because `transaction_key` is intentionally not set until after authorization. + if !gas_balance_spending.is_zero() + && fee_payer == tx.caller + && same_tx_auth_use + && let Some(limits) = key_auth.limits.as_ref() + { + let remaining = limits + .iter() + .rev() + .find(|limit| limit.token == fee_token) + .map(|limit| limit.limit) + .unwrap_or_default(); + + if gas_balance_spending > remaining { + return Err( + FeePaymentError::Other("SpendingLimitExceeded".to_string()).into() + ); + } + } } } @@ -1336,26 +1320,40 @@ where .into()); } - let admin_key = StorageCtx::enter_precompile( - journal, - block, - cfg, - tx, - |keychain: AccountKeychain| { - keychain - .validate_keychain_authorization( - tx.caller, - auth_signer, - block.timestamp().to::(), - Some(key_auth.signature.signature_type().into()), - ) - .map_err(|e| TempoInvalidTransaction::KeychainValidationFailed { - reason: format!("{e:?}"), - }) - }, - )?; + let key_auth_sig_type = key_auth.signature.signature_type().into(); + // The tx key signer and the KeyAuthorization signer are distinct roles. Usually + // they are the same admin key, so reuse the key loaded above. If the sidecar was + // signed by another admin key, or if the sidecar signature type differs, fall back + // to storage validation so the prior SignatureTypeMismatch behavior is preserved. + let signer_is_admin = match loaded_tx_access_key { + Some(loaded_key) + if loaded_key.key_id == auth_signer + && loaded_key.signature_type == key_auth_sig_type => + { + loaded_key.is_admin + } + _ => StorageCtx::enter_precompile( + journal, + block, + cfg, + tx, + |keychain: AccountKeychain| { + keychain + .validate_keychain_authorization( + tx.caller, + auth_signer, + block.timestamp().to::(), + Some(key_auth_sig_type), + ) + .map(|key| key.is_admin) + .map_err(|e| TempoInvalidTransaction::KeychainValidationFailed { + reason: format!("{e:?}"), + }) + }, + )?, + }; - if !admin_key.is_admin { + if !signer_is_admin { return Err(TempoInvalidTransaction::KeyAuthorizationNotSignedByRoot { expected: tx.caller, actual: auth_signer, From 9ea5d278771dcb9fe7e0e3a63bf049534c1c76d1 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 27 May 2026 20:53:57 +0530 Subject: [PATCH 23/39] fix(account-keychain): allow mutators on stored self-key rows --- .../precompiles/src/account_keychain/mod.rs | 145 ++++++++++++------ tips/tip-1049.md | 11 +- 2 files changed, 106 insertions(+), 50 deletions(-) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 58d43cdc5f..b30cf6ca3c 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -253,6 +253,8 @@ impl AccountKeychain { if key_id == Address::ZERO { return Err(AccountKeychainError::zero_public_key().into()); } + // T6+ keeps the account root key implicit. Do not create a stored access-key row + // using the root key id. if self.storage.spec().is_t6() && key_id == msg_sender { return Err(AccountKeychainError::invalid_key_id().into()); } @@ -373,6 +375,7 @@ impl AccountKeychain { if key_id == Address::ZERO { return Err(AccountKeychainError::zero_public_key().into()); } + // Admin keys are explicit access-key rows; the root key remains implicit. if key_id == msg_sender { return Err(AccountKeychainError::invalid_key_id().into()); } @@ -437,14 +440,11 @@ impl AccountKeychain { /// this account, preventing replay of old `KeyAuthorization` signatures. /// /// # Errors - /// - `UnauthorizedCaller` — only the main key can authorize/revoke and, for contract - /// callers on T2+, `msg.sender` must match `tx.origin` + /// - `UnauthorizedCaller` — only the root key or, on T6+, an admin access key can revoke; + /// for contract callers on T2+, `msg.sender` must match `tx.origin` /// - `KeyNotFound` — no key registered with this ID pub fn revoke_key(&mut self, msg_sender: Address, call: revokeKeyCall) -> Result<()> { self.ensure_admin_caller(msg_sender)?; - if self.storage.spec().is_t6() && call.keyId == msg_sender { - return Err(AccountKeychainError::invalid_key_id().into()); - } let key = self.keys[msg_sender][call.keyId].read()?; @@ -472,8 +472,9 @@ impl AccountKeychain { /// limited one. Delegates to `load_active_key` for existence/revocation/expiry checks. /// /// # Errors - /// - `UnauthorizedCaller` — the transaction wasn't signed by the main key, or on T2+ - /// contract callers where `msg.sender != tx.origin` + /// - `UnauthorizedCaller` — the transaction wasn't signed by the root key or, on T6+, an + /// admin access key, or on T2+ contract callers where `msg.sender != tx.origin` + /// - `InvalidKeyId` — on T6+, `keyId` cannot be an admin access key /// - `KeyAlreadyRevoked` — the target key has been permanently revoked /// - `KeyNotFound` — no key is registered under the given `keyId` /// - `KeyExpired` — the key's expiry is at or before the current block timestamp @@ -484,10 +485,6 @@ impl AccountKeychain { ) -> Result<()> { self.ensure_admin_caller(msg_sender)?; - if self.storage.spec().is_t6() && call.keyId == msg_sender { - return Err(AccountKeychainError::invalid_key_id().into()); - } - let current_timestamp = self.storage.timestamp().saturating_to::(); let mut key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; if key.is_admin { @@ -586,7 +583,7 @@ impl AccountKeychain { }) } - /// Root-only create-or-replace updates for one or more target call scopes. + /// Root/admin-only create-or-replace updates for one or more target call scopes. pub fn set_allowed_calls( &mut self, msg_sender: Address, @@ -598,10 +595,6 @@ impl AccountKeychain { self.ensure_admin_caller(msg_sender)?; - if self.storage.spec().is_t6() && call.keyId == msg_sender { - return Err(AccountKeychainError::invalid_key_id().into()); - } - let current_timestamp = self.storage.timestamp().saturating_to::(); let key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; if key.is_admin { @@ -624,7 +617,7 @@ impl AccountKeychain { self.key_scopes[key_hash].is_scoped.write(true) } - /// Root-only removal of one target call scope. + /// Root/admin-only removal of one target call scope. pub fn remove_allowed_calls( &mut self, msg_sender: Address, @@ -632,10 +625,6 @@ impl AccountKeychain { ) -> Result<()> { self.ensure_admin_caller(msg_sender)?; - if self.storage.spec().is_t6() && call.keyId == msg_sender { - return Err(AccountKeychainError::invalid_key_id().into()); - } - let current_timestamp = self.storage.timestamp().saturating_to::(); let key = self.load_active_key(msg_sender, call.keyId, current_timestamp)?; if key.is_admin { @@ -1612,6 +1601,18 @@ mod tests { } } + fn assert_key_not_found(error: TempoPrecompileError) { + match error { + TempoPrecompileError::AccountKeychainError(e) => { + assert!( + matches!(e, AccountKeychainError::KeyNotFound(_)), + "Expected KeyNotFound error, got: {e:?}" + ); + } + _ => panic!("Expected AccountKeychainError, got: {error:?}"), + } + } + fn unrestricted_restrictions() -> KeyRestrictions { tempo_alloy::provider::keychain::KeyRestrictions::default().into() } @@ -1754,7 +1755,7 @@ mod tests { } #[test] - fn test_t6_admin_key_restrictions_and_root_slot_rejected() -> eyre::Result<()> { + fn test_t6_admin_key_restrictions_and_root_authorization_rejected() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); let account = Address::random(); let admin_key = Address::random(); @@ -1780,9 +1781,16 @@ mod tests { ); assert_invalid_key_id( - keychain - .revoke_key(account, revokeKeyCall { keyId: account }) - .expect_err("root key cannot be revoked"), + authorize_key( + &mut keychain, + account, + authorizeKeyCall { + keyId: account, + signatureType: SignatureType::Secp256k1, + config: unrestricted_restrictions(), + }, + ) + .expect_err("root key cannot be registered as an access key"), ); assert_invalid_key_id( @@ -1796,17 +1804,24 @@ mod tests { } #[test] - fn test_t6_restriction_mutators_reject_root_slot() -> eyre::Result<()> { + fn test_t6_root_slot_mutators_use_stored_key_row() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); let account = Address::random(); let token = Address::random(); + let target = Address::random(); StorageCtx::enter(&mut storage, || { let mut keychain = AccountKeychain::new(); keychain.initialize()?; keychain.set_tx_origin(account)?; - assert_invalid_key_id( + assert_key_not_found( + keychain + .revoke_key(account, revokeKeyCall { keyId: account }) + .expect_err("missing self-key row cannot be revoked"), + ); + + assert_key_not_found( keychain .update_spending_limit( account, @@ -1816,34 +1831,34 @@ mod tests { newLimit: U256::from(1), }, ) - .expect_err("root slot cannot receive spending limits"), + .expect_err("missing self-key row cannot receive spending limits"), ); - assert_invalid_key_id( + assert_key_not_found( keychain .set_allowed_calls( account, setAllowedCallsCall { keyId: account, scopes: vec![CallScope { - target: Address::random(), + target, selectorRules: vec![], }], }, ) - .expect_err("root slot cannot receive call scopes"), + .expect_err("missing self-key row cannot receive call scopes"), ); - assert_invalid_key_id( + assert_key_not_found( keychain .remove_allowed_calls( account, removeAllowedCallsCall { keyId: account, - target: Address::random(), + target, }, ) - .expect_err("root slot cannot remove call scopes"), + .expect_err("missing self-key row cannot remove call scopes"), ); keychain.keys[account][account].write(AuthorizedKey { @@ -1854,19 +1869,59 @@ mod tests { is_admin: false, })?; - assert_invalid_key_id( - keychain - .update_spending_limit( - account, - updateSpendingLimitCall { - keyId: account, - token, - newLimit: U256::from(1), - }, - ) - .expect_err("legacy self-key row cannot receive spending limits"), + keychain.update_spending_limit( + account, + updateSpendingLimitCall { + keyId: account, + token, + newLimit: U256::from(1), + }, + )?; + assert_eq!( + keychain.get_remaining_limit(getRemainingLimitCall { + account, + keyId: account, + token, + })?, + U256::from(1) ); + keychain.set_allowed_calls( + account, + setAllowedCallsCall { + keyId: account, + scopes: vec![CallScope { + target, + selectorRules: vec![], + }], + }, + )?; + let allowed_calls = keychain.get_allowed_calls(getAllowedCallsCall { + account, + keyId: account, + })?; + assert!(allowed_calls.isScoped); + assert_eq!(allowed_calls.scopes.len(), 1); + assert_eq!(allowed_calls.scopes[0].target, target); + + keychain.remove_allowed_calls( + account, + removeAllowedCallsCall { + keyId: account, + target, + }, + )?; + let allowed_calls = keychain.get_allowed_calls(getAllowedCallsCall { + account, + keyId: account, + })?; + assert!(allowed_calls.isScoped); + assert!(allowed_calls.scopes.is_empty()); + + keychain.revoke_key(account, revokeKeyCall { keyId: account })?; + assert!(keychain.keys[account][account].read()?.is_revoked); + assert!(keychain.is_admin_key(account, account)?); + Ok(()) }) } diff --git a/tips/tip-1049.md b/tips/tip-1049.md index ac841d89ed..0c2dd85e60 100644 --- a/tips/tip-1049.md +++ b/tips/tip-1049.md @@ -124,12 +124,13 @@ Contracts that need to inspect admin status outside a signature-verification flo `ensure_admin_caller` is widened to additionally accept admin access keys. Every mutator gated by `ensure_admin_caller` (including the new `authorizeAdminKey`) MAY now be called by either the root key or an admin access key, and MUST revert with `AccountKeychainError::UnauthorizedCaller` for any other caller. -When the target `keyId` resolves to an admin key (including the caller targeting itself): +`authorizeKey` and `authorizeAdminKey` MUST reject `keyId == account` with `AccountKeychainError::InvalidKeyId`. New authorizations must not create a stored access-key row that collides with the account's implicit root key. -- `updateSpendingLimit`, `setAllowedCalls`, `removeAllowedCalls` MUST reject with `AccountKeychainError::InvalidKeyId`. Admin keys have no `KeyRestrictions`. -- `revokeKey` MAY be called and revokes the admin key (including self-revocation). +For existing stored key rows, mutators operate on the row selected by `keyId`: -`revokeKey` MUST NOT be used to revoke the root key; any attempt to revoke the root EOA key MUST be rejected. +- When the selected row is an admin key, `updateSpendingLimit`, `setAllowedCalls`, and `removeAllowedCalls` MUST reject with `AccountKeychainError::InvalidKeyId`. Admin keys have no `KeyRestrictions`. +- `revokeKey` MAY be called and revokes any authorized key, including an admin key targeting itself. +- If `keyId == account`, these mutators do not special-case the implicit root key: they return `AccountKeychainError::KeyNotFound` when no stored row exists, and otherwise operate only on the stored `keys[account][account]` row. ### TIP-1020 Compatibility @@ -174,4 +175,4 @@ The target `account`, `is_admin`, and `witness` fields are part of the signed RL 3. **Backward compatibility.** Existing access keys (authorized before this TIP activates) default to `is_admin = false`. Their behavior is unchanged. -Pre-existing `keys[account][account]` entries MUST NOT affect root-key status. `revokeKey` MUST reject `keyId == account` regardless of any stored legacy key entry. +Pre-existing `keys[account][account]` entries MUST NOT affect root-key status. New authorizations MUST reject `keyId == account`, but mutators that target existing access-key rows MAY operate on pre-existing `keys[account][account]` rows. Such mutations affect only the stored row and MUST NOT change implicit root-key status. From c120f7fbb698918dcf42c60ac541b88372272708 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 27 May 2026 21:03:19 +0530 Subject: [PATCH 24/39] refactor(account-keychain): reuse active key checks for admin status --- .../precompiles/src/account_keychain/mod.rs | 68 ++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index b30cf6ca3c..df0a8f1ff4 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -1130,8 +1130,17 @@ impl AccountKeychain { } let current_timestamp = self.storage.timestamp().saturating_to::(); - let key = self.keys[account][key_id].read()?; - Ok(key.expiry != 0 && !key.is_revoked && current_timestamp < key.expiry && key.is_admin) + let key = match self.load_active_key(account, key_id, current_timestamp) { + Ok(key) => key, + Err(crate::error::TempoPrecompileError::AccountKeychainError( + AccountKeychainError::KeyAlreadyRevoked(_) + | AccountKeychainError::KeyNotFound(_) + | AccountKeychainError::KeyExpired(_), + )) => return Ok(false), + Err(err) => return Err(err), + }; + + Ok(key.is_admin) } fn ensure_key_authorization_witness_not_burned( @@ -1643,6 +1652,61 @@ mod tests { }) } + #[test] + fn test_t6_is_admin_key_uses_active_key_status() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(100u64)); + let account = Address::random(); + let active_admin_key = Address::random(); + let non_admin_key = Address::random(); + let revoked_admin_key = Address::random(); + let expired_admin_key = Address::random(); + let missing_key = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + + keychain.keys[account][active_admin_key].write(AuthorizedKey { + signature_type: StoredSignatureType::Secp256k1, + expiry: u64::MAX, + enforce_limits: false, + is_revoked: false, + is_admin: true, + })?; + keychain.keys[account][non_admin_key].write(AuthorizedKey { + signature_type: StoredSignatureType::Secp256k1, + expiry: u64::MAX, + enforce_limits: false, + is_revoked: false, + is_admin: false, + })?; + keychain.keys[account][revoked_admin_key].write(AuthorizedKey { + signature_type: StoredSignatureType::Secp256k1, + expiry: u64::MAX, + enforce_limits: false, + is_revoked: true, + is_admin: true, + })?; + keychain.keys[account][expired_admin_key].write(AuthorizedKey { + signature_type: StoredSignatureType::Secp256k1, + expiry: 100, + enforce_limits: false, + is_revoked: false, + is_admin: true, + })?; + + assert!(keychain.is_admin_key(account, account)?); + assert!(keychain.is_admin_key(account, active_admin_key)?); + assert!(!keychain.is_admin_key(account, non_admin_key)?); + assert!(!keychain.is_admin_key(account, revoked_admin_key)?); + assert!(!keychain.is_admin_key(account, expired_admin_key)?); + assert!(!keychain.is_admin_key(account, missing_key)?); + + Ok(()) + }) + } + #[test] fn test_t6_authorize_admin_key_with_witness_checks_burned_state() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); From 449461d50f4029ac5b7b5ebd700c6bf95b97ff1c Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 27 May 2026 22:47:15 +0530 Subject: [PATCH 25/39] refactor(revm): move key authorization signer checks to env --- crates/revm/src/evm.rs | 6 ++ crates/revm/src/handler.rs | 141 +++++++++++++++++++++---------------- 2 files changed, 86 insertions(+), 61 deletions(-) diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index de201ec1e4..9a16550d5f 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -41,6 +41,10 @@ pub struct TempoEvm { /// The expiry timestamp of the access key used by the current transaction. /// Populated during validation for keychain-signed transactions or transactions carrying a KeyAuthorization. pub(crate) key_expiry: Option, + /// Recovered signer for the current transaction's inline key authorization. + /// + /// Populated by stateless validation and reused by state validation for admin-key checks. + pub(crate) key_authorization_signer: Option
, /// When true, skips the `valid_after` time-window check during validation. /// /// The transaction pool sets this because it intentionally accepts transactions @@ -85,6 +89,7 @@ impl TempoEvm { validator_fee: U256::ZERO, fee_token: None, key_expiry: None, + key_authorization_signer: None, skip_valid_after_check: false, skip_liquidity_check: false, } @@ -127,6 +132,7 @@ impl TempoEvm { pub fn clear(&mut self) { self.fee_token = None; self.key_expiry = None; + self.key_authorization_signer = None; } } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 54d10e6a69..ca973f8975 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -58,8 +58,8 @@ use tempo_precompiles::{ use tempo_primitives::{ TempoAddressExt, transaction::{ - PrimitiveSignature, SignatureType, SignedKeyAuthorization, TEMPO_EXPIRING_NONCE_KEY, - TempoSignature, calc_gas_balance_spending, validate_calls, + PrimitiveSignature, SignatureType, TEMPO_EXPIRING_NONCE_KEY, TempoSignature, + calc_gas_balance_spending, validate_calls, }, }; @@ -143,30 +143,6 @@ fn tempo_signature_verification_gas(signature: &TempoSignature) -> u64 { } } -/// Validates the account binding carried by T6 key authorizations. -/// -/// T6 allows existing admin keys to sign `KeyAuthorization`s for an account. Any authorization -/// that names an account must be bound to the transaction caller so the signed payload cannot be -/// replayed against another account where the same admin key is also authorized. -fn validate_t6_key_authorization_account_binding( - key_auth: &SignedKeyAuthorization, - caller: Address, -) -> Result<(), TempoInvalidTransaction> { - if key_auth.account.is_some_and(|account| account != caller) { - let reason = if key_auth.is_admin() { - "admin key authorization account mismatch" - } else { - "key authorization account mismatch" - }; - - return Err(TempoInvalidTransaction::KeychainValidationFailed { - reason: reason.to_string(), - }); - } - - Ok(()) -} - #[derive(Debug, Clone, Copy)] struct LoadedTxAccessKey { key_id: Address, @@ -936,6 +912,7 @@ where init_gas: &mut InitialAndFloorGas, ) -> Result<(), Self::Error> { self.seed_precompile_tx_context(evm)?; + let key_authorization_signer = evm.key_authorization_signer; let block = &evm.inner.ctx.block; let tx = &evm.inner.ctx.tx; @@ -1299,27 +1276,16 @@ where } } - // T6 defers `KeyAuthorization` signer checks from `validate_env` to this state-aware phase: - // root-signed authorizations remain valid, while admin-signed authorizations must name the - // caller account and prove that the sidecar signer is an active admin key for that account. + // T6 stateless signer/account checks run in `validate_env`. This state-aware phase only + // proves that a non-root sidecar signer is an active admin key for the caller account. if cfg.spec.is_t6() && let Some(tempo_tx_env) = tx.tempo_tx_env.as_ref() && let Some(key_auth) = tempo_tx_env.key_authorization.as_ref() { - validate_t6_key_authorization_account_binding(key_auth, tx.caller)?; - - let auth_signer = key_auth - .recover_signer() - .map_err(|_| TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed)?; + let auth_signer = key_authorization_signer + .expect("T6 key authorization signer is set during validate_env"); if auth_signer != tx.caller { - if key_auth.account.is_none() { - return Err(TempoInvalidTransaction::KeychainValidationFailed { - reason: "admin-signed key authorization account mismatch".to_string(), - } - .into()); - } - let key_auth_sig_type = key_auth.signature.signature_type().into(); // The tx key signer and the KeyAuthorization signer are distinct roles. Usually // they are the same admin key, so reuse the key loaded above. If the sidecar was @@ -1713,6 +1679,7 @@ where fn validate_env(&self, evm: &mut Self::Evm) -> Result<(), Self::Error> { // Reset per-tx validator fee. evm.validator_fee = U256::ZERO; + evm.key_authorization_signer = None; // Validate the fee payer signature let fee_payer = evm.ctx.tx.fee_payer()?; @@ -1737,6 +1704,7 @@ where // AA-specific validations let cfg = &evm.inner.cfg; let tx = &evm.inner.tx; + let mut key_authorization_signer = None; if let Some(aa_env) = tx.tempo_tx_env.as_ref() { // Validate AA transaction structure (calls list, CREATE rules) @@ -1821,8 +1789,22 @@ where .into()); } - if cfg.spec.is_t6() { - validate_t6_key_authorization_account_binding(key_auth, tx.caller)?; + if cfg.spec.is_t6() && key_auth.account.is_some_and(|account| account != tx.caller) + { + // T6 allows existing admin keys to sign `KeyAuthorization`s for an + // account. Any named account must match the transaction caller so the + // signed payload cannot be replayed against another account where the + // same admin key is also authorized. + let reason = if key_auth.is_admin() { + "admin key authorization account mismatch" + } else { + "key authorization account mismatch" + }; + + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: reason.to_string(), + } + .into()); } if key_auth.is_admin() { @@ -1891,6 +1873,20 @@ where } } + if cfg.spec.is_t6() { + let auth_signer = key_auth.recover_signer().map_err(|_| { + TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed + })?; + if auth_signer != tx.caller && key_auth.account.is_none() { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: "admin-signed key authorization account mismatch".to_string(), + } + .into()); + } + + key_authorization_signer = Some(auth_signer); + } + // Cache inline key authorization expiry. if let Some(expiry) = key_auth.expiry { evm.key_expiry = Some(expiry.get()); @@ -1917,6 +1913,8 @@ where validate_time_window(valid_after, aa_env.valid_before, block_timestamp)?; } + evm.key_authorization_signer = key_authorization_signer; + Ok(()) } @@ -5219,6 +5217,12 @@ mod tests { let signed = sign_key_auth(&signer, key_auth); let (mut evm, h) = make_evm(user, key, Some(signed), TempoHardfork::T6, None, false); + let env_result = h.validate_env(&mut evm); + assert!( + env_result.is_ok(), + "root-signed admin key authorization should pass stateless validation, got: {env_result:?}" + ); + let result = h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); assert!( @@ -5302,6 +5306,12 @@ mod tests { false, ); + let env_result = h.validate_env(&mut evm); + assert!( + env_result.is_ok(), + "admin access key authorization should pass stateless validation, got: {env_result:?}" + ); + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { let mut keychain = AccountKeychain::new(); keychain @@ -5345,22 +5355,14 @@ mod tests { false, ); - StorageCtx::enter_ctx(&mut evm.inner.ctx, || { - let mut keychain = AccountKeychain::new(); - keychain - .authorize_admin_key(user, admin_key, PrecompileSignatureType::Secp256k1, None) - .expect("root authorizes admin key"); - }); - - let result = - h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); + let result = h.validate_env(&mut evm); assert!( matches!( &result, Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) if reason.contains("admin-signed key authorization account mismatch") ), - "admin-signed non-admin authorization without account binding should fail, got: {result:?}" + "admin-signed non-admin authorization without account binding should fail in validate_env, got: {result:?}" ); } @@ -5383,6 +5385,12 @@ mod tests { false, ); + let env_result = h.validate_env(&mut evm); + assert!( + env_result.is_ok(), + "admin-signed key authorization should pass stateless validation, got: {env_result:?}" + ); + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { let mut keychain = AccountKeychain::new(); keychain @@ -5424,6 +5432,12 @@ mod tests { None, false, ); + let alice_env_result = alice_handler.validate_env(&mut alice_evm); + assert!( + alice_env_result.is_ok(), + "account-bound authorization should pass Alice stateless validation, got: {alice_env_result:?}" + ); + StorageCtx::enter_ctx(&mut alice_evm.inner.ctx, || { let mut keychain = AccountKeychain::new(); keychain @@ -5456,15 +5470,8 @@ mod tests { let (mut bob_evm, bob_handler) = make_evm(bob, admin_key, Some(signed), TempoHardfork::T6, None, false); - StorageCtx::enter_ctx(&mut bob_evm.inner.ctx, || { - let mut keychain = AccountKeychain::new(); - keychain - .authorize_admin_key(bob, admin_key, PrecompileSignatureType::Secp256k1, None) - .expect("root authorizes Bob admin key"); - }); - let bob_result = bob_handler - .validate_against_state_and_deduct_caller(&mut bob_evm, &mut Default::default()); + let bob_result = bob_handler.validate_env(&mut bob_evm); assert!( matches!( &bob_result, @@ -5506,6 +5513,12 @@ mod tests { evm.inner.ctx.tx.inner.gas_price = 1_000_000_000_000; evm.inner.ctx.tx.inner.gas_priority_fee = Some(1_000_000_000_000); + let env_result = h.validate_env(&mut evm); + assert!( + env_result.is_ok(), + "admin delegation should pass stateless validation, got: {env_result:?}" + ); + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { TIP20Setup::path_usd(user) .with_issuer(user) @@ -5548,6 +5561,12 @@ mod tests { false, ); + let env_result = h.validate_env(&mut evm); + assert!( + env_result.is_ok(), + "admin delegation should pass stateless validation, got: {env_result:?}" + ); + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { let mut keychain = AccountKeychain::new(); keychain From 720bfac3a51dba67e1dc8dbb795053e208b24a89 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 27 May 2026 23:11:47 +0530 Subject: [PATCH 26/39] fix(revm): collapse admin key authorization check --- crates/revm/src/handler.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index ca973f8975..2b5a668584 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1807,17 +1807,17 @@ where .into()); } - if key_auth.is_admin() { - if key_auth.expiry.is_some() + if key_auth.is_admin() + && (key_auth.expiry.is_some() || key_auth.limits.is_some() - || key_auth.allowed_calls.is_some() - { - return Err(TempoInvalidTransaction::KeychainValidationFailed { - reason: "admin key authorizations cannot carry expiry, limits, or call scopes" - .to_string(), - } - .into()); + || key_auth.allowed_calls.is_some()) + { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: + "admin key authorizations cannot carry expiry, limits, or call scopes" + .to_string(), } + .into()); } if cfg.spec.is_t6() && key_auth.key_id == tx.caller { From 46a95d47604afc9174e9282a7aedcb5e0442bb3a Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 15:52:05 +0530 Subject: [PATCH 27/39] fix(revm): require admin auth signer to match tx key --- crates/revm/src/handler.rs | 124 +++++++++++++++++++++++++------------ tips/tip-1049.md | 7 ++- 2 files changed, 90 insertions(+), 41 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 2b5a668584..c83e1f234a 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -372,12 +372,6 @@ fn calculate_key_authorization_gas( let sstore_cost = gas_params.get(GasId::sstore_set_without_load_cost()); let mut regular_gas = sig_gas + sload_cost + sstore_cost * num_sstores + BUFFER; - // T6 account-bound authorizations may be signed by an existing admin key instead of the - // root key. Charge one worst-case cold read for validating that admin signer row. - if spec.is_t6() && key_auth.account.is_some() { - regular_gas += sload_cost; - } - if has_t5_witness { regular_gas += sload_cost + KEY_AUTH_EXTRA_EVENT_BUFFER; } @@ -1286,11 +1280,7 @@ where .expect("T6 key authorization signer is set during validate_env"); if auth_signer != tx.caller { - let key_auth_sig_type = key_auth.signature.signature_type().into(); - // The tx key signer and the KeyAuthorization signer are distinct roles. Usually - // they are the same admin key, so reuse the key loaded above. If the sidecar was - // signed by another admin key, or if the sidecar signature type differs, fall back - // to storage validation so the prior SignatureTypeMismatch behavior is preserved. + let key_auth_sig_type: u8 = key_auth.signature.signature_type().into(); let signer_is_admin = match loaded_tx_access_key { Some(loaded_key) if loaded_key.key_id == auth_signer @@ -1298,25 +1288,14 @@ where { loaded_key.is_admin } - _ => StorageCtx::enter_precompile( - journal, - block, - cfg, - tx, - |keychain: AccountKeychain| { - keychain - .validate_keychain_authorization( - tx.caller, - auth_signer, - block.timestamp().to::(), - Some(key_auth_sig_type), - ) - .map(|key| key.is_admin) - .map_err(|e| TempoInvalidTransaction::KeychainValidationFailed { - reason: format!("{e:?}"), - }) - }, - )?, + Some(_) | None => { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: + "admin-signed key authorization must be signed by transaction key" + .to_string(), + } + .into()); + } }; if !signer_is_admin { @@ -1884,6 +1863,46 @@ where .into()); } + if auth_signer != tx.caller { + let Some(keychain_sig) = aa_env.signature.as_keychain() else { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: + "admin-signed key authorization must be signed by transaction key" + .to_string(), + } + .into()); + }; + + let access_key_addr = if let Some(override_key_id) = aa_env.override_key_id + { + override_key_id + } else { + keychain_sig + .key_id(&aa_env.signature_hash) + .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? + }; + + if access_key_addr != auth_signer { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: + "admin-signed key authorization must be signed by transaction key" + .to_string(), + } + .into()); + } + + if key_auth.signature.signature_type() + != keychain_sig.signature.signature_type() + { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: + "admin-signed key authorization signature type does not match transaction key signature type" + .to_string(), + } + .into()); + } + } + key_authorization_signer = Some(auth_signer); } @@ -3365,8 +3384,6 @@ mod tests { ); let t6_gas_params = crate::gas_params::tempo_gas_params(TempoHardfork::T6); - let t6_sload = - t6_gas_params.warm_storage_read_cost() + t6_gas_params.cold_storage_additional_cost(); let base_t6_key_auth = create_key_auth(0); let mut account_bound_t6_key_auth = create_key_auth(0); account_bound_t6_key_auth.authorization = account_bound_t6_key_auth @@ -3396,13 +3413,13 @@ mod tests { assert_eq!( account_bound_t6_gas - base_t6_gas, - t6_sload, - "T6 account-bound authorization charges one admin-signer cold read" + 0, + "T6 account-bound authorization does not add key authorization gas" ); assert_eq!( admin_t6_gas - base_t6_gas, - t6_sload + KEY_AUTH_EXTRA_EVENT_BUFFER, - "T6 account-bound admin authorization charges one admin-signer cold read and one extra event buffer" + KEY_AUTH_EXTRA_EVENT_BUFFER, + "T6 account-bound admin authorization charges one extra event buffer" ); assert_eq!( admin_t6_gas - account_bound_t6_gas, @@ -3416,11 +3433,11 @@ mod tests { ); assert_eq!( account_bound_t6_state_gas, base_t6_state_gas, - "T6 admin-signer read does not add state gas" + "T6 account binding does not add state gas" ); assert_eq!( admin_t6_state_gas, base_t6_state_gas, - "T6 admin-signer read does not add state gas" + "T6 admin authorization event buffer does not add state gas" ); assert_eq!( unbound_admin_t6_state_gas, base_t6_state_gas, @@ -5337,6 +5354,37 @@ mod tests { }); } + #[test] + fn test_t6_admin_key_authorization_rejects_different_transaction_admin_key() { + let (authorization_signer, authorization_admin_key) = generate_keypair(); + let (_, tx_admin_key) = generate_keypair(); + let user = Address::random(); + let child_key = Address::random(); + let signed = sign_key_auth( + &authorization_signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, child_key) + .with_account(user), + ); + let (mut evm, h) = make_evm( + user, + tx_admin_key, + Some(signed), + TempoHardfork::T6, + None, + false, + ); + + let result = h.validate_env(&mut evm); + assert!( + matches!( + &result, + Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) + if reason.contains("must be signed by transaction key") + ), + "admin-signed key authorization must use the transaction admin key; auth signer {authorization_admin_key}, tx signer {tx_admin_key}, got: {result:?}" + ); + } + #[test] fn test_t6_admin_access_key_non_admin_authorization_requires_account_binding() { let (admin_signer, admin_key) = generate_keypair(); diff --git a/tips/tip-1049.md b/tips/tip-1049.md index 0c2dd85e60..3e3381e02c 100644 --- a/tips/tip-1049.md +++ b/tips/tip-1049.md @@ -138,7 +138,7 @@ TIP-1020's existing stateless `verify(signer, digest, sig)` is unchanged. This T ### Admin Key Privilege Propagation -An admin access key MAY authorize new keys (admin or non-admin) by signing the `KeyAuthorization` itself, enabling delegation chains. This is intentional: it allows multi-device setups where any trusted device can onboard new devices without the root key being available at all. +An admin access key MAY authorize new keys (admin or non-admin) by signing the `KeyAuthorization` itself, enabling delegation chains. The same admin key MUST also sign the transaction carrying that `KeyAuthorization`. This is intentional: it allows multi-device setups where any trusted device can onboard new devices without the root key being available at all. Revocation of an admin key does NOT automatically revoke keys that it authorized. Each key is independently tracked; cascading revocation is out of scope for this TIP. Accounts that need cascading revocation should revoke downstream keys explicitly. @@ -153,6 +153,7 @@ rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?, is_ - If `is_admin` is omitted or `false`, the authorization creates a non-admin key. - If `is_admin` is `true`, the authorization creates an admin key. - If the authorization signer is not the target account's root key, `account` MUST be present and equal the target account being modified. +- If the authorization signer is an admin access key, the transaction MUST be signed by that same admin access key. A transaction signed by the root key or a different admin access key MUST NOT carry an admin-signed `KeyAuthorization`. - If `account` is present, it MUST equal the target account being modified. - If `is_admin` is `true`, `expiry`, `limits`, and `allowed_calls` MUST be omitted or encoded as empty optional values because admin keys carry no restrictions. - If `account` is present, `witness` MAY be present or omitted for the transaction-encoded authorization. ABI calls to `authorizeAdminKey` always provide a `bytes32 witness`, including `bytes32(0)`. @@ -161,8 +162,8 @@ In root/admin terms: - Root key authorizes non-admin key: `account` MAY be omitted. If present, it MUST equal the target account. - Root key authorizes admin key: `account` MAY be omitted. If present, it MUST equal the target account. -- Admin key authorizes non-admin key: `account` MUST be present and MUST equal the target account. -- Admin key authorizes admin key: `account` MUST be present and MUST equal the target account. +- Admin key authorizes non-admin key: `account` MUST be present, MUST equal the target account, and the transaction MUST be signed by the same admin key. +- Admin key authorizes admin key: `account` MUST be present, MUST equal the target account, and the transaction MUST be signed by the same admin key. - Non-admin access key authorizes any key: invalid. The target `account`, `is_admin`, and `witness` fields are part of the signed RLP payload. This binds admin-signed key authorizations to a target account and prevents replaying an unrestricted non-admin authorization as an admin authorization. From 775fd023371f9764fde3e386cb79ae4d80680f28 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 16:25:06 +0530 Subject: [PATCH 28/39] refactor(revm): streamline key authorization validation --- crates/revm/src/handler.rs | 151 +++++++++++++++---------------------- 1 file changed, 60 insertions(+), 91 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index c83e1f234a..00f3f5517b 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -40,7 +40,7 @@ use tempo_contracts::precompiles::{ use tempo_precompiles::{ ECRECOVER_GAS, account_keychain::{ - AccountKeychain, CallScope as PrecompileCallScope, KeyRestrictions, + AccountKeychain, AuthorizedKey, CallScope as PrecompileCallScope, KeyRestrictions, SelectorRule as PrecompileSelectorRule, TokenLimit, }, error::TempoPrecompileError, @@ -143,11 +143,10 @@ fn tempo_signature_verification_gas(signature: &TempoSignature) -> u64 { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] struct LoadedTxAccessKey { key_id: Address, - is_admin: bool, - signature_type: u8, + key: AuthorizedKey, } /// Counts the scope storage rows that pay the dynamic SSTORE-set path for the active spec. @@ -1144,6 +1143,7 @@ where // already exists. Same-tx auth+use is the exception: that key is registered only after fees // are collected, so fee-limit validation uses the inline authorization payload instead. let mut loaded_tx_access_key = None; + let mut same_tx_key_authorization_use = false; if let Some(tempo_tx_env) = tx.tempo_tx_env.as_ref() && let Some(keychain_sig) = tempo_tx_env.signature.as_keychain() { @@ -1171,26 +1171,39 @@ where let key_auth = tempo_tx_env.key_authorization.as_ref(); // Classify whether this keychain-signed tx is using the same access key that the - // inline authorization registers. Before T6, keychain-signed transactions with inline - // key authorizations must have this shape because access keys cannot authorize other - // keys; root-signed key authorizations are handled outside this keychain-signer path. - let same_tx_auth_use = key_auth.is_some_and(|key_auth| { - if cfg.spec.is_t6() { - access_key_addr == key_auth.key_id - } else { - true - } - }); + // inline authorization registers. + same_tx_key_authorization_use = + key_auth.is_some_and(|key_auth| access_key_addr == key_auth.key_id); - let should_validate_existing_tx_key = - key_auth.is_none() || (cfg.spec.is_t6() && !same_tx_auth_use); + if same_tx_key_authorization_use { + let key_auth = key_auth.expect("same-tx auth/use requires inline authorization"); + + // Same-tx auth+use path: the access key does not exist in storage yet, so the fee + // check must use the inline limits directly. `collectFeePreTx` cannot enforce this + // because `transaction_key` is intentionally not set until after authorization. + if !gas_balance_spending.is_zero() + && fee_payer == tx.caller + && let Some(limits) = key_auth.limits.as_ref() + { + let remaining = limits + .iter() + .rev() + .find(|limit| limit.token == fee_token) + .map(|limit| limit.limit) + .unwrap_or_default(); - if should_validate_existing_tx_key { + if gas_balance_spending > remaining { + return Err( + FeePaymentError::Other("SpendingLimitExceeded".to_string()).into() + ); + } + } + } else { // Existing-key path: // - ordinary keychain txs must validate the acting access key before fees are paid // - T6 delegated key authorizations also validate the acting key here, then reuse // the loaded admin/signature-type facts below when the sidecar signer is the same key - let (stored_key_expiry, loaded_key) = StorageCtx::enter_precompile( + let loaded_key = StorageCtx::enter_precompile( journal, block, cfg, @@ -1217,7 +1230,7 @@ where // T6 adds admin delegation: a keychain signer may authorize a different // child key only if the acting transaction key is itself an active admin key. - if key_auth.is_some() && !same_tx_auth_use && !key.is_admin { + if key_auth.is_some() && !key.is_admin { return Err( TempoInvalidTransaction::AccessKeyCannotAuthorizeOtherKeys.into() ); @@ -1230,44 +1243,16 @@ where .set_transaction_key(access_key_addr) .map_err(|e| EVMError::Custom(e.to_string()))?; - Ok::<_, EVMError<_, TempoInvalidTransaction>>(( - key.expiry, - LoadedTxAccessKey { - key_id: access_key_addr, - is_admin: key.is_admin, - signature_type: key.signature_type as u8, - }, - )) + Ok::<_, EVMError<_, TempoInvalidTransaction>>(LoadedTxAccessKey { + key_id: access_key_addr, + key, + }) }, )?; - evm.key_expiry = Some(stored_key_expiry); + evm.key_expiry = Some(loaded_key.key.expiry); loaded_tx_access_key = Some(loaded_key); } - - if let Some(key_auth) = key_auth { - // Same-tx auth+use path: the access key does not exist in storage yet, so the fee - // check must use the inline limits directly. `collectFeePreTx` cannot enforce this - // because `transaction_key` is intentionally not set until after authorization. - if !gas_balance_spending.is_zero() - && fee_payer == tx.caller - && same_tx_auth_use - && let Some(limits) = key_auth.limits.as_ref() - { - let remaining = limits - .iter() - .rev() - .find(|limit| limit.token == fee_token) - .map(|limit| limit.limit) - .unwrap_or_default(); - - if gas_balance_spending > remaining { - return Err( - FeePaymentError::Other("SpendingLimitExceeded".to_string()).into() - ); - } - } - } } // T6 stateless signer/account checks run in `validate_env`. This state-aware phase only @@ -1284,9 +1269,9 @@ where let signer_is_admin = match loaded_tx_access_key { Some(loaded_key) if loaded_key.key_id == auth_signer - && loaded_key.signature_type == key_auth_sig_type => + && (loaded_key.key.signature_type as u8) == key_auth_sig_type => { - loaded_key.is_admin + loaded_key.key.is_admin } Some(_) | None => { return Err(TempoInvalidTransaction::KeychainValidationFailed { @@ -1521,45 +1506,29 @@ where // If this is a same tx auth+use, set the transient key_id to the newly authorized // key and decrement the fee from its spending limit. Admin delegation must keep the // actual signer as the transaction key. - if let Some(keychain_sig) = tempo_tx_env.signature.as_keychain() { - let same_tx_auth_use = if cfg.spec.is_t6() { - let access_key_addr = - if let Some(override_key_id) = tempo_tx_env.override_key_id { - override_key_id - } else { - keychain_sig - .key_id(&tempo_tx_env.signature_hash) - .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? - }; + if same_tx_key_authorization_use { + StorageCtx::enter_precompile( + journal, + block, + cfg, + tx, + |mut keychain: AccountKeychain| { + keychain + .set_transaction_key(key_auth.key_id) + .map_err(|e| EVMError::Custom(e.to_string()))?; - access_key_addr == key_auth.key_id - } else { - true - }; - if same_tx_auth_use { - StorageCtx::enter_precompile( - journal, - block, - cfg, - tx, - |mut keychain: AccountKeychain| { - keychain - .set_transaction_key(key_auth.key_id) - .map_err(|e| EVMError::Custom(e.to_string()))?; - - if evm.collected_fee.is_zero() { - return Ok(()); - } + if evm.collected_fee.is_zero() { + return Ok(()); + } - keychain - .authorize_transfer(fee_payer, fee_token, evm.collected_fee) - .map_err(|err| match err { - TempoPrecompileError::Fatal(err) => EVMError::Custom(err), - err => FeePaymentError::Other(err.to_string()).into(), - }) - }, - )?; - } + keychain + .authorize_transfer(fee_payer, fee_token, evm.collected_fee) + .map_err(|err| match err { + TempoPrecompileError::Fatal(err) => EVMError::Custom(err), + err => FeePaymentError::Other(err.to_string()).into(), + }) + }, + )?; } } From 74ce9f8d76c3de0b0d337681832da9870bae305b Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 16:43:12 +0530 Subject: [PATCH 29/39] refactor(account-keychain): share key authorization validation --- .../precompiles/src/account_keychain/mod.rs | 98 ++++++++----------- 1 file changed, 39 insertions(+), 59 deletions(-) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index df0a8f1ff4..f1aeeaac23 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -244,6 +244,18 @@ impl AccountKeychain { signature_type: SignatureType, config: KeyRestrictions, witness: Option, + ) -> Result<()> { + self.authorize_key_internal(msg_sender, key_id, signature_type, config, witness, false) + } + + fn authorize_key_internal( + &mut self, + msg_sender: Address, + key_id: Address, + signature_type: SignatureType, + config: KeyRestrictions, + witness: Option, + is_admin: bool, ) -> Result<()> { let config = &config; self.ensure_admin_caller(msg_sender)?; @@ -255,7 +267,7 @@ impl AccountKeychain { } // T6+ keeps the account root key implicit. Do not create a stored access-key row // using the root key id. - if self.storage.spec().is_t6() && key_id == msg_sender { + if (is_admin || self.storage.spec().is_t6()) && key_id == msg_sender { return Err(AccountKeychainError::invalid_key_id().into()); } @@ -323,23 +335,25 @@ impl AccountKeychain { expiry: config.expiry, enforce_limits: config.enforceLimits, is_revoked: false, - is_admin: false, + is_admin, }; self.keys[msg_sender][key_id].write(new_key)?; - let limits = config - .enforceLimits - .then_some(config.limits.iter()) - .into_iter() - .flatten(); + if !is_admin { + let limits = config + .enforceLimits + .then_some(config.limits.iter()) + .into_iter() + .flatten(); - self.apply_key_authorization_restrictions( - msg_sender, - key_id, - limits, - allowed_call_configs, - )?; + self.apply_key_authorization_restrictions( + msg_sender, + key_id, + limits, + allowed_call_configs, + )?; + } if let Some(witness) = witness { self.emit_event(AccountKeychainEvent::KeyAuthorizationWitness( @@ -370,54 +384,20 @@ impl AccountKeychain { signature_type: SignatureType, witness: Option, ) -> Result<()> { - self.ensure_admin_caller(msg_sender)?; - - if key_id == Address::ZERO { - return Err(AccountKeychainError::zero_public_key().into()); - } - // Admin keys are explicit access-key rows; the root key remains implicit. - if key_id == msg_sender { - return Err(AccountKeychainError::invalid_key_id().into()); - } - - let existing_key = self.keys[msg_sender][key_id].read()?; - if existing_key.expiry > 0 { - return Err(AccountKeychainError::key_already_exists().into()); - } - if existing_key.is_revoked { - return Err(AccountKeychainError::key_already_revoked().into()); - } - - if let Some(witness) = witness { - self.ensure_key_authorization_witness_not_burned(msg_sender, witness)?; - } - - let signature_type = StoredSignatureType::try_from(signature_type)?; - - self.keys[msg_sender][key_id].write(AuthorizedKey { - signature_type, - expiry: u64::MAX, - enforce_limits: false, - is_revoked: false, - is_admin: true, - })?; - - if let Some(witness) = witness { - self.emit_event(AccountKeychainEvent::KeyAuthorizationWitness( - IAccountKeychain::KeyAuthorizationWitness { - account: msg_sender, - witness, - }, - ))?; - } - - self.emit_event(AccountKeychainEvent::key_authorized( + self.authorize_key_internal( msg_sender, key_id, - signature_type as u8, - // Admin keys never expire; they must be revoked explicitly. - u64::MAX, - ))?; + signature_type, + KeyRestrictions { + expiry: u64::MAX, + enforceLimits: false, + limits: Vec::new(), + allowAnyCalls: true, + allowedCalls: Vec::new(), + }, + witness, + true, + )?; self.emit_event(AccountKeychainEvent::AdminKeyAuthorized( IAccountKeychain::AdminKeyAuthorized { account: msg_sender, From b4a04395fde527c4c2e7c488c4e3b3ff5440ed32 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 16:56:58 +0530 Subject: [PATCH 30/39] fix(account-keychain): scope self-key restriction to admin keys --- .../precompiles/src/account_keychain/mod.rs | 21 +++---------------- crates/revm/src/handler.rs | 7 ------- tips/tip-1049.md | 7 ++----- 3 files changed, 5 insertions(+), 30 deletions(-) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index f1aeeaac23..9fa7c0c8ae 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -235,7 +235,6 @@ impl AccountKeychain { /// - `ExpiryInPast` — expiry must be in the future (enforced since T0) /// - `KeyAlreadyExists` — a key with this ID is already registered /// - `KeyAlreadyRevoked` — revoked keys cannot be re-authorized - /// - `InvalidKeyId` — on T6+, `keyId` cannot be the account root key /// - `InvalidSignatureType` — must be Secp256k1, P256, or WebAuthn pub fn authorize_key( &mut self, @@ -265,9 +264,8 @@ impl AccountKeychain { if key_id == Address::ZERO { return Err(AccountKeychainError::zero_public_key().into()); } - // T6+ keeps the account root key implicit. Do not create a stored access-key row - // using the root key id. - if (is_admin || self.storage.spec().is_t6()) && key_id == msg_sender { + // Admin keys are explicit access-key rows; the root key remains implicit. + if is_admin && key_id == msg_sender { return Err(AccountKeychainError::invalid_key_id().into()); } @@ -1799,7 +1797,7 @@ mod tests { } #[test] - fn test_t6_admin_key_restrictions_and_root_authorization_rejected() -> eyre::Result<()> { + fn test_t6_admin_key_restrictions_and_root_admin_authorization_rejected() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); let account = Address::random(); let admin_key = Address::random(); @@ -1824,19 +1822,6 @@ mod tests { .expect_err("admin keys cannot receive spending limits"), ); - assert_invalid_key_id( - authorize_key( - &mut keychain, - account, - authorizeKeyCall { - keyId: account, - signatureType: SignatureType::Secp256k1, - config: unrestricted_restrictions(), - }, - ) - .expect_err("root key cannot be registered as an access key"), - ); - assert_invalid_key_id( keychain .authorize_admin_key(account, account, SignatureType::Secp256k1, None) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 00f3f5517b..ee61093f00 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1768,13 +1768,6 @@ where .into()); } - if cfg.spec.is_t6() && key_auth.key_id == tx.caller { - return Err(TempoInvalidTransaction::KeychainValidationFailed { - reason: "key authorization key_id cannot equal account".to_string(), - } - .into()); - } - if !cfg.spec.is_t6() { let auth_signer = key_auth.recover_signer().map_err(|_| { TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed diff --git a/tips/tip-1049.md b/tips/tip-1049.md index 3e3381e02c..772c7e4c4d 100644 --- a/tips/tip-1049.md +++ b/tips/tip-1049.md @@ -124,13 +124,12 @@ Contracts that need to inspect admin status outside a signature-verification flo `ensure_admin_caller` is widened to additionally accept admin access keys. Every mutator gated by `ensure_admin_caller` (including the new `authorizeAdminKey`) MAY now be called by either the root key or an admin access key, and MUST revert with `AccountKeychainError::UnauthorizedCaller` for any other caller. -`authorizeKey` and `authorizeAdminKey` MUST reject `keyId == account` with `AccountKeychainError::InvalidKeyId`. New authorizations must not create a stored access-key row that collides with the account's implicit root key. +`authorizeAdminKey` MUST reject `keyId == account` with `AccountKeychainError::InvalidKeyId`. -For existing stored key rows, mutators operate on the row selected by `keyId`: +For stored access-key rows, mutators operate on the row selected by `keyId`: - When the selected row is an admin key, `updateSpendingLimit`, `setAllowedCalls`, and `removeAllowedCalls` MUST reject with `AccountKeychainError::InvalidKeyId`. Admin keys have no `KeyRestrictions`. - `revokeKey` MAY be called and revokes any authorized key, including an admin key targeting itself. -- If `keyId == account`, these mutators do not special-case the implicit root key: they return `AccountKeychainError::KeyNotFound` when no stored row exists, and otherwise operate only on the stored `keys[account][account]` row. ### TIP-1020 Compatibility @@ -175,5 +174,3 @@ The target `account`, `is_admin`, and `witness` fields are part of the signed RL 2. **TIP-1020 stateless methods unchanged.** TIP-1020's `recover` and `verify` MUST behave identically to pre-TIP-1049 semantics. 3. **Backward compatibility.** Existing access keys (authorized before this TIP activates) default to `is_admin = false`. Their behavior is unchanged. - -Pre-existing `keys[account][account]` entries MUST NOT affect root-key status. New authorizations MUST reject `keyId == account`, but mutators that target existing access-key rows MAY operate on pre-existing `keys[account][account]` rows. Such mutations affect only the stored row and MUST NOT change implicit root-key status. From 066119722ed771a426e0e7802fff42b3495397c4 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 17:09:09 +0530 Subject: [PATCH 31/39] refactor(account-keychain): simplify admin key status errors --- crates/precompiles/src/account_keychain/mod.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 9fa7c0c8ae..83fc391fa7 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -1110,12 +1110,8 @@ impl AccountKeychain { let current_timestamp = self.storage.timestamp().saturating_to::(); let key = match self.load_active_key(account, key_id, current_timestamp) { Ok(key) => key, - Err(crate::error::TempoPrecompileError::AccountKeychainError( - AccountKeychainError::KeyAlreadyRevoked(_) - | AccountKeychainError::KeyNotFound(_) - | AccountKeychainError::KeyExpired(_), - )) => return Ok(false), - Err(err) => return Err(err), + Err(err) if err.is_system_error() => return Err(err), + Err(_) => return Ok(false), }; Ok(key.is_admin) From 11f243d4bfae94d80f3ce812d85d80e62a3bc9eb Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 17:19:23 +0530 Subject: [PATCH 32/39] refactor(revm): cache key authorization signer --- crates/revm/src/handler.rs | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index ee61093f00..d384060495 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -58,8 +58,8 @@ use tempo_precompiles::{ use tempo_primitives::{ TempoAddressExt, transaction::{ - PrimitiveSignature, SignatureType, TEMPO_EXPIRING_NONCE_KEY, TempoSignature, - calc_gas_balance_spending, validate_calls, + PrimitiveSignature, SignatureType, SignedKeyAuthorization, TEMPO_EXPIRING_NONCE_KEY, + TempoSignature, calc_gas_balance_spending, validate_calls, }, }; @@ -149,6 +149,22 @@ struct LoadedTxAccessKey { key: AuthorizedKey, } +fn recover_key_authorization_signer( + cache: &OnceLock
, + key_auth: &SignedKeyAuthorization, +) -> Result { + if let Some(signer) = cache.get() { + return Ok(*signer); + } + + let signer = key_auth + .recover_signer() + .map_err(|_| TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed)?; + let _ = cache.set(signer); + + Ok(signer) +} + /// Counts the scope storage rows that pay the dynamic SSTORE-set path for the active spec. /// /// T3 keeps the broader all-persisted-rows accounting from current main. T4 narrows this to rows @@ -1652,7 +1668,7 @@ where // AA-specific validations let cfg = &evm.inner.cfg; let tx = &evm.inner.tx; - let mut key_authorization_signer = None; + let key_authorization_signer = OnceLock::new(); if let Some(aa_env) = tx.tempo_tx_env.as_ref() { // Validate AA transaction structure (calls list, CREATE rules) @@ -1769,9 +1785,8 @@ where } if !cfg.spec.is_t6() { - let auth_signer = key_auth.recover_signer().map_err(|_| { - TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed - })?; + let auth_signer = + recover_key_authorization_signer(&key_authorization_signer, key_auth)?; if auth_signer != tx.caller { return Err(TempoInvalidTransaction::KeyAuthorizationNotSignedByRoot { @@ -1815,9 +1830,8 @@ where } if cfg.spec.is_t6() { - let auth_signer = key_auth.recover_signer().map_err(|_| { - TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed - })?; + let auth_signer = + recover_key_authorization_signer(&key_authorization_signer, key_auth)?; if auth_signer != tx.caller && key_auth.account.is_none() { return Err(TempoInvalidTransaction::KeychainValidationFailed { reason: "admin-signed key authorization account mismatch".to_string(), @@ -1864,8 +1878,6 @@ where .into()); } } - - key_authorization_signer = Some(auth_signer); } // Cache inline key authorization expiry. @@ -1894,7 +1906,7 @@ where validate_time_window(valid_after, aa_env.valid_before, block_timestamp)?; } - evm.key_authorization_signer = key_authorization_signer; + evm.key_authorization_signer = key_authorization_signer.get().copied(); Ok(()) } From feab627121fcdff5267ce7a41cde486ce777847a Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 18:58:48 +0530 Subject: [PATCH 33/39] refactor(primitives): cache key authorization signer on payload --- crates/primitives/src/transaction/envelope.rs | 12 +- .../src/transaction/key_authorization.rs | 80 ++++-- .../src/transaction/tempo_transaction.rs | 14 +- crates/revm/src/evm.rs | 6 - crates/revm/src/handler.rs | 234 +++++++----------- crates/transaction-pool/src/paused.rs | 14 +- crates/transaction-pool/src/tempo_pool.rs | 12 +- 7 files changed, 179 insertions(+), 193 deletions(-) diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 88375fffad..f02ece15f8 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -545,7 +545,7 @@ mod tests { use super::*; use crate::transaction::{ Call, TempoSignedAuthorization, TempoTransaction, TokenLimit, - key_authorization::{KeyAuthorization, SignedKeyAuthorization}, + key_authorization::KeyAuthorization, tt_signature::{KeychainSignature, PrimitiveSignature, TempoSignature}, }; use alloy_consensus::{TxEip1559, TxEip2930, TxEip7702}; @@ -1050,8 +1050,8 @@ mod tests { value: U256::ZERO, input: Bytes::from(calldata), }], - key_authorization: Some(SignedKeyAuthorization { - authorization: KeyAuthorization { + key_authorization: Some( + KeyAuthorization { chain_id: 1, key_type: crate::SignatureType::Secp256k1, key_id: Address::random(), @@ -1061,9 +1061,9 @@ mod tests { witness: None, is_admin: false, account: None, - }, - signature: PrimitiveSignature::Secp256k1(Signature::test_signature()), - }), + } + .into_signed(PrimitiveSignature::Secp256k1(Signature::test_signature())), + ), ..Default::default() }; TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())) diff --git a/crates/primitives/src/transaction/key_authorization.rs b/crates/primitives/src/transaction/key_authorization.rs index 1931ab6681..80c182a195 100644 --- a/crates/primitives/src/transaction/key_authorization.rs +++ b/crates/primitives/src/transaction/key_authorization.rs @@ -4,7 +4,15 @@ use alloc::vec::Vec; use alloy_consensus::crypto::RecoveryError; use alloy_primitives::{Address, B256, U256, keccak256}; use alloy_rlp::Encodable; -use core::num::NonZeroU64; +use core::{ + hash::{Hash, Hasher}, + num::NonZeroU64, +}; + +#[cfg(not(feature = "std"))] +use once_cell::race::OnceBox as OnceLock; +#[cfg(feature = "std")] +use std::sync::OnceLock; /// Token spending limit for access keys /// @@ -349,10 +357,7 @@ impl KeyAuthorization { /// Convert the key authorization into a [`SignedKeyAuthorization`] with a signature. pub fn into_signed(self, signature: PrimitiveSignature) -> SignedKeyAuthorization { - SignedKeyAuthorization { - authorization: self, - signature, - } + SignedKeyAuthorization::new(self, signature) } /// Validates that this key authorization's `chain_id` is compatible with `expected_chain_id`. @@ -404,16 +409,7 @@ pub struct KeyAuthorizationChainIdError { } /// Signed key authorization that can be attached to a transaction. -#[derive( - Clone, - Debug, - PartialEq, - Eq, - Hash, - alloy_rlp::RlpEncodable, - alloy_rlp::RlpDecodable, - derive_more::Deref, -)] +#[derive(Clone, Debug, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable, derive_more::Deref)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] #[cfg_attr(any(test, feature = "arbitrary"), derive(arbitrary::Arbitrary))] @@ -426,13 +422,48 @@ pub struct SignedKeyAuthorization { /// Signature authorizing this key (signed by root key) pub signature: PrimitiveSignature, + + /// Cached signer recovered from `signature`. + /// + /// Excluded from encoding, equality, hashing, and arbitrary generation. + #[cfg_attr(feature = "serde", serde(skip))] + #[cfg_attr(any(test, feature = "arbitrary"), arbitrary(default))] + #[rlp(skip, default)] + signer: OnceLock
, } impl SignedKeyAuthorization { + /// Create a signed key authorization with an empty signer cache. + pub fn new(authorization: KeyAuthorization, signature: PrimitiveSignature) -> Self { + Self { + authorization, + signature, + signer: OnceLock::new(), + } + } + /// Recover the signer of the [`KeyAuthorization`]. pub fn recover_signer(&self) -> Result { - self.signature - .recover_signer(&self.authorization.signature_hash()) + if let Some(signer) = self.signer.get() { + return Ok(*signer); + } + + let signer = self + .signature + .recover_signer(&self.authorization.signature_hash())?; + self.cache_signer(signer); + + Ok(signer) + } + + #[cfg(feature = "std")] + fn cache_signer(&self, signer: Address) { + let _ = self.signer.set(signer); + } + + #[cfg(not(feature = "std"))] + fn cache_signer(&self, signer: Address) { + let _ = self.signer.set(alloc::boxed::Box::new(signer)); } /// Calculates a heuristic for the in-memory size of the signed key authorization @@ -441,6 +472,21 @@ impl SignedKeyAuthorization { } } +impl PartialEq for SignedKeyAuthorization { + fn eq(&self, other: &Self) -> bool { + self.authorization == other.authorization && self.signature == other.signature + } +} + +impl Eq for SignedKeyAuthorization {} + +impl Hash for SignedKeyAuthorization { + fn hash(&self, state: &mut H) { + self.authorization.hash(state); + self.signature.hash(state); + } +} + #[cfg(any(test, feature = "arbitrary"))] impl<'a> arbitrary::Arbitrary<'a> for KeyAuthorization { fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { diff --git a/crates/primitives/src/transaction/tempo_transaction.rs b/crates/primitives/src/transaction/tempo_transaction.rs index 805840ba58..1c0f7a16d1 100644 --- a/crates/primitives/src/transaction/tempo_transaction.rs +++ b/crates/primitives/src/transaction/tempo_transaction.rs @@ -2078,7 +2078,7 @@ mod tests { mod compact_tests { use super::*; use crate::transaction::{ - KeyAuthorization, SignedKeyAuthorization, TempoSignedAuthorization, TokenLimit, + KeyAuthorization, TempoSignedAuthorization, TokenLimit, tt_signature::{P256SignatureWithPreHash, PrimitiveSignature, TempoSignature}, }; use alloy_eips::{eip2930::AccessListItem, eip7702::Authorization}; @@ -2147,8 +2147,8 @@ mod compact_tests { fee_payer_signature: Some(Signature::new(U256::from(1u64), U256::from(2u64), false)), valid_before: Some(NonZeroU64::new(1_700_001_000).unwrap()), valid_after: Some(NonZeroU64::new(1_700_000_000).unwrap()), - key_authorization: Some(SignedKeyAuthorization { - authorization: KeyAuthorization { + key_authorization: Some( + KeyAuthorization { chain_id: 42170, key_type: SignatureType::P256, key_id: address!("0x000000000000000000000000000000000000dead"), @@ -2162,8 +2162,8 @@ mod compact_tests { witness: None, is_admin: false, account: None, - }, - signature: PrimitiveSignature::P256(P256SignatureWithPreHash { + } + .into_signed(PrimitiveSignature::P256(P256SignatureWithPreHash { r: b256!("0x1111111111111111111111111111111111111111111111111111111111111111"), s: b256!("0x2222222222222222222222222222222222222222222222222222222222222222"), pub_key_x: b256!( @@ -2173,8 +2173,8 @@ mod compact_tests { "0x4444444444444444444444444444444444444444444444444444444444444444" ), pre_hash: false, - }), - }), + })), + ), tempo_authorization_list: vec![TempoSignedAuthorization::new_unchecked( Authorization { chain_id: U256::from(42170u64), diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 9a16550d5f..de201ec1e4 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -41,10 +41,6 @@ pub struct TempoEvm { /// The expiry timestamp of the access key used by the current transaction. /// Populated during validation for keychain-signed transactions or transactions carrying a KeyAuthorization. pub(crate) key_expiry: Option, - /// Recovered signer for the current transaction's inline key authorization. - /// - /// Populated by stateless validation and reused by state validation for admin-key checks. - pub(crate) key_authorization_signer: Option
, /// When true, skips the `valid_after` time-window check during validation. /// /// The transaction pool sets this because it intentionally accepts transactions @@ -89,7 +85,6 @@ impl TempoEvm { validator_fee: U256::ZERO, fee_token: None, key_expiry: None, - key_authorization_signer: None, skip_valid_after_check: false, skip_liquidity_check: false, } @@ -132,7 +127,6 @@ impl TempoEvm { pub fn clear(&mut self) { self.fee_token = None; self.key_expiry = None; - self.key_authorization_signer = None; } } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 245f202d15..a0342a7b9c 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -58,8 +58,8 @@ use tempo_precompiles::{ use tempo_primitives::{ TempoAddressExt, transaction::{ - PrimitiveSignature, SignatureType, SignedKeyAuthorization, TEMPO_EXPIRING_NONCE_KEY, - TempoSignature, calc_gas_balance_spending, validate_calls, + PrimitiveSignature, SignatureType, TEMPO_EXPIRING_NONCE_KEY, TempoSignature, + calc_gas_balance_spending, validate_calls, }, }; @@ -149,22 +149,6 @@ struct LoadedTxAccessKey { key: AuthorizedKey, } -fn recover_key_authorization_signer( - cache: &OnceLock
, - key_auth: &SignedKeyAuthorization, -) -> Result { - if let Some(signer) = cache.get() { - return Ok(*signer); - } - - let signer = key_auth - .recover_signer() - .map_err(|_| TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed)?; - let _ = cache.set(signer); - - Ok(signer) -} - /// Counts the scope storage rows that pay the dynamic SSTORE-set path for the active spec. /// /// T3 keeps the broader all-persisted-rows accounting from current main. T4 narrows this to rows @@ -921,7 +905,6 @@ where init_gas: &mut InitialAndFloorGas, ) -> Result<(), Self::Error> { self.seed_precompile_tx_context(evm)?; - let key_authorization_signer = evm.key_authorization_signer; let block = &evm.inner.ctx.block; let tx = &evm.inner.ctx.tx; @@ -1277,8 +1260,9 @@ where && let Some(tempo_tx_env) = tx.tempo_tx_env.as_ref() && let Some(key_auth) = tempo_tx_env.key_authorization.as_ref() { - let auth_signer = key_authorization_signer - .expect("T6 key authorization signer is set during validate_env"); + let auth_signer = key_auth + .recover_signer() + .map_err(|_| TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed)?; if auth_signer != tx.caller { let key_auth_sig_type: u8 = key_auth.signature.signature_type().into(); @@ -1643,7 +1627,6 @@ where fn validate_env(&self, evm: &mut Self::Evm) -> Result<(), Self::Error> { // Reset per-tx validator fee. evm.validator_fee = U256::ZERO; - evm.key_authorization_signer = None; // Validate the fee payer signature let fee_payer = evm.ctx.tx.fee_payer()?; @@ -1668,7 +1651,6 @@ where // AA-specific validations let cfg = &evm.inner.cfg; let tx = &evm.inner.tx; - let key_authorization_signer = OnceLock::new(); if let Some(aa_env) = tx.tempo_tx_env.as_ref() { // Validate AA transaction structure (calls list, CREATE rules) @@ -1785,8 +1767,9 @@ where } if !cfg.spec.is_t6() { - let auth_signer = - recover_key_authorization_signer(&key_authorization_signer, key_auth)?; + let auth_signer = key_auth.recover_signer().map_err(|_| { + TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed + })?; if auth_signer != tx.caller { return Err(TempoInvalidTransaction::KeyAuthorizationNotSignedByRoot { @@ -1830,8 +1813,9 @@ where } if cfg.spec.is_t6() { - let auth_signer = - recover_key_authorization_signer(&key_authorization_signer, key_auth)?; + let auth_signer = key_auth.recover_signer().map_err(|_| { + TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed + })?; if auth_signer != tx.caller && key_auth.account.is_none() { return Err(TempoInvalidTransaction::KeychainValidationFailed { reason: "admin-signed key authorization account mismatch".to_string(), @@ -1906,8 +1890,6 @@ where validate_time_window(valid_after, aa_env.valid_before, block_timestamp)?; } - evm.key_authorization_signer = key_authorization_signer.get().copied(); - Ok(()) } @@ -3204,12 +3186,9 @@ mod tests { .collect(), ); } - SignedKeyAuthorization { - authorization: auth, - signature: PrimitiveSignature::Secp256k1( - alloy_primitives::Signature::test_signature(), - ), - } + auth.into_signed(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )) }; // Test 0 limits: base (27k) + ecrecover (3k) = 30,000 @@ -3418,21 +3397,17 @@ mod tests { "T6 unbound admin authorization does not add state gas" ); - let scoped = SignedKeyAuthorization { - authorization: KeyAuthorization::unrestricted( - 1, - SignatureType::Secp256k1, - Address::random(), - ) + let scoped = KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::random()) .with_allowed_calls(vec![tempo_primitives::transaction::CallScope { target: Address::random(), selector_rules: vec![tempo_primitives::transaction::SelectorRule { selector: [0xa9, 0x05, 0x9c, 0xbb], recipients: vec![Address::random(), Address::random()], }], - }]), - signature: PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()), - }; + }]) + .into_signed(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )); let (gas, state_gas) = calculate_key_authorization_gas(&scoped, &t3_gas_params, TempoHardfork::T3); @@ -3456,33 +3431,30 @@ mod tests { ECRECOVER_GAS + t4_sload + t4_sstore * num_sstores + BUFFER + 29_000 + expected_state; assert_eq!(gas, expected, "T4 scope writes should be fully charged"); assert_eq!(state_gas, expected_state, "T4 scope state gas"); - let multi_scope = SignedKeyAuthorization { - authorization: KeyAuthorization::unrestricted( - 1, - SignatureType::Secp256k1, - Address::random(), - ) - .with_allowed_calls(vec![ - tempo_primitives::transaction::CallScope { - target: Address::random(), - selector_rules: vec![ - tempo_primitives::transaction::SelectorRule { - selector: [0xa9, 0x05, 0x9c, 0xbb], - recipients: vec![], - }, - tempo_primitives::transaction::SelectorRule { - selector: [0x09, 0x5e, 0xa7, 0xb3], - recipients: vec![], - }, - ], - }, - tempo_primitives::transaction::CallScope { - target: Address::random(), - selector_rules: vec![], - }, - ]), - signature: PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()), - }; + let multi_scope = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::random()) + .with_allowed_calls(vec![ + tempo_primitives::transaction::CallScope { + target: Address::random(), + selector_rules: vec![ + tempo_primitives::transaction::SelectorRule { + selector: [0xa9, 0x05, 0x9c, 0xbb], + recipients: vec![], + }, + tempo_primitives::transaction::SelectorRule { + selector: [0x09, 0x5e, 0xa7, 0xb3], + recipients: vec![], + }, + ], + }, + tempo_primitives::transaction::CallScope { + target: Address::random(), + selector_rules: vec![], + }, + ]) + .into_signed(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )); let (gas, state_gas) = calculate_key_authorization_gas(&multi_scope, &t3_gas_params, TempoHardfork::T3); @@ -3506,18 +3478,13 @@ mod tests { #[test] fn test_t4_key_authorization_matches_tip1016_sstore_regular_cost() { - use tempo_primitives::transaction::{ - KeyAuthorization, SignatureType, SignedKeyAuthorization, - }; + use tempo_primitives::transaction::{KeyAuthorization, SignatureType}; - let key_auth = SignedKeyAuthorization { - authorization: KeyAuthorization::unrestricted( - 1, - SignatureType::Secp256k1, - Address::random(), - ), - signature: PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()), - }; + let key_auth = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::random()) + .into_signed(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )); // TIP-1016 is opt-in via amsterdam_eip8037; manually enable for this test. let gas_params = @@ -3537,41 +3504,35 @@ mod tests { #[test] fn test_translate_allowed_calls_for_precompile_preserves_empty_nested_allow_all_lists() { use tempo_primitives::transaction::{ - CallScope, KeyAuthorization, SelectorRule, SignatureType, SignedKeyAuthorization, + CallScope, KeyAuthorization, SelectorRule, SignatureType, }; - let empty_selector_rules = SignedKeyAuthorization { - authorization: KeyAuthorization::unrestricted( - 1, - SignatureType::Secp256k1, - Address::random(), - ) - .with_allowed_calls(vec![CallScope { - target: Address::random(), - selector_rules: vec![], - }]), - signature: PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()), - }; + let empty_selector_rules = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::random()) + .with_allowed_calls(vec![CallScope { + target: Address::random(), + selector_rules: vec![], + }]) + .into_signed(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )); let translated = translate_allowed_calls_for_precompile(&empty_selector_rules); assert_eq!(translated.len(), 1); assert!(translated[0].selectorRules.is_empty()); - let empty_recipients = SignedKeyAuthorization { - authorization: KeyAuthorization::unrestricted( - 1, - SignatureType::Secp256k1, - Address::random(), - ) - .with_allowed_calls(vec![CallScope { - target: Address::random(), - selector_rules: vec![SelectorRule { - selector: [0xa9, 0x05, 0x9c, 0xbb], - recipients: vec![], - }], - }]), - signature: PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()), - }; + let empty_recipients = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::random()) + .with_allowed_calls(vec![CallScope { + target: Address::random(), + selector_rules: vec![SelectorRule { + selector: [0xa9, 0x05, 0x9c, 0xbb], + recipients: vec![], + }], + }]) + .into_signed(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )); let translated = translate_allowed_calls_for_precompile(&empty_recipients); assert_eq!(translated.len(), 1); @@ -3598,26 +3559,23 @@ mod tests { }; // Create key authorization with 2 limits - let key_auth: SignedKeyAuthorization = SignedKeyAuthorization { - authorization: KeyAuthorization::unrestricted( - 1, - SignatureType::Secp256k1, - Address::random(), - ) - .with_limits(vec![ - TokenLimit { - token: Address::random(), - limit: U256::from(1000), - period: 0, - }, - TokenLimit { - token: Address::random(), - limit: U256::from(2000), - period: 0, - }, - ]), - signature: PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()), - }; + let key_auth: SignedKeyAuthorization = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::random()) + .with_limits(vec![ + TokenLimit { + token: Address::random(), + limit: U256::from(1000), + period: 0, + }, + TokenLimit { + token: Address::random(), + limit: U256::from(2000), + period: 0, + }, + ]) + .into_signed(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )); let aa_env_with_key_auth = TempoBatchCallEnv { signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( @@ -4593,10 +4551,9 @@ mod tests { period: 0, }).collect()); } - SignedKeyAuthorization { - authorization: auth, - signature: PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()), - } + auth.into_signed(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )) }; // Test both pre-T1B and T1B branches @@ -4626,9 +4583,7 @@ mod tests { num_limits in 0usize..5, ) { use tempo_primitives::transaction::{ - SignatureType, SignedKeyAuthorization, - key_authorization::KeyAuthorization, - TokenLimit as PrimTokenLimit, + SignatureType, TokenLimit as PrimTokenLimit, key_authorization::KeyAuthorization, }; let signature = match sig_type { @@ -4651,10 +4606,7 @@ mod tests { period: 0, }).collect()); } - let key_auth = SignedKeyAuthorization { - authorization: auth, - signature, - }; + let key_auth = auth.into_signed(signature); // Pre-T1B: minimum is KEY_AUTH_BASE_GAS + ECRECOVER_GAS let (gas, _) = calculate_key_authorization_gas(&key_auth, &GasParams::default(), tempo_chainspec::hardfork::TempoHardfork::default()); diff --git a/crates/transaction-pool/src/paused.rs b/crates/transaction-pool/src/paused.rs index f81740c709..36a5ec69ed 100644 --- a/crates/transaction-pool/src/paused.rs +++ b/crates/transaction-pool/src/paused.rs @@ -548,14 +548,12 @@ mod tests { let burned_witness = B256::random(); let other_witness = B256::random(); - let key_authorization = |witness| SignedKeyAuthorization { - authorization: KeyAuthorization::unrestricted( - 42431, - SignatureType::Secp256k1, - Address::random(), - ) - .with_witness(witness), - signature: PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()), + let key_authorization = |witness| { + KeyAuthorization::unrestricted(42431, SignatureType::Secp256k1, Address::random()) + .with_witness(witness) + .into_signed(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )) }; let matching = Arc::new(wrap_valid_tx( diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index efcc09cf22..867f07ea79 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -1968,14 +1968,10 @@ mod tests { let burned_witness = B256::random(); let other_witness = B256::random(); - let key_authorization = |witness| SignedKeyAuthorization { - authorization: KeyAuthorization::unrestricted( - 42431, - SignatureType::Secp256k1, - Address::random(), - ) - .with_witness(witness), - signature: PrimitiveSignature::Secp256k1(Signature::test_signature()), + let key_authorization = |witness| { + KeyAuthorization::unrestricted(42431, SignatureType::Secp256k1, Address::random()) + .with_witness(witness) + .into_signed(PrimitiveSignature::Secp256k1(Signature::test_signature())) }; let matching = crate::test_utils::TxBuilder::aa(sender) From e7835d5efe127672475569b19c09a1fe5eccc34d Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 19:32:11 +0530 Subject: [PATCH 34/39] fix(revm): require root tx for root key auth --- crates/revm/src/handler.rs | 44 +++++++++++++++++++++++++++++++++++++- tips/tip-1049.md | 5 +++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index a0342a7b9c..981bc3ca4d 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1698,6 +1698,7 @@ where if let Some(key_auth) = &aa_env.key_authorization { // Check if this TX is using a Keychain signature (access key). Non-admin access // keys cannot authorize other keys; T6 admin keys can. + let mut same_tx_auth_use = false; if let Some(keychain_sig) = aa_env.signature.as_keychain() { // Use override_key_id if provided (for gas estimation), otherwise recover from signature let access_key_addr = if let Some(override_key_id) = aa_env.override_key_id { @@ -1709,7 +1710,7 @@ where .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? }; - let same_tx_auth_use = access_key_addr == key_auth.key_id; + same_tx_auth_use = access_key_addr == key_auth.key_id; if !same_tx_auth_use && !cfg.spec.is_t6() { return Err( TempoInvalidTransaction::AccessKeyCannotAuthorizeOtherKeys.into() @@ -1823,6 +1824,18 @@ where .into()); } + if auth_signer == tx.caller + && aa_env.signature.is_keychain() + && !same_tx_auth_use + { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: + "root-signed key authorization must use root transaction signature" + .to_string(), + } + .into()); + } + if auth_signer != tx.caller { let Some(keychain_sig) = aa_env.signature.as_keychain() else { return Err(TempoInvalidTransaction::KeychainValidationFailed { @@ -5184,6 +5197,35 @@ mod tests { }); } + #[test] + fn test_t6_root_signed_key_authorization_rejects_admin_keychain_submission() { + let (root_signer, user) = generate_keypair(); + let (_, admin_key) = generate_keypair(); + let child_key = Address::random(); + let signed = sign_key_auth( + &root_signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, child_key), + ); + let (mut evm, h) = make_evm( + user, + admin_key, + Some(signed), + TempoHardfork::T6, + None, + false, + ); + + let env_result = h.validate_env(&mut evm); + assert!( + matches!( + &env_result, + Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) + if reason.contains("root transaction signature") + ), + "root-signed key authorization should require a root transaction signature, got: {env_result:?}" + ); + } + #[test] fn test_t6_root_key_authorization_rejects_account_mismatch() { let (signer, user) = generate_keypair(); diff --git a/tips/tip-1049.md b/tips/tip-1049.md index 772c7e4c4d..e299a315c2 100644 --- a/tips/tip-1049.md +++ b/tips/tip-1049.md @@ -152,6 +152,7 @@ rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?, is_ - If `is_admin` is omitted or `false`, the authorization creates a non-admin key. - If `is_admin` is `true`, the authorization creates an admin key. - If the authorization signer is not the target account's root key, `account` MUST be present and equal the target account being modified. +- If the authorization signer is the target account's root key, the transaction MUST be signed by the root key, except for same-transaction authorize-and-use where the transaction key is the key being authorized. - If the authorization signer is an admin access key, the transaction MUST be signed by that same admin access key. A transaction signed by the root key or a different admin access key MUST NOT carry an admin-signed `KeyAuthorization`. - If `account` is present, it MUST equal the target account being modified. - If `is_admin` is `true`, `expiry`, `limits`, and `allowed_calls` MUST be omitted or encoded as empty optional values because admin keys carry no restrictions. @@ -159,8 +160,8 @@ rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?, is_ In root/admin terms: -- Root key authorizes non-admin key: `account` MAY be omitted. If present, it MUST equal the target account. -- Root key authorizes admin key: `account` MAY be omitted. If present, it MUST equal the target account. +- Root key authorizes non-admin key: `account` MAY be omitted. If present, it MUST equal the target account. The transaction MUST be signed by the root key, except for same-transaction authorize-and-use by the key being authorized. +- Root key authorizes admin key: `account` MAY be omitted. If present, it MUST equal the target account. The transaction MUST be signed by the root key, except for same-transaction authorize-and-use by the key being authorized. - Admin key authorizes non-admin key: `account` MUST be present, MUST equal the target account, and the transaction MUST be signed by the same admin key. - Admin key authorizes admin key: `account` MUST be present, MUST equal the target account, and the transaction MUST be signed by the same admin key. - Non-admin access key authorizes any key: invalid. From f9d3f793bf446bb822c03f3dca3c68a8662d4ab5 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 19:33:33 +0530 Subject: [PATCH 35/39] chore(transaction-pool): remove unused imports --- crates/transaction-pool/src/paused.rs | 4 +--- crates/transaction-pool/src/tempo_pool.rs | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/transaction-pool/src/paused.rs b/crates/transaction-pool/src/paused.rs index 36a5ec69ed..a8e0032a89 100644 --- a/crates/transaction-pool/src/paused.rs +++ b/crates/transaction-pool/src/paused.rs @@ -307,9 +307,7 @@ mod tests { use reth_transaction_pool::TransactionOrigin; use tempo_primitives::{ SignatureType, TempoTxEnvelope, - transaction::{ - KeyAuthorization, PrimitiveSignature, SignedKeyAuthorization, tt_signed::AASigned, - }, + transaction::{KeyAuthorization, PrimitiveSignature, tt_signed::AASigned}, }; fn create_valid_tx(sender: Address) -> Arc> { diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index 867f07ea79..9fde455b7e 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -1365,9 +1365,7 @@ mod tests { }; use tempo_primitives::{ Block, TempoHeader, TempoPrimitives, TempoTxEnvelope, - transaction::{ - KeyAuthorization, PrimitiveSignature, SignatureType, SignedKeyAuthorization, - }, + transaction::{KeyAuthorization, PrimitiveSignature, SignatureType}, }; fn provider_with_spending_limit( From 8042c99a9e9374d74daa36237c84adff27935342 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 20:14:24 +0530 Subject: [PATCH 36/39] chore(tip-1049): update tempo-std ABI --- tips/verify/lib/tempo-std | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/verify/lib/tempo-std b/tips/verify/lib/tempo-std index 96ed91b1da..d703eeb880 160000 --- a/tips/verify/lib/tempo-std +++ b/tips/verify/lib/tempo-std @@ -1 +1 @@ -Subproject commit 96ed91b1da6ca41c987aba0470cadb0f0107be43 +Subproject commit d703eeb880859dcdac32a866c461e7c1821306b1 From cb7001bf9c17aa58086a688d6676505598b13f22 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 20:16:55 +0530 Subject: [PATCH 37/39] Update crates/precompiles/src/account_keychain/mod.rs Co-authored-by: 0xrusowsky <90208954+0xrusowsky@users.noreply.github.com> --- crates/precompiles/src/account_keychain/mod.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 83fc391fa7..acb01d746d 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -396,12 +396,9 @@ impl AccountKeychain { witness, true, )?; - self.emit_event(AccountKeychainEvent::AdminKeyAuthorized( - IAccountKeychain::AdminKeyAuthorized { - account: msg_sender, - publicKey: key_id, - }, - )) + self.emit_event( + AccountKeychainEvent::admin_key_authorized(msg_sender, key_id) + ) } /// Burns a TIP-1053 witness without authorizing a key. From be191d70c3cf10265379315600d7c8c616ffb8d3 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 28 May 2026 07:48:09 -0700 Subject: [PATCH 38/39] chore(tip-1049): align tempo-std foundry lock --- tips/verify/foundry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/verify/foundry.lock b/tips/verify/foundry.lock index 30f1f7251b..78406d028b 100644 --- a/tips/verify/foundry.lock +++ b/tips/verify/foundry.lock @@ -8,7 +8,7 @@ "lib/tempo-std": { "branch": { "name": "master", - "rev": "59063b041ba5c430e7e01e3724f1d40d45da686f" + "rev": "d703eeb880859dcdac32a866c461e7c1821306b1" } } } From c67f82a6604493489c2c8c1e783aaee82862bf0f Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 28 May 2026 20:21:13 +0530 Subject: [PATCH 39/39] chore(account-keychain): apply rustfmt --- crates/precompiles/src/account_keychain/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index acb01d746d..dbb71b49a5 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -396,9 +396,9 @@ impl AccountKeychain { witness, true, )?; - self.emit_event( - AccountKeychainEvent::admin_key_authorized(msg_sender, key_id) - ) + self.emit_event(AccountKeychainEvent::admin_key_authorized( + msg_sender, key_id, + )) } /// Burns a TIP-1053 witness without authorizing a key.