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/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/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<()> { diff --git a/crates/precompiles/src/account_keychain/dispatch.rs b/crates/precompiles/src/account_keychain/dispatch.rs index 16f6572aa8..f9a02b25ad 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,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.account, c.keyId)) + } IAccountKeychainCalls::getTransactionKey(call) => { view(call, |c| self.get_transaction_key(c, msg_sender)) } @@ -154,7 +167,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..dbb71b49a5 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]: @@ -54,10 +54,11 @@ 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 - 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 @@ -65,6 +66,40 @@ 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, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Storable)] +pub enum StoredSignatureType { + #[default] + Secp256k1, + P256, + WebAuthn, +} + +impl TryFrom for StoredSignatureType { + type Error = crate::error::TempoPrecompileError; + + fn try_from(value: SignatureType) -> std::result::Result { + match value { + SignatureType::Secp256k1 => Ok(Self::Secp256k1), + SignatureType::P256 => Ok(Self::P256), + SignatureType::WebAuthn => Ok(Self::WebAuthn), + _ => Err(AccountKeychainError::invalid_signature_type().into()), + } + } +} + +impl From for SignatureType { + fn from(value: StoredSignatureType) -> Self { + match value { + StoredSignatureType::Secp256k1 => Self::Secp256k1, + StoredSignatureType::P256 => Self::P256, + StoredSignatureType::WebAuthn => Self::WebAuthn, + } + } } /// Account Keychain contract for managing authorized keys (session keys, spending limits). @@ -191,7 +226,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 @@ -208,6 +243,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)?; @@ -217,6 +264,10 @@ 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 is_admin && 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() { @@ -237,13 +288,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)?; // TIP-1011 fields are hardfork-gated at T3, so reject them before mutating state. let allowed_call_configs = if is_t3 { @@ -288,22 +333,25 @@ impl AccountKeychain { expiry: config.expiry, enforce_limits: config.enforceLimits, is_revoked: 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( @@ -318,13 +366,41 @@ impl AccountKeychain { self.emit_event(AccountKeychainEvent::key_authorized( msg_sender, key_id, - signature_type, + signature_type as u8, config.expiry, ))?; 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.authorize_key_internal( + msg_sender, + key_id, + signature_type, + KeyRestrictions { + expiry: u64::MAX, + enforceLimits: false, + limits: Vec::new(), + allowAnyCalls: true, + allowedCalls: Vec::new(), + }, + witness, + true, + )?; + self.emit_event(AccountKeychainEvent::admin_key_authorized( + msg_sender, key_id, + )) + } + /// Burns a TIP-1053 witness without authorizing a key. pub fn burn_key_authorization_witness( &mut self, @@ -339,8 +415,8 @@ 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)?; @@ -371,8 +447,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 @@ -385,6 +462,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 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 { @@ -431,16 +511,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: key.signature_type.into(), keyId: call.keyId, expiry: key.expiry, enforceLimits: key.enforce_limits, @@ -486,7 +558,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, @@ -499,7 +571,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 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; @@ -517,7 +592,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, @@ -526,7 +601,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 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 +700,7 @@ impl AccountKeychain { self.key_authorization_witnesses[call.account][call.witness].read() } + /// Returns true for the root key or for an active admin access key. /// Returns the access key used to authorize the current transaction (`Address::ZERO` = root key). pub fn get_transaction_key( &self, @@ -1003,7 +1082,9 @@ 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 !transaction_key.is_zero() + && (!self.storage.spec().is_t6() || !self.is_admin_key(msg_sender, transaction_key)?) + { return Err(AccountKeychainError::unauthorized_caller().into()); } @@ -1017,6 +1098,22 @@ impl AccountKeychain { Ok(()) } + /// Internal predicate for root/admin status. + pub fn is_admin_key(&self, account: Address, key_id: Address) -> Result { + if key_id == account { + return Ok(true); + } + + let current_timestamp = self.storage.timestamp().saturating_to::(); + let key = match self.load_active_key(account, key_id, current_timestamp) { + Ok(key) => key, + Err(err) if err.is_system_error() => return Err(err), + Err(_) => return Ok(false), + }; + + Ok(key.is_admin) + } + fn ensure_key_authorization_witness_not_burned( &self, account: Address, @@ -1097,10 +1194,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()); @@ -1472,10 +1569,416 @@ 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 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() } + #[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, StoredSignatureType::P256); + assert_eq!(key.expiry, u64::MAX); + assert!(!key.enforce_limits); + assert!(!key.is_revoked); + assert!(key.is_admin); + assert!(keychain.is_admin_key(account, account)?); + assert!(keychain.is_admin_key(account, admin_key)?); + + Ok(()) + }) + } + + #[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); + 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); + 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(account, 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_admin_authorization_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 + .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_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_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, + updateSpendingLimitCall { + keyId: account, + token, + newLimit: U256::from(1), + }, + ) + .expect_err("missing self-key row cannot receive spending limits"), + ); + + assert_key_not_found( + keychain + .set_allowed_calls( + account, + setAllowedCallsCall { + keyId: account, + scopes: vec![CallScope { + target, + selectorRules: vec![], + }], + }, + ) + .expect_err("missing self-key row cannot receive call scopes"), + ); + + assert_key_not_found( + keychain + .remove_allowed_calls( + account, + removeAllowedCallsCall { + keyId: account, + target, + }, + ) + .expect_err("missing self-key row cannot remove call scopes"), + ); + + keychain.keys[account][account].write(AuthorizedKey { + signature_type: StoredSignatureType::Secp256k1, + expiry: u64::MAX, + enforce_limits: false, + is_revoked: false, + is_admin: false, + })?; + + 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(()) + }) + } + + #[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); @@ -3530,10 +4033,11 @@ 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, + 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 9cda72b4a6..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(), @@ -1059,9 +1059,11 @@ mod tests { limits, allowed_calls: None, witness: None, - }, - signature: PrimitiveSignature::Secp256k1(Signature::test_signature()), - }), + is_admin: false, + account: None, + } + .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 02f45eb6e9..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 /// @@ -163,9 +171,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?, 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 @@ -173,8 +182,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))] @@ -216,6 +224,16 @@ pub struct KeyAuthorization { /// `None` means no witness. `Some(witness)` means the witness field is present, including when /// `witness == B256::ZERO`. pub witness: Option, + + /// Whether this authorization creates an admin access key. + #[cfg_attr(feature = "serde", serde(default))] + pub is_admin: bool, + + /// Account this authorization targets. + /// + /// 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
, } impl KeyAuthorization { @@ -230,6 +248,8 @@ impl KeyAuthorization { limits: None, allowed_calls: None, witness: None, + is_admin: false, + account: None, } } @@ -274,6 +294,24 @@ impl KeyAuthorization { self.witness } + /// Convert this authorization into an account-bound admin-key authorization. + pub fn into_admin(mut self, account: Address) -> Self { + 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.is_admin + } + /// Computes the authorization message hash for this key authorization. pub fn signature_hash(&self) -> B256 { let mut buf = Vec::new(); @@ -310,15 +348,16 @@ 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.is_admin + || self.account.is_some()) } /// 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`. @@ -370,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))] @@ -392,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 @@ -407,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 { @@ -418,6 +498,8 @@ impl<'a> arbitrary::Arbitrary<'a> for KeyAuthorization { limits: u.arbitrary()?, allowed_calls: u.arbitrary()?, witness: u.arbitrary::>()?.map(B256::from), + is_admin: u.arbitrary()?, + account: u.arbitrary()?, }) } } @@ -455,6 +537,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, )] @@ -559,6 +725,8 @@ mod tests { limits, allowed_calls: None, witness: None, + is_admin: false, + account: None, } } @@ -605,6 +773,48 @@ mod tests { assert_eq!(reencoded, encoded); } + #[test] + 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); + 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); + 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!(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); + let decoded = + ::decode(&mut encoded.as_slice()).expect("decode auth"); + assert_eq!(decoded, admin); + assert_eq!(decoded.witness(), Some(witness)); + 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] fn test_witness_encoding_preserves_prior_absent_trailing_fields() { let witness = B256::repeat_byte(0x53); @@ -787,6 +997,8 @@ mod tests { limits: None, allowed_calls: None, witness: 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 4657e6f19e..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"), @@ -2160,8 +2160,10 @@ mod compact_tests { }]), allowed_calls: None, witness: None, - }, - signature: PrimitiveSignature::P256(P256SignatureWithPreHash { + is_admin: false, + account: None, + } + .into_signed(PrimitiveSignature::P256(P256SignatureWithPreHash { r: b256!("0x1111111111111111111111111111111111111111111111111111111111111111"), s: b256!("0x2222222222222222222222222222222222222222222222222222222222222222"), pub_key_x: b256!( @@ -2171,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/handler.rs b/crates/revm/src/handler.rs index 4718938a6c..981bc3ca4d 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, @@ -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). /// @@ -143,6 +143,12 @@ fn tempo_signature_verification_gas(signature: &TempoSignature) -> u64 { } } +#[derive(Debug, Clone)] +struct LoadedTxAccessKey { + key_id: Address, + key: AuthorizedKey, +} + /// 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 @@ -342,7 +348,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(); @@ -366,7 +372,11 @@ fn calculate_key_authorization_gas( let mut regular_gas = sig_gas + sload_cost + sstore_cost * num_sstores + BUFFER; 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 @@ -1128,8 +1138,11 @@ 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; + 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() { @@ -1146,10 +1159,27 @@ where .into()); } - if let Some(key_auth) = tempo_tx_env.key_authorization.as_ref() { - // 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. + // 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)? + }; + + 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. + same_tx_key_authorization_use = + key_auth.is_some_and(|key_auth| access_key_addr == key_auth.key_id); + + 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() @@ -1168,18 +1198,11 @@ where } } } 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)? - }; - - // 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( + // 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 loaded_key = StorageCtx::enter_precompile( journal, block, cfg, @@ -1190,9 +1213,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( @@ -1205,6 +1227,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() && !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. @@ -1212,11 +1242,54 @@ where .set_transaction_key(access_key_addr) .map_err(|e| EVMError::Custom(e.to_string()))?; - Ok::<_, EVMError<_, TempoInvalidTransaction>>(key.expiry) + 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); + } + } + + // 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() + { + 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(); + let signer_is_admin = match loaded_tx_access_key { + Some(loaded_key) + if loaded_key.key_id == auth_signer + && (loaded_key.key.signature_type as u8) == key_auth_sig_type => + { + loaded_key.key.is_admin + } + Some(_) | None => { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: + "admin-signed key authorization must be signed by transaction key" + .to_string(), + } + .into()); + } + }; + + if !signer_is_admin { + return Err(TempoInvalidTransaction::KeyAuthorizationNotSignedByRoot { + expected: tx.caller, + actual: auth_signer, + } + .into()); + } } } @@ -1375,14 +1448,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. @@ -1421,8 +1503,10 @@ 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() { + // 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 same_tx_key_authorization_use { StorageCtx::enter_precompile( journal, block, @@ -1612,8 +1696,9 @@ 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. + 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 { @@ -1625,14 +1710,15 @@ 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 { + 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() ); } - 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 { @@ -1643,23 +1729,58 @@ where } } - // Validate that the KeyAuthorization is signed by the root account - let root_account = &tx.caller; + 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()); + } - // Recover the signer of the KeyAuthorization - let auth_signer = key_auth.recover_signer().map_err(|_| { - TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed - })?; + 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" + }; - // Verify the KeyAuthorization is signed by the root account - if auth_signer != *root_account { - return Err(TempoInvalidTransaction::KeyAuthorizationNotSignedByRoot { - expected: *root_account, - actual: auth_signer, + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: reason.to_string(), } .into()); } + 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()); + } + + 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). @@ -1692,6 +1813,70 @@ 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()); + } + + 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 { + 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()); + } + } + } + // Cache inline key authorization expiry. if let Some(expiry) = key_auth.expiry { evm.key_expiry = Some(expiry.get()); @@ -3014,12 +3199,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 @@ -3158,7 +3340,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!( @@ -3167,21 +3349,78 @@ mod tests { "T5 witness authorization does not add state gas" ); - let scoped = SignedKeyAuthorization { - authorization: KeyAuthorization::unrestricted( - 1, - SignatureType::Secp256k1, - Address::random(), - ) + let t6_gas_params = crate::gas_params::tempo_gas_params(TempoHardfork::T6); + 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 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); + 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); + 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, + 0, + "T6 account-bound authorization does not add key authorization gas" + ); + assert_eq!( + admin_t6_gas - base_t6_gas, + KEY_AUTH_EXTRA_EVENT_BUFFER, + "T6 account-bound admin authorization charges 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, + 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, + "T6 account binding does not add state gas" + ); + assert_eq!( + admin_t6_state_gas, base_t6_state_gas, + "T6 admin authorization event buffer 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 = 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); @@ -3205,33 +3444,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); @@ -3255,18 +3491,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 = @@ -3286,41 +3517,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); @@ -3347,26 +3572,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( @@ -4342,10 +4564,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 @@ -4375,9 +4596,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 { @@ -4400,10 +4619,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()); @@ -4902,6 +5118,497 @@ 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_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 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!( + 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_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(); + 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(); + 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::WebAuthn, child_key) + .into_admin(user), + ); + let (mut evm, h) = make_evm( + user, + admin_key, + Some(signed), + TempoHardfork::T6, + None, + 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 + .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(user, child_key) + .expect("admin key status read succeeds"), + "child key should be registered as admin" + ); + }); + } + + #[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(); + 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, + ); + + 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 in validate_env, got: {result:?}" + ); + } + + #[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, + ); + + 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 + .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; + + 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, + ); + 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 + .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(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); + + let bob_result = bob_handler.validate_env(&mut bob_evm); + assert!( + matches!( + &bob_result, + Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) + if reason.contains("key authorization account mismatch") + ), + "Alice-bound authorization should not replay for Bob, got: {bob_result:?}" + ); + } + + #[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); + + 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) + .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, + ); + + 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 + .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( diff --git a/crates/transaction-pool/src/maintain.rs b/crates/transaction-pool/src/maintain.rs index 3b750fc853..db58db9d7c 100644 --- a/crates/transaction-pool/src/maintain.rs +++ b/crates/transaction-pool/src/maintain.rs @@ -45,6 +45,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. @@ -105,6 +110,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() @@ -138,6 +144,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( @@ -223,6 +242,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() @@ -241,6 +261,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. @@ -255,6 +279,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) @@ -771,11 +801,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, ); @@ -853,6 +885,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(), @@ -1140,6 +1174,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 8c6a4306a3..a8e0032a89 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,26 +218,58 @@ 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 Some(subject) = entry.tx.transaction.keychain_subject() else { + 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 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 true; + 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) + }); }; - 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_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)); }; 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)) + || key_authorization_target.as_ref().is_some_and(|subject| { + subject.matches_key_update(key_authorization_target_changes) + }) + || (sender_paid && matches_limit_update) + { return false; } @@ -273,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> { @@ -456,7 +488,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, @@ -490,7 +527,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); @@ -504,14 +546,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( @@ -550,8 +590,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); @@ -564,6 +608,132 @@ 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, + &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(), + ); + + 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 f10d446c40..9fde455b7e 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,24 +202,49 @@ 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() - && 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; 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() @@ -433,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, @@ -1330,15 +1357,15 @@ 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}, }; use tempo_primitives::{ Block, TempoHeader, TempoPrimitives, TempoTxEnvelope, - transaction::{ - KeyAuthorization, PrimitiveSignature, SignatureType, SignedKeyAuthorization, - }, + transaction::{KeyAuthorization, PrimitiveSignature, SignatureType}, }; fn provider_with_spending_limit( @@ -1375,10 +1402,11 @@ 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, + is_admin: false, })?; let limit_key = AccountKeychain::spending_limit_key(account, key_id); keychain.spending_limits[limit_key][fee_token].write(limit_state)?; @@ -1445,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 { @@ -1937,14 +1966,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) @@ -2021,6 +2046,182 @@ 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()); + } + + #[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. @@ -2366,10 +2567,11 @@ 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, + is_admin: false, }) }) .unwrap(); @@ -2401,10 +2603,11 @@ 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, + is_admin: false, }) }) .unwrap(); diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 34007fea4e..071d793668 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(), } } @@ -172,6 +178,47 @@ 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 { + *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, + }) + }) + } + /// 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()?; @@ -1267,15 +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, -} - impl KeychainSubject { /// Returns true if this subject matches any of the revoked keys. /// @@ -1296,3 +1334,28 @@ impl KeychainSubject { spending_limit_updates.contains(self.account, self.key_id, self.fee_token) } } + +/// 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 { + key_updates.contains(self.account, self.key_id) + } +} diff --git a/tips/tip-1049.md b/tips/tip-1049.md index 132cd51008..e299a315c2 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 @@ -121,12 +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. -When the target `keyId` resolves to an admin key (including the caller targeting itself): +`authorizeAdminKey` MUST reject `keyId == account` with `AccountKeychainError::InvalidKeyId`. -- `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 stored access-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. ### TIP-1020 Compatibility @@ -134,23 +137,36 @@ 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. ## 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 `is_admin?` and `account?` trailing optional fields 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?, is_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 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. +- 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 `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. +In root/admin terms: + +- 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. + +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 @@ -159,5 +175,3 @@ The target `account` and `is_admin` fields are part of the signed RLP payload. T 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. `revokeKey` MUST reject `keyId == account` regardless of any stored legacy key entry. 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" } } } 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