diff --git a/crates/contracts/src/precompiles/account_keychain.rs b/crates/contracts/src/precompiles/account_keychain.rs index 60df7fef3c..c65c70fc2d 100644 --- a/crates/contracts/src/precompiles/account_keychain.rs +++ b/crates/contracts/src/precompiles/account_keychain.rs @@ -3,8 +3,8 @@ pub use IAccountKeychain::{ IAccountKeychainErrors as AccountKeychainError, IAccountKeychainEvents as AccountKeychainEvent, authorizeKey_0Call as legacyAuthorizeKeyCall, authorizeKey_1Call as authorizeKeyCall, - getAllowedCallsReturn, getRemainingLimitWithPeriodCall, - getRemainingLimitWithPeriodReturn as getRemainingLimitReturn, + authorizeKey_2Call as authorizeKeyWithNonceCall, getAllowedCallsReturn, + getRemainingLimitWithPeriodCall, getRemainingLimitWithPeriodReturn as getRemainingLimitReturn, }; crate::sol! { @@ -92,6 +92,9 @@ crate::sol! { uint256 remainingLimit ); + /// Emitted when a TIP-1053 key-authorization nonce is consumed. + event KeyAuthorizationNonceConsumed(address indexed account, bytes32 indexed nonce); + /// Legacy authorize-key entrypoint used before T3. function authorizeKey( address keyId, @@ -111,6 +114,19 @@ crate::sol! { KeyRestrictions calldata config ) external; + /// Authorize a new key with a TIP-1053 replay nonce. + /// @dev The nonce must be unused for the caller's account. bytes32(0) is a valid nonce. + function authorizeKey( + address keyId, + SignatureType signatureType, + KeyRestrictions calldata config, + bytes32 nonce + ) external; + + /// Burn a TIP-1053 key-authorization nonce without authorizing a key. + /// @dev Callable by the account root key or an active access key. + function burnKeyAuthorizationNonce(bytes32 nonce) external; + /// Revoke an authorized key /// @param publicKey The public key to revoke function revokeKey(address keyId) external; @@ -176,6 +192,9 @@ crate::sol! { address keyId ) external view returns (bool isScoped, CallScope[] memory scopes); + /// Returns whether a TIP-1053 key-authorization nonce has been consumed. + function isKeyAuthorizationNonceUsed(address account, bytes32 nonce) 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); @@ -194,6 +213,8 @@ crate::sol! { error SignatureTypeMismatch(uint8 expected, uint8 actual); error CallNotAllowed(); error InvalidCallScope(); + error InvalidKeyAuthorizationNonce(); + error KeyAuthorizationNonceAlreadyUsed(); error LegacyAuthorizeKeySelectorChanged(bytes4 newSelector); } } @@ -266,6 +287,18 @@ impl AccountKeychainError { Self::InvalidCallScope(IAccountKeychain::InvalidCallScope {}) } + /// Creates an error for a TIP-1053 nonce path that is unavailable for the current hardfork. + pub const fn invalid_key_authorization_nonce() -> Self { + Self::InvalidKeyAuthorizationNonce(IAccountKeychain::InvalidKeyAuthorizationNonce {}) + } + + /// Creates an error for a TIP-1053 nonce that has already been consumed. + pub const fn key_authorization_nonce_already_used() -> Self { + Self::KeyAuthorizationNonceAlreadyUsed( + IAccountKeychain::KeyAuthorizationNonceAlreadyUsed {}, + ) + } + /// Creates an error for the legacy authorize-key selector being unavailable on T3+. pub fn legacy_authorize_key_selector_changed(new_selector: [u8; 4]) -> Self { Self::LegacyAuthorizeKeySelectorChanged( diff --git a/crates/precompiles/src/account_keychain/dispatch.rs b/crates/precompiles/src/account_keychain/dispatch.rs index 128faca08d..aa9c4d211f 100644 --- a/crates/precompiles/src/account_keychain/dispatch.rs +++ b/crates/precompiles/src/account_keychain/dispatch.rs @@ -21,6 +21,11 @@ const T3_ADDED: &[[u8; 4]] = &[ IAccountKeychain::getAllowedCallsCall::SELECTOR, ]; const T3_DROPPED: &[[u8; 4]] = &[IAccountKeychain::getRemainingLimitCall::SELECTOR]; +const T5_ADDED: &[[u8; 4]] = &[ + IAccountKeychain::authorizeKey_2Call::SELECTOR, + IAccountKeychain::burnKeyAuthorizationNonceCall::SELECTOR, + IAccountKeychain::isKeyAuthorizationNonceUsedCall::SELECTOR, +]; impl Precompile for AccountKeychain { fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { @@ -30,9 +35,12 @@ impl Precompile for AccountKeychain { dispatch_call( calldata, - &[SelectorSchedule::new(TempoHardfork::T3) - .with_added(T3_ADDED) - .with_dropped(T3_DROPPED)], + &[ + SelectorSchedule::new(TempoHardfork::T3) + .with_added(T3_ADDED) + .with_dropped(T3_DROPPED), + SelectorSchedule::new(TempoHardfork::T5).with_added(T5_ADDED), + ], IAccountKeychainCalls::abi_decode, |call| match call { IAccountKeychainCalls::authorizeKey_0(call) => { @@ -69,6 +77,16 @@ impl Precompile for AccountKeychain { IAccountKeychainCalls::authorizeKey_1(call) => { mutate_void(call, msg_sender, |sender, c| self.authorize_key(sender, c)) } + IAccountKeychainCalls::authorizeKey_2(call) => { + mutate_void(call, msg_sender, |sender, c| { + self.authorize_key_with_nonce(sender, c) + }) + } + IAccountKeychainCalls::burnKeyAuthorizationNonce(call) => { + mutate_void(call, msg_sender, |sender, c| { + self.burn_key_authorization_nonce(sender, c) + }) + } IAccountKeychainCalls::revokeKey(call) => { mutate_void(call, msg_sender, |sender, c| self.revoke_key(sender, c)) } @@ -97,6 +115,9 @@ impl Precompile for AccountKeychain { IAccountKeychainCalls::getAllowedCalls(call) => { view(call, |c| self.get_allowed_calls(c)) } + IAccountKeychainCalls::isKeyAuthorizationNonceUsed(call) => { + view(call, |c| self.is_key_authorization_nonce_used(c)) + } IAccountKeychainCalls::getTransactionKey(call) => { view(call, |c| self.get_transaction_key(c, msg_sender)) } @@ -115,7 +136,7 @@ mod tests { test_util::{assert_full_coverage, check_selector_coverage}, }; use alloy::{ - primitives::U256, + primitives::{B256, U256}, sol_types::{SolCall, SolError}, }; use tempo_chainspec::hardfork::TempoHardfork; @@ -123,7 +144,7 @@ mod tests { #[test] fn test_account_keychain_selector_coverage() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T3); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); StorageCtx::enter(&mut storage, || { let mut fee_manager = AccountKeychain::new(); let selectors: Vec<_> = IAccountKeychainCalls::SELECTORS @@ -348,6 +369,54 @@ mod tests { }) } + #[test] + fn test_t5_nonce_selectors_rejected_pre_t5() -> eyre::Result<()> { + let account = Address::random(); + let nonce = B256::repeat_byte(0x53); + + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + + for (selector, calldata) in [ + ( + IAccountKeychain::authorizeKey_2Call::SELECTOR, + IAccountKeychain::authorizeKey_2Call { + keyId: Address::random(), + signatureType: IAccountKeychain::SignatureType::Secp256k1, + config: KeyRestrictions { + expiry: u64::MAX, + enforceLimits: false, + limits: vec![], + allowAnyCalls: true, + allowedCalls: vec![], + }, + nonce, + } + .abi_encode(), + ), + ( + IAccountKeychain::burnKeyAuthorizationNonceCall::SELECTOR, + IAccountKeychain::burnKeyAuthorizationNonceCall { nonce }.abi_encode(), + ), + ( + IAccountKeychain::isKeyAuthorizationNonceUsedCall::SELECTOR, + IAccountKeychain::isKeyAuthorizationNonceUsedCall { account, nonce } + .abi_encode(), + ), + ] { + let result = keychain.call(&calldata, account)?; + assert!(result.is_revert(), "expected T5 selector to revert pre-T5"); + + let decoded = UnknownFunctionSelector::abi_decode(&result.bytes)?; + assert_eq!(decoded.selector.as_slice(), &selector); + } + + Ok(()) + }) + } + #[test] fn test_t3_selector_with_malformed_data_returns_unknown_selector_error() -> eyre::Result<()> { let selector = getRemainingLimitWithPeriodCall::SELECTOR; diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 8a28463fe0..fc73c072df 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -16,11 +16,11 @@ pub use tempo_contracts::precompiles::{ IAccountKeychain, IAccountKeychain::{ CallScope, KeyInfo, KeyRestrictions, SelectorRule, SignatureType, TokenLimit, - getAllowedCallsCall, getKeyCall, getRemainingLimitCall, getRemainingLimitWithPeriodCall, - getTransactionKeyCall, removeAllowedCallsCall, revokeKeyCall, setAllowedCallsCall, - updateSpendingLimitCall, + burnKeyAuthorizationNonceCall, getAllowedCallsCall, getKeyCall, getRemainingLimitCall, + getRemainingLimitWithPeriodCall, getTransactionKeyCall, isKeyAuthorizationNonceUsedCall, + removeAllowedCallsCall, revokeKeyCall, setAllowedCallsCall, updateSpendingLimitCall, }, - authorizeKeyCall, getAllowedCallsReturn, getRemainingLimitReturn, + authorizeKeyCall, authorizeKeyWithNonceCall, getAllowedCallsReturn, getRemainingLimitReturn, }; use tempo_primitives::TempoAddressExt; @@ -81,6 +81,9 @@ pub struct AccountKeychain { // key_scopes[(account, keyId)] -> call scoping configuration. key_scopes: Mapping, + // key_authorization_nonces[account][nonce] -> true once a TIP-1053 nonce is consumed. + key_authorization_nonces: Mapping>, + // WARNING(rusowsky): transient storage slots must always be placed at the very end until the `contract` // macro is refactored and has 2 independent layouts (persistent and transient). // If new (persistent) storage fields need to be added to the precompile, they must go above this one. @@ -198,13 +201,50 @@ impl AccountKeychain { /// - `KeyAlreadyRevoked` — revoked keys cannot be re-authorized /// - `InvalidSignatureType` — must be Secp256k1, P256, or WebAuthn pub fn authorize_key(&mut self, msg_sender: Address, call: authorizeKeyCall) -> Result<()> { - let config = &call.config; + self.authorize_key_inner( + msg_sender, + call.keyId, + call.signatureType, + call.config, + None, + ) + } + + /// Registers a new access key and consumes a TIP-1053 nonce atomically with the successful + /// authorization. + pub fn authorize_key_with_nonce( + &mut self, + msg_sender: Address, + call: authorizeKeyWithNonceCall, + ) -> Result<()> { + self.authorize_key_inner( + msg_sender, + call.keyId, + call.signatureType, + call.config, + Some(call.nonce), + ) + } + fn authorize_key_inner( + &mut self, + msg_sender: Address, + key_id: Address, + signature_type: SignatureType, + config: KeyRestrictions, + nonce: Option, + ) -> Result<()> { + let config = &config; self.ensure_admin_caller(msg_sender)?; let is_t3 = self.storage.spec().is_t3(); + if nonce.is_some() && !self.storage.spec().is_t5() { + return Err(AccountKeychainError::invalid_key_authorization_nonce().into()); + } + let has_nonce = nonce.is_some(); + // Validate inputs - if call.keyId == Address::ZERO { + if key_id == Address::ZERO { return Err(AccountKeychainError::zero_public_key().into()); } @@ -217,7 +257,7 @@ impl AccountKeychain { } // Check if key already exists (key exists if expiry > 0) - let existing_key = self.keys[msg_sender][call.keyId].read()?; + let existing_key = self.keys[msg_sender][key_id].read()?; if existing_key.expiry > 0 { return Err(AccountKeychainError::key_already_exists().into()); } @@ -228,7 +268,7 @@ impl AccountKeychain { } // Convert SignatureType enum to u8 for storage - let signature_type = match call.signatureType { + let signature_type = match signature_type { SignatureType::Secp256k1 => 0, SignatureType::P256 => 1, SignatureType::WebAuthn => 2, @@ -243,12 +283,23 @@ impl AccountKeychain { if !seen_tokens.insert(limit.token) { return Err(AccountKeychainError::invalid_spending_limit().into()); } + + // Nonce-bearing authorizations must fail before consuming the nonce. Keep this + // validation nonce-only so legacy no-nonce failure gas/storage-touch behavior + // remains unchanged for reexecution. + if has_nonce { + Self::t3_spending_limit_cap(limit.amount)?; + } } } if config.allowAnyCalls { None } else { + // See the nonce-only validation note above. + if has_nonce { + self.validate_call_scopes(&config.allowedCalls)?; + } Some(config.allowedCalls.as_slice()) } } else { @@ -263,6 +314,10 @@ impl AccountKeychain { None }; + if let Some(nonce) = nonce { + self.consume_key_authorization_nonce(msg_sender, nonce)?; + } + // Create and store the new key let new_key = AuthorizedKey { signature_type, @@ -271,7 +326,7 @@ impl AccountKeychain { is_revoked: false, }; - self.keys[msg_sender][call.keyId].write(new_key)?; + self.keys[msg_sender][key_id].write(new_key)?; let limits = config .enforceLimits @@ -281,7 +336,7 @@ impl AccountKeychain { self.apply_key_authorization_restrictions( msg_sender, - call.keyId, + key_id, limits, allowed_call_configs, )?; @@ -290,13 +345,27 @@ impl AccountKeychain { self.emit_event(AccountKeychainEvent::KeyAuthorized( IAccountKeychain::KeyAuthorized { account: msg_sender, - publicKey: call.keyId, + publicKey: key_id, signatureType: signature_type, expiry: config.expiry, }, )) } + /// Burns a TIP-1053 nonce without authorizing a key. + pub fn burn_key_authorization_nonce( + &mut self, + msg_sender: Address, + call: burnKeyAuthorizationNonceCall, + ) -> Result<()> { + if !self.storage.spec().is_t5() { + return Err(AccountKeychainError::invalid_key_authorization_nonce().into()); + } + + self.ensure_nonce_burn_caller(msg_sender)?; + self.consume_key_authorization_nonce(msg_sender, call.nonce) + } + /// Permanently revokes an access key. Once revoked, a key ID can never be re-authorized for /// this account, preventing replay of old `KeyAuthorization` signatures. /// @@ -583,6 +652,14 @@ impl AccountKeychain { }) } + /// Returns whether a TIP-1053 key-authorization nonce has been consumed for an account. + pub fn is_key_authorization_nonce_used( + &self, + call: isKeyAuthorizationNonceUsedCall, + ) -> Result { + self.key_authorization_nonces[call.account][call.nonce].read() + } + /// Returns the access key used to authorize the current transaction (`Address::ZERO` = root key). pub fn get_transaction_key( &self, @@ -977,6 +1054,32 @@ impl AccountKeychain { Ok(()) } + fn ensure_nonce_burn_caller(&self, msg_sender: Address) -> Result<()> { + let tx_origin = self.tx_origin.t_read()?; + if tx_origin.is_zero() || tx_origin != msg_sender { + return Err(AccountKeychainError::unauthorized_caller().into()); + } + + let transaction_key = self.transaction_key.t_read()?; + if !transaction_key.is_zero() { + let current_timestamp = self.storage.timestamp().saturating_to::(); + self.load_active_key(msg_sender, transaction_key, current_timestamp)?; + } + + Ok(()) + } + + fn consume_key_authorization_nonce(&mut self, account: Address, nonce: B256) -> Result<()> { + if self.key_authorization_nonces[account][nonce].read()? { + return Err(AccountKeychainError::key_authorization_nonce_already_used().into()); + } + + self.key_authorization_nonces[account][nonce].write(true)?; + self.emit_event(AccountKeychainEvent::KeyAuthorizationNonceConsumed( + IAccountKeychain::KeyAuthorizationNonceConsumed { account, nonce }, + )) + } + /// Load and validate a key exists, is not revoked, and is not expired. /// /// Returns the key if valid, or an error if: @@ -1379,6 +1482,146 @@ mod tests { } } + fn assert_nonce_already_used(error: TempoPrecompileError) { + match error { + TempoPrecompileError::AccountKeychainError(e) => { + assert!( + matches!(e, AccountKeychainError::KeyAuthorizationNonceAlreadyUsed(_)), + "Expected KeyAuthorizationNonceAlreadyUsed error, got: {e:?}" + ); + } + _ => panic!("Expected AccountKeychainError, got: {error:?}"), + } + } + + fn unrestricted_restrictions() -> KeyRestrictions { + KeyRestrictions { + expiry: u64::MAX, + enforceLimits: false, + limits: vec![], + allowAnyCalls: true, + allowedCalls: vec![], + } + } + + #[test] + fn test_t5_authorize_key_with_nonce_consumes_and_rejects_reuse() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let account = Address::random(); + let first_key = Address::random(); + let second_key = Address::random(); + let nonce = B256::ZERO; + + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + + keychain.authorize_key_with_nonce( + account, + authorizeKeyWithNonceCall { + keyId: first_key, + signatureType: SignatureType::Secp256k1, + config: unrestricted_restrictions(), + nonce, + }, + )?; + + assert!( + keychain.is_key_authorization_nonce_used(isKeyAuthorizationNonceUsedCall { + account, + nonce, + })? + ); + + let replay = keychain.authorize_key_with_nonce( + account, + authorizeKeyWithNonceCall { + keyId: second_key, + signatureType: SignatureType::Secp256k1, + config: unrestricted_restrictions(), + nonce, + }, + ); + assert_nonce_already_used(replay.expect_err("nonce replay must fail")); + + assert_eq!(keychain.keys[account][second_key].read()?.expiry, 0); + Ok(()) + }) + } + + #[test] + fn test_t5_burn_key_authorization_nonce_blocks_later_auth() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let account = Address::random(); + let nonce = B256::repeat_byte(0x54); + + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + + keychain + .burn_key_authorization_nonce(account, burnKeyAuthorizationNonceCall { nonce })?; + + assert!( + keychain.is_key_authorization_nonce_used(isKeyAuthorizationNonceUsedCall { + account, + nonce, + })? + ); + + let result = keychain.authorize_key_with_nonce( + account, + authorizeKeyWithNonceCall { + keyId: Address::random(), + signatureType: SignatureType::Secp256k1, + config: unrestricted_restrictions(), + nonce, + }, + ); + assert_nonce_already_used(result.expect_err("burned nonce must not authorize")); + + Ok(()) + }) + } + + #[test] + fn test_t5_access_key_can_burn_key_authorization_nonce() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let account = Address::random(); + let access_key = Address::random(); + let nonce = B256::repeat_byte(0x55); + + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + + keychain.authorize_key( + account, + authorizeKeyCall { + keyId: access_key, + signatureType: SignatureType::Secp256k1, + config: unrestricted_restrictions(), + }, + )?; + + keychain.set_transaction_key(access_key)?; + keychain + .burn_key_authorization_nonce(account, burnKeyAuthorizationNonceCall { nonce })?; + + assert!( + keychain.is_key_authorization_nonce_used(isKeyAuthorizationNonceUsedCall { + account, + nonce, + })? + ); + + Ok(()) + }) + } + #[test] fn test_transaction_key_transient_storage() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new(1); diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 356ad574ad..ebf41673ed 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -872,6 +872,7 @@ mod tests { expiry: None, limits: None, allowed_calls: None, + nonce: None, }, signature: PrimitiveSignature::Secp256k1(Signature::test_signature()), }), diff --git a/crates/primitives/src/transaction/key_authorization.rs b/crates/primitives/src/transaction/key_authorization.rs index e378a017d2..3e8962b7eb 100644 --- a/crates/primitives/src/transaction/key_authorization.rs +++ b/crates/primitives/src/transaction/key_authorization.rs @@ -165,12 +165,14 @@ impl From for AbiSelectorRule { /// 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. /// -/// RLP encoding: `[chain_id, key_type, key_id, expiry?, limits?, allowed_calls?]` +/// RLP encoding: `[chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, nonce?]` /// - 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 /// - `allowed_calls`: `None` (canonically omitted, explicit 0x80 accepted) = unrestricted, /// `Some([])` = scoped with no allowed calls, `Some([...])` = scoped calls +/// - `nonce`: `None` (canonically omitted) = no TIP-1053 uniqueness tracking, +/// `Some(bytes32)` = arbitrary nonce consumed on successful T5+ authorization. #[derive(Clone, Debug, PartialEq, Eq, Hash, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable)] #[rlp(trailing(canonical))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -208,6 +210,12 @@ pub struct KeyAuthorization { /// - `Some([])` = scoped mode with no allowed calls /// - `Some([CallScope{...}])` = explicit target/selector scope list pub allowed_calls: Option>, + + /// Optional TIP-1053 nonce for per-account uniqueness tracking. + /// + /// `None` means no nonce. `Some(nonce)` means the nonce field is present, including when + /// `nonce == B256::ZERO`. + pub nonce: Option, } impl KeyAuthorization { @@ -221,6 +229,7 @@ impl KeyAuthorization { expiry: None, limits: None, allowed_calls: None, + nonce: None, } } @@ -254,6 +263,17 @@ impl KeyAuthorization { self } + /// Attach a TIP-1053 nonce to this authorization. + pub fn with_nonce(mut self, nonce: B256) -> Self { + self.nonce = Some(nonce); + self + } + + /// Returns this authorization's TIP-1053 nonce, if present. + pub fn nonce(&self) -> Option { + self.nonce + } + /// Computes the authorization message hash for this key authorization. pub fn signature_hash(&self) -> B256 { let mut buf = Vec::new(); @@ -273,6 +293,11 @@ impl KeyAuthorization { self.allowed_calls.is_some() } + /// Returns whether this authorization carries a TIP-1053 nonce field. + pub fn has_nonce(&self) -> bool { + self.nonce.is_some() + } + /// Returns whether this key has unlimited spending (limits is None) pub fn has_unlimited_spending(&self) -> bool { self.limits.is_none() @@ -285,7 +310,7 @@ 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_periodic_limits() || self.has_call_scopes() || self.has_nonce()) } /// Convert the key authorization into a [`SignedKeyAuthorization`] with a signature. @@ -392,6 +417,7 @@ impl<'a> arbitrary::Arbitrary<'a> for KeyAuthorization { expiry: u.arbitrary()?, limits: u.arbitrary()?, allowed_calls: u.arbitrary()?, + nonce: u.arbitrary::>()?.map(B256::from), }) } } @@ -532,9 +558,125 @@ mod tests { expiry: expiry.and_then(NonZeroU64::new), limits, allowed_calls: None, + nonce: None, } } + #[test] + fn test_zero_nonce_roundtrip_and_changes_signature_hash() { + let auth = make_auth(None, None); + let zero_nonce_auth = auth.clone().with_nonce(B256::ZERO); + + let mut encoded = Vec::new(); + zero_nonce_auth.encode(&mut encoded); + + assert_eq!(zero_nonce_auth.nonce(), Some(B256::ZERO)); + assert!(zero_nonce_auth.has_nonce()); + assert_ne!(zero_nonce_auth.signature_hash(), auth.signature_hash()); + + let decoded = + ::decode(&mut encoded.as_slice()).expect("decode auth"); + assert_eq!(decoded, zero_nonce_auth); + + let mut reencoded = Vec::new(); + decoded.encode(&mut reencoded); + assert_eq!(reencoded, encoded); + } + + #[test] + fn test_nonce_roundtrip_and_changes_signature_hash() { + let auth = make_auth(None, None); + let nonce = B256::repeat_byte(0x53); + let nonce_auth = auth.clone().with_nonce(nonce); + + assert_eq!(nonce_auth.nonce(), Some(nonce)); + assert!(nonce_auth.has_nonce()); + assert_ne!(nonce_auth.signature_hash(), auth.signature_hash()); + + let mut encoded = Vec::new(); + nonce_auth.encode(&mut encoded); + + let decoded = + ::decode(&mut encoded.as_slice()).expect("decode auth"); + assert_eq!(decoded, nonce_auth); + + let mut reencoded = Vec::new(); + decoded.encode(&mut reencoded); + assert_eq!(reencoded, encoded); + } + + #[test] + fn test_nonce_encoding_preserves_prior_absent_trailing_fields() { + let nonce = B256::repeat_byte(0x53); + let auth = make_auth(None, None).with_nonce(nonce); + + let mut encoded = Vec::new(); + auth.encode(&mut encoded); + + let mut payload = &encoded[..]; + let header = alloy_rlp::Header::decode(&mut payload).expect("decode list header"); + assert!(header.list); + assert_eq!(header.payload_length, payload.len()); + + let fixed_fields_len = + auth.chain_id.length() + auth.key_type.length() + auth.key_id.length(); + assert_eq!( + &payload[fixed_fields_len..fixed_fields_len + 3], + &[alloy_rlp::EMPTY_STRING_CODE; 3], + "expiry, limits, and allowed_calls must be explicit empty placeholders before nonce" + ); + + let mut nonce_payload = &payload[fixed_fields_len + 3..]; + let decoded_nonce = B256::decode(&mut nonce_payload).expect("decode nonce field"); + assert_eq!(decoded_nonce, nonce); + assert!(nonce_payload.is_empty()); + } + + #[test] + fn test_decode_accepts_explicit_zero_nonce() { + let auth = make_auth(None, None); + let mut encoded = Vec::new(); + let payload_length = auth.chain_id.length() + + auth.key_type.length() + + auth.key_id.length() + + 3 + + B256::ZERO.length(); + alloy_rlp::Header { + list: true, + payload_length, + } + .encode(&mut encoded); + auth.chain_id.encode(&mut encoded); + auth.key_type.encode(&mut encoded); + auth.key_id.encode(&mut encoded); + encoded.extend_from_slice(&[alloy_rlp::EMPTY_STRING_CODE; 3]); + B256::ZERO.encode(&mut encoded); + + let decoded = + ::decode(&mut encoded.as_slice()).expect("decode auth"); + assert_eq!(decoded.nonce(), Some(B256::ZERO)); + } + + #[test] + fn test_decode_rejects_explicit_absent_nonce_field() { + let auth = make_auth(None, None); + let mut encoded = Vec::new(); + let payload_length = + auth.chain_id.length() + auth.key_type.length() + auth.key_id.length() + 4; + alloy_rlp::Header { + list: true, + payload_length, + } + .encode(&mut encoded); + auth.chain_id.encode(&mut encoded); + auth.key_type.encode(&mut encoded); + auth.key_id.encode(&mut encoded); + encoded.extend_from_slice(&[alloy_rlp::EMPTY_STRING_CODE; 4]); + + ::decode(&mut encoded.as_slice()) + .expect_err("absent nonce field must be omitted, not encoded as 0x80"); + } + #[test] fn test_signature_hash_and_recover_signer() { let (signing_key, expected_address) = generate_secp256k1_keypair(); @@ -644,6 +786,7 @@ mod tests { expiry: None, limits: None, allowed_calls: None, + nonce: None, } } diff --git a/crates/primitives/src/transaction/tempo_transaction.rs b/crates/primitives/src/transaction/tempo_transaction.rs index 59549a01b9..364c9f512e 100644 --- a/crates/primitives/src/transaction/tempo_transaction.rs +++ b/crates/primitives/src/transaction/tempo_transaction.rs @@ -2089,6 +2089,7 @@ mod compact_tests { period: 86400, }]), allowed_calls: None, + nonce: None, }, signature: PrimitiveSignature::P256(P256SignatureWithPreHash { r: b256!("0x1111111111111111111111111111111111111111111111111111111111111111"), diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index fe53647ffe..67e570c6c8 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -41,6 +41,7 @@ use tempo_precompiles::{ account_keychain::{ AccountKeychain, CallScope as PrecompileCallScope, KeyRestrictions, SelectorRule as PrecompileSelectorRule, TokenLimit, authorizeKeyCall, + authorizeKeyWithNonceCall, }, error::TempoPrecompileError, nonce::{ @@ -347,15 +348,24 @@ fn calculate_key_authorization_gas( num_limits }; + let has_t5_nonce = spec.is_t5() && key_auth.has_nonce(); let mut num_sstores = 1 + limit_slots; if spec.is_t3() { num_sstores += call_scope_storage_slots(&key_auth.authorization, spec); } + if has_t5_nonce { + num_sstores += 1; + } + let sstore_cost = gas_params.get(GasId::sstore_set_without_load_cost()); let mut total_gas = sig_gas + sload_cost + sstore_cost * num_sstores + BUFFER; + if has_t5_nonce { + total_gas += sload_cost; + } + // T4+: include extra gas for call scopes configuration if spec.is_t4() { total_gas += call_scope_extra_gas(&key_auth.authorization); @@ -1342,21 +1352,37 @@ where let allow_any_calls = key_auth.allowed_calls.is_none(); let precompile_allowed_calls = translate_allowed_calls_for_precompile(key_auth); - // Create the authorize key call - let authorize_call = authorizeKeyCall { - keyId: access_key_addr, - signatureType: signature_type, - config: KeyRestrictions { - expiry, - enforceLimits: enforce_limits, - limits: precompile_limits, - allowAnyCalls: allow_any_calls, - allowedCalls: precompile_allowed_calls, - }, + let config = KeyRestrictions { + expiry, + enforceLimits: enforce_limits, + limits: precompile_limits, + allowAnyCalls: allow_any_calls, + allowedCalls: precompile_allowed_calls, }; // Call precompile to authorize the key (same phase as nonce increment) - match keychain.authorize_key(tx.caller, authorize_call) { + let result = if let Some(nonce) = key_auth.nonce() { + keychain.authorize_key_with_nonce( + tx.caller, + authorizeKeyWithNonceCall { + keyId: access_key_addr, + signatureType: signature_type, + config, + nonce, + }, + ) + } else { + keychain.authorize_key( + tx.caller, + authorizeKeyCall { + keyId: access_key_addr, + signatureType: signature_type, + config, + }, + ) + }; + + match result { // all is good, we can do execution. Ok(_) => Ok(false), // on out of gas we are skipping execution but not invalidating the transaction. @@ -1626,6 +1652,13 @@ where .validate_chain_id(cfg.chain_id(), cfg.spec.is_t1c()) .map_err(TempoInvalidTransaction::from)?; + if key_auth.has_nonce() && !cfg.spec.is_t5() { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: "key authorization nonces are not active before T5".to_string(), + } + .into()); + } + // T3 gates all TIP-1011 fields. Before activation, transaction semantics must stay // unchanged, so periodic limits and call scopes are rejected. if !cfg.spec.is_t3() { @@ -3027,6 +3060,35 @@ mod tests { ); } + let t5_gas_params = crate::gas_params::tempo_gas_params(TempoHardfork::T5); + let t5_sstore = + t5_gas_params.get(revm::context_interface::cfg::GasId::sstore_set_without_load_cost()); + let t5_sload = + t5_gas_params.warm_storage_read_cost() + t5_gas_params.cold_storage_additional_cost(); + let t5_sstore_state = + t5_gas_params.get(revm::context_interface::cfg::GasId::sstore_set_state_gas()); + let base_t5_key_auth = create_key_auth(0); + let mut nonce_t5_key_auth = create_key_auth(0); + nonce_t5_key_auth.authorization = nonce_t5_key_auth + .authorization + .with_nonce(B256::repeat_byte(0x53)); + + let (base_t5_gas, base_t5_state_gas) = + calculate_key_authorization_gas(&base_t5_key_auth, &t5_gas_params, TempoHardfork::T5); + let (nonce_t5_gas, nonce_t5_state_gas) = + calculate_key_authorization_gas(&nonce_t5_key_auth, &t5_gas_params, TempoHardfork::T5); + + assert_eq!( + nonce_t5_gas - base_t5_gas, + t5_sload + t5_sstore + t5_sstore_state, + "T5 nonce adds one consumed-nonce SLOAD and one consumed-nonce SSTORE" + ); + assert_eq!( + nonce_t5_state_gas - base_t5_state_gas, + t5_sstore_state, + "T5 nonce adds state gas for one consumed-nonce SSTORE" + ); + let scoped = SignedKeyAuthorization { authorization: KeyAuthorization::unrestricted( 1, @@ -4702,6 +4764,62 @@ mod tests { assert_eq!(evm.key_expiry, Some(expiry)); } + #[test] + fn test_key_authorization_nonce_rejected_before_t5() { + let (signer, user) = generate_keypair(); + let key = Address::random(); + let signed = sign_key_auth( + &signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, key) + .with_nonce(B256::repeat_byte(0x53)), + ); + let (mut evm, h) = make_evm(user, key, Some(signed), TempoHardfork::T4, None, false); + + let result = h.validate_env(&mut evm); + assert!( + matches!( + &result, + Err(EVMError::Transaction(TempoInvalidTransaction::KeychainValidationFailed { reason })) + if reason.contains("before T5") + ), + "nonce-bearing key authorization should be rejected before T5, got: {result:?}" + ); + } + + #[test] + fn test_t5_key_authorization_consumes_nonce_in_state() { + use tempo_precompiles::account_keychain::isKeyAuthorizationNonceUsedCall; + + let (signer, user) = generate_keypair(); + let key = Address::random(); + let nonce = B256::repeat_byte(0x54); + let signed = sign_key_auth( + &signer, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, key).with_nonce(nonce), + ); + let (mut evm, h) = make_evm(user, key, Some(signed), TempoHardfork::T5, None, false); + + let result = + h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); + assert!( + result.is_ok(), + "T5 nonce authorization should pass: {result:?}" + ); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, || { + let keychain = AccountKeychain::new(); + assert!( + keychain + .is_key_authorization_nonce_used(isKeyAuthorizationNonceUsedCall { + account: user, + nonce, + }) + .expect("nonce read succeeds"), + "T5 key authorization must consume its nonce" + ); + }); + } + #[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 6cfe2563a3..09c74c2d7a 100644 --- a/crates/transaction-pool/src/maintain.rs +++ b/crates/transaction-pool/src/maintain.rs @@ -8,7 +8,7 @@ use crate::{ }; use alloy_consensus::transaction::TxHashRef; use alloy_primitives::{ - Address, TxHash, + Address, B256, TxHash, map::{AddressMap, HashMap, HashSet}, }; use alloy_sol_types::SolEvent; @@ -85,6 +85,11 @@ pub struct TempoPoolUpdates { /// with the runtime's actual spending-limit decrements instead of inferring them from /// the mined transaction body. pub spending_limit_spends: SpendingLimitUpdates, + /// TIP-1053 key-authorization nonce consumptions. + /// + /// Pending AA transactions carrying the same `(account, nonce)` key authorization are no + /// longer executable once either a sibling authorization or an explicit burn consumes it. + pub key_authorization_nonce_consumptions: AddressMap>, } impl TempoPoolUpdates { @@ -106,6 +111,7 @@ impl TempoPoolUpdates { && self.transfer_policy_updates.is_empty() && self.fee_balance_changes.is_empty() && self.spending_limit_spends.is_empty() + && self.key_authorization_nonce_consumptions.is_empty() } /// Extracts pool updates from a committed chain segment. @@ -139,6 +145,14 @@ impl TempoPoolUpdates { event.publicKey, Some(event.token), ); + } else if let Ok(event) = + IAccountKeychain::KeyAuthorizationNonceConsumed::decode_log(log) + { + updates + .key_authorization_nonce_consumptions + .entry(event.account) + .or_default() + .insert(event.nonce); } } // Validator and user token changes @@ -196,6 +210,7 @@ impl TempoPoolUpdates { || !self.blacklist_additions.is_empty() || !self.whitelist_removals.is_empty() || !self.fee_balance_changes.is_empty() + || !self.key_authorization_nonce_consumptions.is_empty() } } @@ -560,11 +575,13 @@ where if !updates.revoked_keys.is_empty() || !updates.spending_limit_changes.is_empty() || !updates.spending_limit_spends.is_empty() + || !updates.key_authorization_nonce_consumptions.is_empty() { state.paused_pool.evict_invalidated( &updates.revoked_keys, &updates.spending_limit_changes, &updates.spending_limit_spends, + &updates.key_authorization_nonce_consumptions, ); } metrics.pause_events_duration_seconds.record(pause_start.elapsed()); @@ -910,6 +927,38 @@ mod tests { assert_eq!(updates.spending_limit_spends.len(), 1); } + #[test] + fn extracts_key_authorization_nonce_consumed_events() { + let account = Address::random(); + let nonce = B256::random(); + + let log = alloy_primitives::Log::new_from_event_unchecked( + ACCOUNT_KEYCHAIN_ADDRESS, + IAccountKeychain::KeyAuthorizationNonceConsumed { account, nonce }, + ) + .reserialize(); + let receipt = tempo_primitives::TempoReceipt { + tx_type: tempo_primitives::TempoTxType::AA, + success: true, + cumulative_gas_used: 1, + logs: vec![log], + }; + + let block = create_block_with_txs(1, vec![], vec![]); + let chain = create_test_chain_with_receipts(vec![block], vec![vec![receipt]]); + + let updates = TempoPoolUpdates::from_chain(&chain); + + assert!( + updates + .key_authorization_nonce_consumptions + .get(&account) + .is_some_and(|nonces| nonces.contains(&nonce)), + "Should contain the consumed (account, nonce)" + ); + assert!(updates.has_invalidation_events()); + } + /// The pool should only track actual AccessKeySpend events, not infer spends from the /// mined transaction body. #[test] diff --git a/crates/transaction-pool/src/paused.rs b/crates/transaction-pool/src/paused.rs index fa0d3d0f9b..dd77ba9754 100644 --- a/crates/transaction-pool/src/paused.rs +++ b/crates/transaction-pool/src/paused.rs @@ -6,7 +6,10 @@ //! and re-validated. use crate::{RevokedKeys, SpendingLimitUpdates, transaction::TempoPooledTransaction}; -use alloy_primitives::{Address, TxHash, map::HashMap}; +use alloy_primitives::{ + Address, B256, TxHash, + map::{AddressMap, HashMap, HashSet}, +}; use reth_transaction_pool::{PoolTransaction, ValidPoolTransaction}; use std::{sync::Arc, time::Instant}; @@ -203,10 +206,12 @@ impl PausedFeeTokenPool { revoked_keys: &RevokedKeys, spending_limit_updates: &SpendingLimitUpdates, spending_limit_spends: &SpendingLimitUpdates, + key_authorization_nonce_consumptions: &AddressMap>, ) -> usize { if revoked_keys.is_empty() && spending_limit_updates.is_empty() && spending_limit_spends.is_empty() + && key_authorization_nonce_consumptions.is_empty() { return 0; } @@ -215,29 +220,38 @@ impl PausedFeeTokenPool { 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 { + if let Some(subject) = entry.tx.transaction.keychain_subject() { + let matches_limit_update = + subject.matches_spending_limit_update(spending_limit_updates); + let matches_limit_spend = + subject.matches_spending_limit_update(spending_limit_spends); + let sender_paid = if matches_limit_update || matches_limit_spend { + let sender = *entry.tx.transaction.sender_ref(); + entry + .tx + .transaction + .inner() + .fee_payer(sender) + .map_or(true, |fee_payer| fee_payer == sender) + } else { + false + }; + + if subject.matches_revoked(revoked_keys) + || (sender_paid && (matches_limit_update || matches_limit_spend)) + { + return false; + } + } + + let Some(nonce_subject) = entry.tx.transaction.key_authorization_nonce_subject() + else { return true; }; - let matches_limit_update = - subject.matches_spending_limit_update(spending_limit_updates); - let matches_limit_spend = - subject.matches_spending_limit_update(spending_limit_spends); - let sender_paid = if matches_limit_update || matches_limit_spend { - let sender = *entry.tx.transaction.sender_ref(); - entry - .tx - .transaction - .inner() - .fee_payer(sender) - .map_or(true, |fee_payer| fee_payer == sender) - } else { - false - }; - - let invalidated = subject.matches_revoked(revoked_keys) - || (sender_paid && (matches_limit_update || matches_limit_spend)); - !invalidated + !key_authorization_nonce_consumptions + .get(&nonce_subject.account) + .is_some_and(|nonces| nonces.contains(&nonce_subject.nonce)) }); count += before - meta.entries.len(); } @@ -441,8 +455,12 @@ mod tests { let mut spends = SpendingLimitUpdates::new(); spends.insert(user_address, key_id, Some(fee_token)); - let evicted = - pool.evict_invalidated(&RevokedKeys::new(), &SpendingLimitUpdates::new(), &spends); + let evicted = pool.evict_invalidated( + &RevokedKeys::new(), + &SpendingLimitUpdates::new(), + &spends, + &AddressMap::default(), + ); assert_eq!( evicted, 1, @@ -476,8 +494,12 @@ mod tests { let mut spends = SpendingLimitUpdates::new(); spends.insert(user_address, key_id, Some(fee_token)); - let evicted = - pool.evict_invalidated(&RevokedKeys::new(), &SpendingLimitUpdates::new(), &spends); + let evicted = pool.evict_invalidated( + &RevokedKeys::new(), + &SpendingLimitUpdates::new(), + &spends, + &AddressMap::default(), + ); assert_eq!(evicted, 0, "Sponsored keychain tx should not be evicted"); assert_eq!(pool.len(), 1); @@ -508,8 +530,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, &SpendingLimitUpdates::new()); + let evicted = pool.evict_invalidated( + &RevokedKeys::new(), + &updates, + &SpendingLimitUpdates::new(), + &AddressMap::default(), + ); assert_eq!(evicted, 0, "Sponsored keychain tx should not be evicted"); assert_eq!(pool.len(), 1); diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index 08fc60bbd6..a0558fa8fd 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -105,6 +105,8 @@ where /// changed for a token matching the transaction's fee token /// 2b. **Spending limit spends**: AA transactions whose remaining spending limit (re-read /// from state) is now insufficient after included keychain txs decremented it + /// 2c. **Key-authorization nonce consumptions**: AA transactions with a nonce-bearing + /// inline key authorization whose `(account, nonce)` has already been consumed /// 3. **Validator token changes**: Transactions that would fail due to insufficient /// liquidity in the new (user_token, validator_token) AMM pool /// 4. **Fee payer balance changes**: Transactions whose fee payer no longer has enough @@ -186,6 +188,7 @@ where let mut revoked_count = 0; let mut spending_limit_count = 0; let mut spending_limit_spend_count = 0; + let mut key_authorization_nonce_count = 0; let mut liquidity_count = 0; let mut user_token_count = 0; let mut blacklisted_count = 0; @@ -241,6 +244,19 @@ where continue; } + // Check 2c: TIP-1053 key-authorization nonce consumptions + if !updates.key_authorization_nonce_consumptions.is_empty() + && let Some(subject) = tx.transaction.key_authorization_nonce_subject() + && updates + .key_authorization_nonce_consumptions + .get(&subject.account) + .is_some_and(|nonces| nonces.contains(&subject.nonce)) + { + to_remove.push(*tx.hash()); + key_authorization_nonce_count += 1; + continue; + } + // Check 3: Validator token changes (re-check liquidity for all transactions) // Prevents mass eviction because it only: // - evicts when NO validator token has enough liquidity @@ -420,6 +436,7 @@ where revoked_count, spending_limit_count, spending_limit_spend_count, + key_authorization_nonce_count, liquidity_count, user_token_count, blacklisted_count, @@ -1275,7 +1292,7 @@ mod tests { use super::*; use crate::{test_utils::MockProviderStorageExt, transaction::KeychainSubject}; use alloy_consensus::Header; - use alloy_primitives::{U256, address, uint}; + use alloy_primitives::{Signature, U256, address, uint}; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; use reth_primitives_traits::Recovered; @@ -1298,7 +1315,12 @@ mod tests { tip20::slots as tip20_slots, tip403_registry::{CompoundPolicyData, PolicyData, TIP403Registry}, }; - use tempo_primitives::{Block, TempoHeader, TempoPrimitives, TempoTxEnvelope}; + use tempo_primitives::{ + Block, TempoHeader, TempoPrimitives, TempoTxEnvelope, + transaction::{ + KeyAuthorization, PrimitiveSignature, SignatureType, SignedKeyAuthorization, + }, + }; fn provider_with_spending_limit( account: Address, @@ -1478,6 +1500,96 @@ mod tests { assert!(pool.get(pooled.hash()).is_none()); } + #[tokio::test] + async fn evicts_transactions_with_consumed_key_authorization_nonce() { + let sender = Address::random(); + let consumed_nonce = B256::random(); + let other_nonce = B256::random(); + + let key_authorization = |nonce| SignedKeyAuthorization { + authorization: KeyAuthorization::unrestricted( + 42431, + SignatureType::Secp256k1, + Address::random(), + ) + .with_nonce(nonce), + signature: PrimitiveSignature::Secp256k1(Signature::test_signature()), + }; + + let matching = crate::test_utils::TxBuilder::aa(sender) + .nonce(0) + .key_authorization(key_authorization(consumed_nonce)) + .build(); + let untouched = crate::test_utils::TxBuilder::aa(sender) + .nonce(1) + .key_authorization(key_authorization(other_nonce)) + .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_nonce_consumptions + .entry(sender) + .or_default() + .insert(consumed_nonce); + + let evicted = pool.evict_invalidated_transactions(&updates); + assert_eq!(evicted, vec![*matching.hash()]); + assert!(pool.get(matching.hash()).is_none()); + assert!(pool.get(untouched.hash()).is_some()); + } + /// Eviction must match sub-policy IDs against compound policies. /// When a token uses a compound policy, and a sub-policy event fires, /// the eviction comparison must detect the match. diff --git a/crates/transaction-pool/src/test_utils.rs b/crates/transaction-pool/src/test_utils.rs index 636aa506f3..459164f979 100644 --- a/crates/transaction-pool/src/test_utils.rs +++ b/crates/transaction-pool/src/test_utils.rs @@ -17,7 +17,7 @@ use tempo_precompiles::storage::{StorageCtx, hashmap::HashMapStorageProvider}; use tempo_primitives::{ TempoPrimitives, TempoTxEnvelope, transaction::{ - TempoSignedAuthorization, TempoTransaction, + SignedKeyAuthorization, TempoSignedAuthorization, TempoTransaction, tempo_transaction::Call, tt_signature::{KeychainVersion, PrimitiveSignature, TempoSignature}, tt_signed::AASigned, @@ -61,6 +61,8 @@ pub(crate) struct TxBuilder { calls: Option>, /// Authorization list for AA transactions. authorization_list: Option>, + /// Inline key authorization for AA transactions. + key_authorization: Option, /// Access list for AA transactions. access_list: AccessList, } @@ -82,6 +84,7 @@ impl Default for TxBuilder { chain_id: 42431, // MODERATO chain_id calls: None, authorization_list: None, + key_authorization: None, access_list: Default::default(), } } @@ -174,6 +177,12 @@ impl TxBuilder { self } + /// Set the inline key authorization for the AA transaction. + pub(crate) fn key_authorization(mut self, key_authorization: SignedKeyAuthorization) -> Self { + self.key_authorization = Some(key_authorization); + self + } + /// Set the access list for the AA transaction. pub(crate) fn access_list(mut self, access_list: AccessList) -> Self { self.access_list = access_list; @@ -204,7 +213,7 @@ impl TxBuilder { valid_before: self.valid_before, access_list: self.access_list, tempo_authorization_list: self.authorization_list.unwrap_or_default(), - key_authorization: None, + key_authorization: self.key_authorization, }; let signature = @@ -260,7 +269,7 @@ impl TxBuilder { valid_before: self.valid_before, access_list: self.access_list, tempo_authorization_list: self.authorization_list.unwrap_or_default(), - key_authorization: None, + key_authorization: self.key_authorization, }; // Create a temp AASigned to get the signature hash diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 60a160085f..c88acbe651 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -164,6 +164,21 @@ impl TempoPooledTransaction { }) } + /// Extracts the TIP-1053 key-authorization nonce consumed by this transaction, if any. + pub fn key_authorization_nonce_subject(&self) -> Option { + let aa_tx = self.inner().as_aa()?; + let nonce = aa_tx + .tx() + .key_authorization + .as_ref()? + .authorization + .nonce()?; + Some(KeyAuthorizationNonceSubject { + account: *self.sender_ref(), + nonce, + }) + } + /// Returns the unique identifier for this AA transaction. pub(crate) fn aa_transaction_id(&self) -> Option { let nonce_key = self.nonce_key()?; @@ -1160,6 +1175,15 @@ pub struct KeychainSubject { pub fee_token: Address, } +/// Key-authorization nonce identity extracted from an AA transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct KeyAuthorizationNonceSubject { + /// The account whose key-authorization nonce is consumed. + pub account: Address, + /// The consumed TIP-1053 nonce. + pub nonce: B256, +} + impl KeychainSubject { /// Returns true if this subject matches any of the revoked keys. /// diff --git a/tips/verify/lib/tempo-std b/tips/verify/lib/tempo-std index ae53fadbdf..f4717ff15e 160000 --- a/tips/verify/lib/tempo-std +++ b/tips/verify/lib/tempo-std @@ -1 +1 @@ -Subproject commit ae53fadbdf140b808ea58115882938cc3372009d +Subproject commit f4717ff15e709e739d2d1e27ffa9e176b683cf27