From 12c2d0964ded11b0875dc68e6e553609b0a2866d Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 7 May 2026 17:32:01 +0530 Subject: [PATCH 1/8] docs(tip-1053): simplify nonce presence semantics Amp-Thread-ID: https://ampcode.com/threads/T-019dfd59-0a01-77c6-9733-3288f271ec23 --- tips/tip-1053.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tips/tip-1053.md b/tips/tip-1053.md index 49ecdf9c95..da0cbfc7df 100644 --- a/tips/tip-1053.md +++ b/tips/tip-1053.md @@ -33,7 +33,7 @@ The protocol stays minimal: it does not learn about sign-in semantics, applicati - The offchain verifier (e.g. app server) is responsible for replay protection of its own session flow. The protocol's nonce uniqueness check protects the onchain registration of the **key authorization**; an offchain verifier still needs to bind its session to the specific nonce it issued. - The `nonce` format is application-defined when used as a challenge digest. The protocol does not validate its structure. -- This TIP is purely additive to `key_authorization`. Existing authorizations (without the field) continue to validate identically. The field defaults to absent / zero, in which case the resulting hash is byte-equivalent to pre-TIP-1053 encoding for that authorization and no nonce tracking is performed. +- This TIP is purely additive to `key_authorization`. Existing authorizations (without the field) continue to validate identically. When the field is absent, the resulting hash is byte-equivalent to pre-TIP-1053 encoding for that authorization and no nonce tracking is performed. - Nonce-bearing authorizations gain an additional uniqueness guarantee at the `(account, nonce)` granularity. --- @@ -52,13 +52,13 @@ key_authorization = rlp([ expiry?, limits?, allowed_calls?, - nonce?, // NEW — 32 bytes, default zero/absent + nonce?, // NEW — 32 bytes ]) ``` - Encoding is RLP, identical to the existing payload format with one optional trailing item. - `nonce` MUST be exactly 32 bytes when present. -- A `nonce` of `bytes32(0)` is reserved to mean "no nonce" and MUST be omitted from the RLP encoding rather than encoded as 32 zero bytes. This guarantees byte-equivalence with pre-TIP-1053 encoding for nonce-less authorizations. +- Absence of the trailing field means "no nonce". If the field is present, its exact `bytes32` value is the nonce, including `bytes32(0)`. ## Signing @@ -75,13 +75,13 @@ No additional domain separation is introduced. RLP boundaries already disambigua The protocol: - MUST include `nonce` in the signing hash whenever it is present in the encoded `key_authorization`. -- MUST enforce that, for any account `A`, a given non-zero `nonce` is consumed at most once. An attempt to register a `key_authorization` whose `(account, nonce)` pair has already been consumed MUST revert. +- MUST enforce that, for any account `A`, a present `nonce` is consumed at most once. An attempt to register a `key_authorization` whose `(account, nonce)` pair has already been consumed MUST revert. - MUST mark `(account, nonce)` as consumed atomically with the successful registration of the authorization. - MUST NOT enforce ordering, monotonicity, or any other structural constraint on `nonce`. Nonces may be arbitrary 32-byte values chosen by the application. - MUST NOT interpret the `nonce` value beyond uniqueness tracking. - MUST NOT emit `nonce` in any event by default beyond what is needed to expose consumption. -A nonce-less authorization (field absent / `bytes32(0)`) is processed exactly as in pre-TIP-1053 behavior: no uniqueness check is performed, no state is written for nonce tracking, and the existing `keys[account][key_id]` permanence is the sole replay guard. +A nonce-less authorization (field absent) is processed exactly as in pre-TIP-1053 behavior: no uniqueness check is performed, no state is written for nonce tracking, and the existing `keys[account][key_id]` permanence is the sole replay guard. The field is invisible to existing precompile read paths that surface `KeyInfo`. A new read path MAY be added to query consumption status of a given `(account, nonce)` pair. @@ -123,14 +123,14 @@ The `nonce` format is application-defined when used as a challenge digest. The p ## Backward Compatibility -- Existing key authorizations without the field continue to validate. The encoder MUST omit the trailing field when `nonce == bytes32(0)`, producing a byte-equivalent encoding to pre-TIP-1053 payloads. -- Legacy verifiers that don't understand the field will compute the same hash for legacy-shaped authorizations and will reject (signature mismatch) for authorizations with a non-zero nonce, which is the correct fail-safe. -- Nonce uniqueness tracking is only engaged for non-zero nonces; legacy nonce-less authorizations incur no additional state. +- Existing key authorizations without the field continue to validate and produce byte-equivalent encodings to pre-TIP-1053 payloads. +- Legacy verifiers that don't understand the field will compute the same hash for legacy-shaped authorizations and will reject (signature mismatch) for authorizations with a present nonce, which is the correct fail-safe. +- Nonce uniqueness tracking is only engaged when the nonce field is present; legacy nonce-less authorizations incur no additional state. # Invariants 1. **Hash inclusion.** When `nonce` is present in the encoded `key_authorization`, it MUST be part of the signing hash. -2. **Encoding determinism.** A `nonce` of `bytes32(0)` MUST be omitted from the RLP encoding, never encoded as 32 zero bytes. +2. **Encoding determinism.** An absent `nonce` field MUST be omitted from the RLP encoding. A present `nonce` field MUST encode its exact 32-byte value. -3. **Single-use.** For any account `A` and any non-zero `nonce` value `n`, the protocol MUST accept at most one successful `key_authorization` registration or burn for `(A, n)`. +3. **Single-use.** For any account `A` and any present `nonce` value `n`, the protocol MUST accept at most one successful `key_authorization` registration or burn for `(A, n)`. From e43cbe6d20259403b6656bae88d1ae94aa2424d7 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 6 May 2026 20:07:31 +0530 Subject: [PATCH 2/8] feat(account-keychain): implement TIP-1053 nonces Amp-Thread-ID: https://ampcode.com/threads/T-019dfd59-0a01-77c6-9733-3288f271ec23 --- .../src/precompiles/account_keychain.rs | 34 ++- .../src/account_keychain/dispatch.rs | 79 ++++- .../precompiles/src/account_keychain/mod.rs | 268 ++++++++++++++++- crates/primitives/src/transaction/envelope.rs | 1 + .../src/transaction/key_authorization.rs | 270 +++++++++++++++++- .../src/transaction/tempo_transaction.rs | 1 + crates/revm/src/handler.rs | 142 ++++++++- 7 files changed, 761 insertions(+), 34 deletions(-) diff --git a/crates/contracts/src/precompiles/account_keychain.rs b/crates/contracts/src/precompiles/account_keychain.rs index 60df7fef3c..733e1832a7 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! { @@ -111,6 +111,19 @@ crate::sol! { KeyRestrictions calldata config ) external; + /// Authorize a new key with a TIP-1053 replay nonce. + /// @dev The nonce must be non-zero and unused for the caller's account. + 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 +189,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 +210,8 @@ crate::sol! { error SignatureTypeMismatch(uint8 expected, uint8 actual); error CallNotAllowed(); error InvalidCallScope(); + error InvalidKeyAuthorizationNonce(); + error KeyAuthorizationNonceAlreadyUsed(); error LegacyAuthorizeKeySelectorChanged(bytes4 newSelector); } } @@ -266,6 +284,18 @@ impl AccountKeychainError { Self::InvalidCallScope(IAccountKeychain::InvalidCallScope {}) } + /// Creates an error for a zero or otherwise invalid TIP-1053 nonce. + 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..71959b3752 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,52 @@ 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 non-zero 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 let Some(nonce) = nonce { + if nonce == B256::ZERO || !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 +259,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 +270,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 +285,19 @@ impl AccountKeychain { if !seen_tokens.insert(limit.token) { return Err(AccountKeychainError::invalid_spending_limit().into()); } + + if has_nonce { + Self::t3_spending_limit_cap(limit.amount)?; + } } } if config.allowAnyCalls { None } else { + if has_nonce { + self.validate_call_scopes(&config.allowedCalls)?; + } Some(config.allowedCalls.as_slice()) } } else { @@ -263,6 +312,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 +324,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 +334,7 @@ impl AccountKeychain { self.apply_key_authorization_restrictions( msg_sender, - call.keyId, + key_id, limits, allowed_call_configs, )?; @@ -290,13 +343,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 +650,18 @@ 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 { + if call.nonce == B256::ZERO { + return Ok(false); + } + + 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 +1056,33 @@ 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 nonce == B256::ZERO { + return Err(AccountKeychainError::invalid_key_authorization_nonce().into()); + } + + 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) + } + /// Load and validate a key exists, is not revoked, and is not expired. /// /// Returns the key if valid, or an error if: @@ -1379,6 +1485,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::repeat_byte(0x53); + + 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..d8f45e64c3 100644 --- a/crates/primitives/src/transaction/key_authorization.rs +++ b/crates/primitives/src/transaction/key_authorization.rs @@ -165,14 +165,15 @@ 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 -#[derive(Clone, Debug, PartialEq, Eq, Hash, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable)] -#[rlp(trailing(canonical))] +/// - `nonce`: `None` (canonically omitted) = no TIP-1053 uniqueness tracking, +/// `Some(non_zero_bytes32)` = arbitrary nonce consumed on successful T5+ authorization +#[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))] @@ -208,6 +209,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(B256::ZERO)` is normalized to `None` by constructors and + /// encoding so zero-nonce authorizations remain byte-identical to pre-TIP-1053 payloads. + pub nonce: Option, } impl KeyAuthorization { @@ -221,6 +228,7 @@ impl KeyAuthorization { expiry: None, limits: None, allowed_calls: None, + nonce: None, } } @@ -254,6 +262,19 @@ impl KeyAuthorization { self } + /// Attach a TIP-1053 nonce to this authorization. + /// + /// A zero nonce is the protocol's no-nonce sentinel and is therefore normalized to `None`. + pub fn with_nonce(mut self, nonce: B256) -> Self { + self.nonce = (nonce != B256::ZERO).then_some(nonce); + self + } + + /// Returns this authorization's non-zero TIP-1053 nonce, if present. + pub fn nonce(&self) -> Option { + self.normalized_nonce() + } + /// Computes the authorization message hash for this key authorization. pub fn signature_hash(&self) -> B256 { let mut buf = Vec::new(); @@ -273,6 +294,11 @@ impl KeyAuthorization { self.allowed_calls.is_some() } + /// Returns whether this authorization carries a non-zero TIP-1053 nonce. + pub fn has_nonce(&self) -> bool { + self.normalized_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 +311,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. @@ -333,6 +359,174 @@ impl KeyAuthorization { + scopes.iter().map(CallScope::heap_size).sum::() }) } + + fn normalized_nonce(&self) -> Option { + self.nonce.filter(|nonce| *nonce != B256::ZERO) + } +} + +impl Encodable for KeyAuthorization { + fn encode(&self, out: &mut dyn alloy_rlp::BufMut) { + alloy_rlp::Header { + list: true, + payload_length: self.payload_length(), + } + .encode(out); + + self.chain_id.encode(out); + self.key_type.encode(out); + self.key_id.encode(out); + + let nonce = self.normalized_nonce(); + let include_allowed_calls = self.allowed_calls.is_some() || nonce.is_some(); + let include_limits = self.limits.is_some() || include_allowed_calls; + let include_expiry = self.expiry.is_some() || include_limits; + + if include_expiry { + encode_optional(&self.expiry, out); + } + + if include_limits { + encode_optional(&self.limits, out); + } + + if include_allowed_calls { + encode_optional(&self.allowed_calls, out); + } + + if let Some(nonce) = nonce { + nonce.encode(out); + } + } + + fn length(&self) -> usize { + alloy_rlp::Header { + list: true, + payload_length: self.payload_length(), + } + .length() + + self.payload_length() + } +} + +impl KeyAuthorization { + fn payload_length(&self) -> usize { + let nonce = self.normalized_nonce(); + let include_allowed_calls = self.allowed_calls.is_some() || nonce.is_some(); + let include_limits = self.limits.is_some() || include_allowed_calls; + let include_expiry = self.expiry.is_some() || include_limits; + + let mut len = self.chain_id.length() + self.key_type.length() + self.key_id.length(); + + if include_expiry { + len += optional_length(&self.expiry); + } + + if include_limits { + len += optional_length(&self.limits); + } + + if include_allowed_calls { + len += optional_length(&self.allowed_calls); + } + + if let Some(nonce) = nonce { + len += nonce.length(); + } + + len + } +} + +impl alloy_rlp::Decodable for KeyAuthorization { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + let header = alloy_rlp::Header::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + + if header.payload_length > buf.len() { + return Err(alloy_rlp::Error::InputTooShort); + } + + let (mut payload, rest) = buf.split_at(header.payload_length); + *buf = rest; + + let chain_id = alloy_rlp::Decodable::decode(&mut payload)?; + let key_type = alloy_rlp::Decodable::decode(&mut payload)?; + let key_id = alloy_rlp::Decodable::decode(&mut payload)?; + + let expiry = decode_optional_canonical(&mut payload, "expiry")?; + + let limits = decode_optional_canonical(&mut payload, "limits")?; + + let allowed_calls = decode_optional_canonical(&mut payload, "allowed_calls")?; + + let nonce = if payload.is_empty() { + None + } else { + if payload.first() == Some(&alloy_rlp::EMPTY_STRING_CODE) { + return Err(alloy_rlp::Error::Custom( + "key authorization nonce must be omitted when absent", + )); + } + + let nonce = ::decode(&mut payload)?; + + if nonce == B256::ZERO { + return Err(alloy_rlp::Error::Custom( + "zero key authorization nonce must be omitted", + )); + } + + Some(nonce) + }; + + if !payload.is_empty() { + return Err(alloy_rlp::Error::UnexpectedLength); + } + + Ok(Self { + chain_id, + key_type, + key_id, + expiry, + limits, + allowed_calls, + nonce, + }) + } +} + +fn encode_optional(value: &Option, out: &mut dyn alloy_rlp::BufMut) { + if let Some(value) = value.as_ref() { + value.encode(out); + } else { + out.put_u8(alloy_rlp::EMPTY_STRING_CODE); + } +} + +fn optional_length(value: &Option) -> usize { + value.as_ref().map_or(1, Encodable::length) +} + +fn decode_optional_canonical( + payload: &mut &[u8], + field: &'static str, +) -> alloy_rlp::Result> { + if payload.is_empty() { + return Ok(None); + } + + if payload.first() == Some(&alloy_rlp::EMPTY_STRING_CODE) { + if payload.len() == 1 { + return Err(alloy_rlp::Error::Custom(field)); + } + *payload = &payload[1..]; + Ok(None) + } else { + Ok(Some(T::decode(payload)?)) + } } /// Error returned when a [`KeyAuthorization`]'s `chain_id` does not match the expected value. @@ -392,6 +586,10 @@ impl<'a> arbitrary::Arbitrary<'a> for KeyAuthorization { expiry: u.arbitrary()?, limits: u.arbitrary()?, allowed_calls: u.arbitrary()?, + nonce: u + .arbitrary::>()? + .map(B256::from) + .filter(|nonce| *nonce != B256::ZERO), }) } } @@ -532,7 +730,70 @@ mod tests { expiry: expiry.and_then(NonZeroU64::new), limits, allowed_calls: None, + nonce: None, + } + } + + #[test] + fn test_zero_nonce_is_canonical_no_nonce_encoding() { + let auth = make_auth(None, None); + let zero_nonce_auth = auth.clone().with_nonce(B256::ZERO); + + let mut encoded = Vec::new(); + auth.encode(&mut encoded); + + let mut zero_encoded = Vec::new(); + zero_nonce_auth.encode(&mut zero_encoded); + + assert_eq!(zero_nonce_auth.nonce(), None); + assert_eq!(zero_encoded, encoded); + assert_eq!(zero_nonce_auth.signature_hash(), auth.signature_hash()); + } + + #[test] + fn test_nonzero_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_decode_rejects_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); + + ::decode(&mut encoded.as_slice()) + .expect_err("explicit zero nonce must be rejected"); } #[test] @@ -644,6 +905,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( From 6abee5c4375aca64370c6731548645494deec3ba Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 6 May 2026 20:16:12 +0530 Subject: [PATCH 3/8] refactor(primitives): derive key authorization RLP wire format Amp-Thread-ID: https://ampcode.com/threads/T-019dfd59-0a01-77c6-9733-3288f271ec23 --- .../src/transaction/key_authorization.rs | 259 +++++++----------- 1 file changed, 95 insertions(+), 164 deletions(-) diff --git a/crates/primitives/src/transaction/key_authorization.rs b/crates/primitives/src/transaction/key_authorization.rs index d8f45e64c3..cd5d6bb422 100644 --- a/crates/primitives/src/transaction/key_authorization.rs +++ b/crates/primitives/src/transaction/key_authorization.rs @@ -365,170 +365,6 @@ impl KeyAuthorization { } } -impl Encodable for KeyAuthorization { - fn encode(&self, out: &mut dyn alloy_rlp::BufMut) { - alloy_rlp::Header { - list: true, - payload_length: self.payload_length(), - } - .encode(out); - - self.chain_id.encode(out); - self.key_type.encode(out); - self.key_id.encode(out); - - let nonce = self.normalized_nonce(); - let include_allowed_calls = self.allowed_calls.is_some() || nonce.is_some(); - let include_limits = self.limits.is_some() || include_allowed_calls; - let include_expiry = self.expiry.is_some() || include_limits; - - if include_expiry { - encode_optional(&self.expiry, out); - } - - if include_limits { - encode_optional(&self.limits, out); - } - - if include_allowed_calls { - encode_optional(&self.allowed_calls, out); - } - - if let Some(nonce) = nonce { - nonce.encode(out); - } - } - - fn length(&self) -> usize { - alloy_rlp::Header { - list: true, - payload_length: self.payload_length(), - } - .length() - + self.payload_length() - } -} - -impl KeyAuthorization { - fn payload_length(&self) -> usize { - let nonce = self.normalized_nonce(); - let include_allowed_calls = self.allowed_calls.is_some() || nonce.is_some(); - let include_limits = self.limits.is_some() || include_allowed_calls; - let include_expiry = self.expiry.is_some() || include_limits; - - let mut len = self.chain_id.length() + self.key_type.length() + self.key_id.length(); - - if include_expiry { - len += optional_length(&self.expiry); - } - - if include_limits { - len += optional_length(&self.limits); - } - - if include_allowed_calls { - len += optional_length(&self.allowed_calls); - } - - if let Some(nonce) = nonce { - len += nonce.length(); - } - - len - } -} - -impl alloy_rlp::Decodable for KeyAuthorization { - fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { - let header = alloy_rlp::Header::decode(buf)?; - if !header.list { - return Err(alloy_rlp::Error::UnexpectedString); - } - - if header.payload_length > buf.len() { - return Err(alloy_rlp::Error::InputTooShort); - } - - let (mut payload, rest) = buf.split_at(header.payload_length); - *buf = rest; - - let chain_id = alloy_rlp::Decodable::decode(&mut payload)?; - let key_type = alloy_rlp::Decodable::decode(&mut payload)?; - let key_id = alloy_rlp::Decodable::decode(&mut payload)?; - - let expiry = decode_optional_canonical(&mut payload, "expiry")?; - - let limits = decode_optional_canonical(&mut payload, "limits")?; - - let allowed_calls = decode_optional_canonical(&mut payload, "allowed_calls")?; - - let nonce = if payload.is_empty() { - None - } else { - if payload.first() == Some(&alloy_rlp::EMPTY_STRING_CODE) { - return Err(alloy_rlp::Error::Custom( - "key authorization nonce must be omitted when absent", - )); - } - - let nonce = ::decode(&mut payload)?; - - if nonce == B256::ZERO { - return Err(alloy_rlp::Error::Custom( - "zero key authorization nonce must be omitted", - )); - } - - Some(nonce) - }; - - if !payload.is_empty() { - return Err(alloy_rlp::Error::UnexpectedLength); - } - - Ok(Self { - chain_id, - key_type, - key_id, - expiry, - limits, - allowed_calls, - nonce, - }) - } -} - -fn encode_optional(value: &Option, out: &mut dyn alloy_rlp::BufMut) { - if let Some(value) = value.as_ref() { - value.encode(out); - } else { - out.put_u8(alloy_rlp::EMPTY_STRING_CODE); - } -} - -fn optional_length(value: &Option) -> usize { - value.as_ref().map_or(1, Encodable::length) -} - -fn decode_optional_canonical( - payload: &mut &[u8], - field: &'static str, -) -> alloy_rlp::Result> { - if payload.is_empty() { - return Ok(None); - } - - if payload.first() == Some(&alloy_rlp::EMPTY_STRING_CODE) { - if payload.len() == 1 { - return Err(alloy_rlp::Error::Custom(field)); - } - *payload = &payload[1..]; - Ok(None) - } else { - Ok(Some(T::decode(payload)?)) - } -} - /// Error returned when a [`KeyAuthorization`]'s `chain_id` does not match the expected value. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct KeyAuthorizationChainIdError { @@ -627,6 +463,101 @@ mod rlp { use super::*; use alloy_rlp::{Decodable, Encodable}; + /// RLP-only wrapper for TIP-1053's nonce semantics. Deriving directly on `Option` + /// would encode `Some(B256::ZERO)` as an explicit zero nonce, but the wire format reserves + /// zero as the absent sentinel and requires it to be omitted. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + struct KeyAuthorizationNonce(B256); + + impl Encodable for KeyAuthorizationNonce { + fn encode(&self, out: &mut dyn alloy_rlp::BufMut) { + self.0.encode(out) + } + + fn length(&self) -> usize { + self.0.length() + } + } + + impl Decodable for KeyAuthorizationNonce { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + let nonce = B256::decode(buf)?; + if nonce == B256::ZERO { + return Err(alloy_rlp::Error::Custom( + "zero key authorization nonce must be omitted", + )); + } + Ok(Self(nonce)) + } + } + + #[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>, + nonce: Option, + } + + impl From<&KeyAuthorization> for KeyAuthorizationWire { + fn from(value: &KeyAuthorization) -> Self { + let KeyAuthorization { + chain_id, + key_type, + key_id, + expiry, + limits, + allowed_calls, + nonce: _, + } = value; + Self { + chain_id: *chain_id, + key_type: *key_type, + key_id: *key_id, + expiry: *expiry, + limits: limits.clone(), + allowed_calls: allowed_calls.clone(), + nonce: value.normalized_nonce().map(KeyAuthorizationNonce), + } + } + } + + impl From for KeyAuthorization { + fn from(value: KeyAuthorizationWire) -> Self { + 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, + nonce: value.nonce.map(|nonce| nonce.0), + } + } + } + + 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() + } + } + + impl Decodable for KeyAuthorization { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + Ok(KeyAuthorizationWire::decode(buf)?.into()) + } + } + #[derive( Clone, Debug, PartialEq, Eq, Hash, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable, )] From e6167ef778722b4ef631ee03611ff3fee0e83736 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 7 May 2026 16:40:16 +0530 Subject: [PATCH 4/8] test(primitives): pin TIP-1053 nonce RLP encoding Amp-Thread-ID: https://ampcode.com/threads/T-019dfd59-0a01-77c6-9733-3288f271ec23 --- .../precompiles/src/account_keychain/mod.rs | 4 ++ .../src/transaction/key_authorization.rs | 47 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 71959b3752..4d0282a5e4 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -286,6 +286,9 @@ impl AccountKeychain { 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)?; } @@ -295,6 +298,7 @@ impl AccountKeychain { if config.allowAnyCalls { None } else { + // See the nonce-only validation note above. if has_nonce { self.validate_call_scopes(&config.allowedCalls)?; } diff --git a/crates/primitives/src/transaction/key_authorization.rs b/crates/primitives/src/transaction/key_authorization.rs index cd5d6bb422..5d374da4f6 100644 --- a/crates/primitives/src/transaction/key_authorization.rs +++ b/crates/primitives/src/transaction/key_authorization.rs @@ -703,6 +703,33 @@ mod tests { assert_eq!(reencoded, encoded); } + #[test] + fn test_nonzero_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_rejects_explicit_zero_nonce() { let auth = make_auth(None, None); @@ -727,6 +754,26 @@ mod tests { .expect_err("explicit zero nonce must be rejected"); } + #[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(); From 34a663c6edf0c682da7625b58d93f5cdc4cca073 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 7 May 2026 17:19:59 +0530 Subject: [PATCH 5/8] refactor: simplify TIP-1053 nonce semantics Amp-Thread-ID: https://ampcode.com/threads/T-019dfd59-0a01-77c6-9733-3288f271ec23 --- .../src/precompiles/account_keychain.rs | 4 +- .../precompiles/src/account_keychain/mod.rs | 20 +-- .../src/transaction/key_authorization.rs | 155 ++++-------------- 3 files changed, 36 insertions(+), 143 deletions(-) diff --git a/crates/contracts/src/precompiles/account_keychain.rs b/crates/contracts/src/precompiles/account_keychain.rs index 733e1832a7..9523a6cc20 100644 --- a/crates/contracts/src/precompiles/account_keychain.rs +++ b/crates/contracts/src/precompiles/account_keychain.rs @@ -112,7 +112,7 @@ crate::sol! { ) external; /// Authorize a new key with a TIP-1053 replay nonce. - /// @dev The nonce must be non-zero and unused for the caller's account. + /// @dev The nonce must be unused for the caller's account. bytes32(0) is a valid nonce. function authorizeKey( address keyId, SignatureType signatureType, @@ -284,7 +284,7 @@ impl AccountKeychainError { Self::InvalidCallScope(IAccountKeychain::InvalidCallScope {}) } - /// Creates an error for a zero or otherwise invalid TIP-1053 nonce. + /// 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 {}) } diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 4d0282a5e4..9c8e67d2b0 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -210,8 +210,8 @@ impl AccountKeychain { ) } - /// Registers a new access key and consumes a non-zero TIP-1053 nonce atomically with the - /// successful authorization. + /// 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, @@ -238,10 +238,8 @@ impl AccountKeychain { self.ensure_admin_caller(msg_sender)?; let is_t3 = self.storage.spec().is_t3(); - if let Some(nonce) = nonce { - if nonce == B256::ZERO || !self.storage.spec().is_t5() { - return Err(AccountKeychainError::invalid_key_authorization_nonce().into()); - } + if nonce.is_some() && !self.storage.spec().is_t5() { + return Err(AccountKeychainError::invalid_key_authorization_nonce().into()); } let has_nonce = nonce.is_some(); @@ -659,10 +657,6 @@ impl AccountKeychain { &self, call: isKeyAuthorizationNonceUsedCall, ) -> Result { - if call.nonce == B256::ZERO { - return Ok(false); - } - self.key_authorization_nonces[call.account][call.nonce].read() } @@ -1076,10 +1070,6 @@ impl AccountKeychain { } fn consume_key_authorization_nonce(&mut self, account: Address, nonce: B256) -> Result<()> { - if nonce == B256::ZERO { - return Err(AccountKeychainError::invalid_key_authorization_nonce().into()); - } - if self.key_authorization_nonces[account][nonce].read()? { return Err(AccountKeychainError::key_authorization_nonce_already_used().into()); } @@ -1517,7 +1507,7 @@ mod tests { let account = Address::random(); let first_key = Address::random(); let second_key = Address::random(); - let nonce = B256::repeat_byte(0x53); + let nonce = B256::ZERO; StorageCtx::enter(&mut storage, || { let mut keychain = AccountKeychain::new(); diff --git a/crates/primitives/src/transaction/key_authorization.rs b/crates/primitives/src/transaction/key_authorization.rs index 5d374da4f6..3e8962b7eb 100644 --- a/crates/primitives/src/transaction/key_authorization.rs +++ b/crates/primitives/src/transaction/key_authorization.rs @@ -172,8 +172,9 @@ impl From for AbiSelectorRule { /// - `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(non_zero_bytes32)` = arbitrary nonce consumed on successful T5+ authorization -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +/// `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))] #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] #[cfg_attr(test, reth_codecs::add_arbitrary_tests(rlp))] @@ -212,8 +213,8 @@ pub struct KeyAuthorization { /// Optional TIP-1053 nonce for per-account uniqueness tracking. /// - /// `None` means no nonce. `Some(B256::ZERO)` is normalized to `None` by constructors and - /// encoding so zero-nonce authorizations remain byte-identical to pre-TIP-1053 payloads. + /// `None` means no nonce. `Some(nonce)` means the nonce field is present, including when + /// `nonce == B256::ZERO`. pub nonce: Option, } @@ -263,16 +264,14 @@ impl KeyAuthorization { } /// Attach a TIP-1053 nonce to this authorization. - /// - /// A zero nonce is the protocol's no-nonce sentinel and is therefore normalized to `None`. pub fn with_nonce(mut self, nonce: B256) -> Self { - self.nonce = (nonce != B256::ZERO).then_some(nonce); + self.nonce = Some(nonce); self } - /// Returns this authorization's non-zero TIP-1053 nonce, if present. + /// Returns this authorization's TIP-1053 nonce, if present. pub fn nonce(&self) -> Option { - self.normalized_nonce() + self.nonce } /// Computes the authorization message hash for this key authorization. @@ -294,9 +293,9 @@ impl KeyAuthorization { self.allowed_calls.is_some() } - /// Returns whether this authorization carries a non-zero TIP-1053 nonce. + /// Returns whether this authorization carries a TIP-1053 nonce field. pub fn has_nonce(&self) -> bool { - self.normalized_nonce().is_some() + self.nonce.is_some() } /// Returns whether this key has unlimited spending (limits is None) @@ -359,10 +358,6 @@ impl KeyAuthorization { + scopes.iter().map(CallScope::heap_size).sum::() }) } - - fn normalized_nonce(&self) -> Option { - self.nonce.filter(|nonce| *nonce != B256::ZERO) - } } /// Error returned when a [`KeyAuthorization`]'s `chain_id` does not match the expected value. @@ -422,10 +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) - .filter(|nonce| *nonce != B256::ZERO), + nonce: u.arbitrary::>()?.map(B256::from), }) } } @@ -463,101 +455,6 @@ mod rlp { use super::*; use alloy_rlp::{Decodable, Encodable}; - /// RLP-only wrapper for TIP-1053's nonce semantics. Deriving directly on `Option` - /// would encode `Some(B256::ZERO)` as an explicit zero nonce, but the wire format reserves - /// zero as the absent sentinel and requires it to be omitted. - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - struct KeyAuthorizationNonce(B256); - - impl Encodable for KeyAuthorizationNonce { - fn encode(&self, out: &mut dyn alloy_rlp::BufMut) { - self.0.encode(out) - } - - fn length(&self) -> usize { - self.0.length() - } - } - - impl Decodable for KeyAuthorizationNonce { - fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { - let nonce = B256::decode(buf)?; - if nonce == B256::ZERO { - return Err(alloy_rlp::Error::Custom( - "zero key authorization nonce must be omitted", - )); - } - Ok(Self(nonce)) - } - } - - #[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>, - nonce: Option, - } - - impl From<&KeyAuthorization> for KeyAuthorizationWire { - fn from(value: &KeyAuthorization) -> Self { - let KeyAuthorization { - chain_id, - key_type, - key_id, - expiry, - limits, - allowed_calls, - nonce: _, - } = value; - Self { - chain_id: *chain_id, - key_type: *key_type, - key_id: *key_id, - expiry: *expiry, - limits: limits.clone(), - allowed_calls: allowed_calls.clone(), - nonce: value.normalized_nonce().map(KeyAuthorizationNonce), - } - } - } - - impl From for KeyAuthorization { - fn from(value: KeyAuthorizationWire) -> Self { - 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, - nonce: value.nonce.map(|nonce| nonce.0), - } - } - } - - 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() - } - } - - impl Decodable for KeyAuthorization { - fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { - Ok(KeyAuthorizationWire::decode(buf)?.into()) - } - } - #[derive( Clone, Debug, PartialEq, Eq, Hash, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable, )] @@ -666,23 +563,28 @@ mod tests { } #[test] - fn test_zero_nonce_is_canonical_no_nonce_encoding() { + 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(); - auth.encode(&mut encoded); + zero_nonce_auth.encode(&mut encoded); - let mut zero_encoded = Vec::new(); - zero_nonce_auth.encode(&mut zero_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); - assert_eq!(zero_nonce_auth.nonce(), None); - assert_eq!(zero_encoded, encoded); - assert_eq!(zero_nonce_auth.signature_hash(), auth.signature_hash()); + let mut reencoded = Vec::new(); + decoded.encode(&mut reencoded); + assert_eq!(reencoded, encoded); } #[test] - fn test_nonzero_nonce_roundtrip_and_changes_signature_hash() { + 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); @@ -704,7 +606,7 @@ mod tests { } #[test] - fn test_nonzero_nonce_encoding_preserves_prior_absent_trailing_fields() { + fn test_nonce_encoding_preserves_prior_absent_trailing_fields() { let nonce = B256::repeat_byte(0x53); let auth = make_auth(None, None).with_nonce(nonce); @@ -731,7 +633,7 @@ mod tests { } #[test] - fn test_decode_rejects_explicit_zero_nonce() { + 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() @@ -750,8 +652,9 @@ mod tests { encoded.extend_from_slice(&[alloy_rlp::EMPTY_STRING_CODE; 3]); B256::ZERO.encode(&mut encoded); - ::decode(&mut encoded.as_slice()) - .expect_err("explicit zero nonce must be rejected"); + let decoded = + ::decode(&mut encoded.as_slice()).expect("decode auth"); + assert_eq!(decoded.nonce(), Some(B256::ZERO)); } #[test] From e3d525868f9d7ae14e68cc41d6b5aa598078c4c0 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 7 May 2026 19:35:32 +0530 Subject: [PATCH 6/8] fix(txpool): evict consumed key authorization nonces --- .../src/precompiles/account_keychain.rs | 3 + .../precompiles/src/account_keychain/mod.rs | 5 +- crates/transaction-pool/src/maintain.rs | 51 +++++++- crates/transaction-pool/src/paused.rs | 80 ++++++++---- crates/transaction-pool/src/tempo_pool.rs | 116 +++++++++++++++++- crates/transaction-pool/src/test_utils.rs | 15 ++- crates/transaction-pool/src/transaction.rs | 24 ++++ 7 files changed, 260 insertions(+), 34 deletions(-) diff --git a/crates/contracts/src/precompiles/account_keychain.rs b/crates/contracts/src/precompiles/account_keychain.rs index 9523a6cc20..c65c70fc2d 100644 --- a/crates/contracts/src/precompiles/account_keychain.rs +++ b/crates/contracts/src/precompiles/account_keychain.rs @@ -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, diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 9c8e67d2b0..fc73c072df 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -1074,7 +1074,10 @@ impl AccountKeychain { return Err(AccountKeychainError::key_authorization_nonce_already_used().into()); } - self.key_authorization_nonces[account][nonce].write(true) + 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. 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..266d004cfd 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.clone()).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. /// From 104e5a465eca13e178cc9b8bfcef292ec5b76689 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 7 May 2026 19:48:47 +0530 Subject: [PATCH 7/8] fix(txpool): satisfy clippy for nonce eviction --- crates/transaction-pool/src/tempo_pool.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index 266d004cfd..a0558fa8fd 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -105,7 +105,7 @@ 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 + /// 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 @@ -1547,7 +1547,7 @@ mod tests { .disable_balance_check() .build(InMemoryBlobStore::default()); let amm_cache = - AmmLiquidityCache::new(provider.clone()).expect("failed to setup AmmLiquidityCache"); + AmmLiquidityCache::new(provider).expect("failed to setup AmmLiquidityCache"); let validator = TempoTransactionValidator::new( inner, crate::validator::DEFAULT_AA_VALID_AFTER_MAX_SECS, From 7469c2ded0f830794302920a3c137ec44330679d Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 7 May 2026 19:55:50 +0530 Subject: [PATCH 8/8] chore(tip-1053): update tempo-std ABI --- tips/verify/lib/tempo-std | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/verify/lib/tempo-std b/tips/verify/lib/tempo-std index ae53fadbdf..f4717ff15e 160000 --- a/tips/verify/lib/tempo-std +++ b/tips/verify/lib/tempo-std @@ -1 +1 @@ -Subproject commit ae53fadbdf140b808ea58115882938cc3372009d +Subproject commit f4717ff15e709e739d2d1e27ffa9e176b683cf27