From bfd2b21e9ab728266588328a0be76f363083921e Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 4 May 2026 20:53:12 +0530 Subject: [PATCH] docs(tip-0001): add tempo transaction spec Amp-Thread-ID: https://ampcode.com/threads/T-019df38a-05dc-7359-a6c7-8224dc40c2df --- tips/tip-0001.md | 1299 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1299 insertions(+) create mode 100644 tips/tip-0001.md diff --git a/tips/tip-0001.md b/tips/tip-0001.md new file mode 100644 index 0000000000..414ecf7f6e --- /dev/null +++ b/tips/tip-0001.md @@ -0,0 +1,1299 @@ +--- +id: TIP-0001 +title: Tempo Transaction +description: Technical specification for the Tempo transaction type (EIP-2718) with WebAuthn signatures, parallelizable nonces, gas sponsorship, and batching. +authors: Tanishk Goyal (@legion2002), Jake Moxey (@jxom), Georgios Konstantopoulos (@gakonst), Arsenii Kulikov (@klkvr) +status: Mainnet +related: EIP-2718, TIP-1007, TIP-1009, TIP-1011, TIP-1020 +protocolVersion: T0 +--- + +# TIP-0001: Tempo Transaction + +## Abstract + +This spec introduces native protocol support for the following features, using a new Tempo transaction type: + +- WebAuthn/P256 signature validation - enables passkey accounts +- Parallelizable nonces - allows higher tx throughput for each account +- Gas sponsorship - allows apps to pay for their users' transactions +- Call batching - allows users to multicall efficiently and atomically +- Scheduled txs - allow users to specify a time window in which their tx can be executed +- Access keys - allow a sender's key to provision scoped access keys with spending limits + +This TIP is added retroactively. Tempo Transactions shipped at genesis, are live on mainnet, and this document copies the existing public transaction spec into the TIP repository. Later improvements and related transaction-specific work are specified in [TIP-1007](./tip-1007.md), [TIP-1009](./tip-1009.md), [TIP-1011](./tip-1011.md), and [TIP-1020](./tip-1020.md). + +## Motivation + +Current accounts are limited to secp256k1 signatures and sequential nonces, creating UX and scalability challenges. +Users cannot leverage modern authentication methods like passkeys, and applications face throughput limitations due to sequential nonces. + +## Specification + +### Transaction Type + +A new EIP-2718 transaction type is introduced with type byte `0x76`: + +```rust +pub struct TempoTransaction { + // Standard EIP-1559 fields + chain_id: ChainId, // EIP-155 replay protection + max_priority_fee_per_gas: u128, + max_fee_per_gas: u128, + gas_limit: u64, + calls: Vec, // Batch of calls to execute atomically + access_list: AccessList, // EIP-2930 access list + + // nonce-related fields + nonce_key: U256, // 2D nonce key (0 = protocol nonce, >0 = user nonces) + nonce: u64, // Current nonce value for the nonce key + + // Optional features + fee_token: Option
, // Optional fee token preference + fee_payer_signature: Option, // Sponsored transactions (secp256k1 only) + valid_before: Option, // Transaction expiration timestamp (seconds) + valid_after: Option, // Transaction can only be included after this timestamp (seconds) + key_authorization: Option, // Access key authorization (optional) + aa_authorization_list: Vec, // EIP-7702 style authorizations with AA signatures +} + +// Call structure for batching +pub struct Call { + to: TxKind, // Can be Address or Create + value: U256, + input: Bytes // Calldata for the call +} + +// Key authorization for provisioning access keys +// RLP encoding: [chain_id, key_type, key_id, expiry?, limits?, allowed_calls?] +pub struct KeyAuthorization { + chain_id: u64, // Chain ID for replay protection (0 = valid on any chain) + key_type: SignatureType, // Type of key: Secp256k1 (0), P256 (1), or WebAuthn (2) + key_id: Address, // Key identifier (address derived from public key) + expiry: Option, // Unix timestamp when key expires (omit / None for never expires) + limits: Option>, // TIP20 spending limits (None = unlimited spending) + allowed_calls: Option>, // Call-scope allowlist (None = unrestricted; Some(empty) = scoped deny-all) +} + +// Signed key authorization (authorization + root key signature) +pub struct SignedKeyAuthorization { + authorization: KeyAuthorization, + signature: PrimitiveSignature, // Root key's signature over keccak256(rlp(authorization)) +} + +// TIP20 spending limit for access keys +pub struct TokenLimit { + token: Address, // TIP20 token address + limit: U256, // Maximum spending amount for this token + period: u64, // Recurring period in seconds (0 = one-time, non-zero = recurring) +} + +// Call-scope allowlist entry: a target contract and its allowed selector rules +pub struct CallScope { + target: Address, // Target contract address + selector_rules: Vec, // Allowed selectors on that target (empty = any selector allowed) +} + +// Selector rule: a function selector and optional recipient allowlist (for recipient-bound TIP-20 selectors) +pub struct SelectorRule { + selector: [u8; 4], // 4-byte function selector + recipients: Vec
, // Allowed recipients (empty = any recipient) +} +``` + +### Signature Types + +Four signature schemes are supported. The signature type is determined by length and type identifier: + +#### secp256k1 (65 bytes) + +```rust +pub struct Signature { + r: B256, // 32 bytes + s: B256, // 32 bytes + v: u8 // 1 byte (recovery id) +} +``` + +**Format**: No type identifier prefix (backward compatible). Total length: 65 bytes. +**Detection**: Exactly 65 bytes with no type identifier. + +#### P256 (130 bytes) + +```rust +pub struct P256SignatureWithPreHash { + typeId: u8, // 0x01 + r: B256, // 32 bytes + s: B256, // 32 bytes + pub_key_x: B256, // 32 bytes + pub_key_y: B256, // 32 bytes + pre_hash: bool // 1 byte +} +``` + +**Format**: Type identifier `0x01` + 129 bytes of signature data. Total length: 130 bytes. The `typeId` is a wire format prefix (not a struct field) prepended during encoding. + +Note: Some P256 implementers (like Web Crypto) require the digests to be pre-hashed before verification. If `pre_hash` is set to `true`, then before verification: `digest = sha256(digest)`. + +#### WebAuthn (Variable length, max 2KB) + +```rust +pub struct WebAuthnSignature { + typeId: u8, // 0x02 + webauthn_data: Bytes, // Variable length (authenticatorData || clientDataJSON) + r: B256, // 32 bytes + s: B256, // 32 bytes + pub_key_x: B256, // 32 bytes + pub_key_y: B256 // 32 bytes +} +``` + +**Format**: Type identifier `0x02` + variable `webauthn_data` + 128 bytes (`r`, `s`, `pub_key_x`, `pub_key_y`). Total length: variable (minimum 129 bytes, maximum 2049 bytes). The `typeId` is a wire format prefix prepended during encoding. Parse by working backwards: last 128 bytes are `r`, `s`, `pub_key_x`, `pub_key_y`. + +#### Keychain (Variable length) + +```rust +pub struct KeychainSignature { + typeId: u8, // 0x03 + user_address: Address, // 20 bytes - root account address + signature: PrimitiveSignature // Inner signature (Secp256k1, P256, or WebAuthn) +} +``` + +**Format**: Type identifier `0x03` + `user_address` (20 bytes) + inner signature. The `typeId` is a wire format prefix prepended during encoding. +**Purpose**: Allows an access key to sign on behalf of a root account. The handler validates that `user_address` has authorized the access key in the AccountKeychain precompile. + +### Address Derivation + +#### secp256k1 + +```solidity +address(uint160(uint256(keccak256(abi.encode(x, y))))) +``` + +#### P256 and WebAuthn + +```solidity +function deriveAddressFromP256(bytes32 pubKeyX, bytes32 pubKeyY) public pure returns (address) { + // Hash + bytes32 hash = keccak256(abi.encodePacked( + pubKeyX, + pubKeyY + )); + + // Take last 20 bytes as address + return address(uint160(uint256(hash))); +} +``` + +### Tempo Authorization List + +The `aa_authorization_list` field enables EIP-7702 style delegation with support for all three AA signature types (secp256k1, P256, and WebAuthn), not just secp256k1. + +#### Structure + +```rust +pub struct TempoSignedAuthorization { + inner: Authorization, // Standard EIP-7702 authorization + signature: TempoSignature, // Can be Secp256k1, P256, or WebAuthn +} +``` + +Each authorization in the list: + +- Delegates an account to a specified implementation contract +- Is signed by the account's authority using any supported signature type +- Follows EIP-7702 semantics for delegation and execution + +#### Validation + +- Cannot have `Create` calls when `aa_authorization_list` is non-empty (follows EIP-7702 semantics) +- Authority address is recovered from the signature and matched against the authorization + +### Parallelizable Nonces + +- **Protocol nonce (key 0)**: Existing account nonce, incremented for regular txs, 7702 authorization, or `CREATE` +- **User nonces (keys 1-N)**: Enable parallel execution with special gas schedule +- **Reserved sequence keys**: Nonce sequence keys with the most significant byte `0x5b` are reserved for protocol-managed validator sequencing. + +#### Account State Changes + +- `nonces: mapping(uint256 => uint64)` - 2D nonce tracking + +**Implementation Note:** Nonces are stored in the storage of a designated precompile at address `0x4E4F4E4345000000000000000000000000000000` (ASCII hex for "NONCE"), as there is currently no clean way to extend account state in Reth. + +**Storage Layout at `0x4E4F4E4345`:** + +- Storage key: `keccak256(abi.encode(account_address, nonce_key))` +- Storage value: `nonce` (`uint64`) + +Note: Protocol nonce key (`0`) is directly stored in the account state, just like normal transaction types. + +#### Nonce Precompile + +The nonce precompile implements the following interface for managing 2D nonces: + +```solidity +/// @title INonce - Nonce Precompile Interface +/// @notice Interface for managing 2D nonces as per the Tempo Transaction spec +/// @dev This precompile manages user nonce keys (1-N) while protocol nonces (key 0) +/// are handled directly by account state. Each account can have multiple +/// independent nonce sequences identified by a nonce key. +interface INonce { + /// @notice Emitted when a nonce is incremented for an account and nonce key + /// @param account The account whose nonce was incremented + /// @param nonceKey The nonce key that was incremented + /// @param newNonce The new nonce value after incrementing + event NonceIncremented(address indexed account, uint256 indexed nonceKey, uint64 newNonce); + + /// @notice Thrown when trying to access protocol nonce (key 0) through the precompile + /// @dev Protocol nonce should be accessed through account state, not this precompile + error ProtocolNonceNotSupported(); + + /// @notice Thrown when an invalid nonce key is provided + error InvalidNonceKey(); + + /// @notice Thrown when a nonce value would overflow + error NonceOverflow(); + + /// @notice Get the current nonce for a specific account and nonce key + /// @param account The account address + /// @param nonceKey The nonce key (must be > 0, protocol nonce key 0 not supported) + /// @return nonce The current nonce value + function getNonce(address account, uint256 nonceKey) external view returns (uint64 nonce); +} +``` + +#### Precompile Implementation + +The precompile contract maintains a single storage mapping: + +```solidity +contract Nonce is INonce { + /// @dev Mapping from account -> nonce key -> nonce value + mapping(address => mapping(uint256 => uint64)) private nonces; +} +``` + +#### Gas Schedule + +For transactions using nonce keys: + +1. **Protocol nonce (key 0)**: No additional gas cost + - Uses the standard account nonce stored in account state +2. **Existing user key (nonce > 0)**: Add 5,000 gas to base cost + - Rationale: Cold SLOAD (2,100) + warm SSTORE reset (2,900) +3. **New user key (nonce == 0)**: Add 22,100 gas to base cost + - Rationale: Cold SLOAD (2,100) + SSTORE set for 0 -> non-zero (20,000) + +We specify the complete gas schedule in more detail in the [gas costs section](#gas-costs). + +### Transaction Validation + +#### Signature Validation + +1. Determine type from signature format: + - 65 bytes (no type identifier) = secp256k1 + - First byte `0x01` + 129 bytes = P256 (total 130 bytes) + - First byte `0x02` + variable data = WebAuthn (total 129-2049 bytes) + - First byte `0x03` + 20 bytes + inner signature = Keychain + - Otherwise invalid +2. Apply appropriate verification: + - secp256k1: Standard `ecrecover` + - P256: P256 curve verification with provided public key (sha256 pre-hash if flag set) + - WebAuthn: Parse `clientDataJSON`, verify challenge and type, then P256 verify + - Keychain: Verify inner signature, then validate access key authorization via AccountKeychain precompile + +#### Nonce Validation + +1. Fetch sequence for given nonce key +2. Verify sequence matches transaction +3. Increment sequence + +#### Fee Payer Validation (if present) + +1. Verify fee payer signature (K1 only initially) +2. Recover payer address via `ecrecover` +3. Deduct fees from payer instead of sender + +### Fee Payer Signature Details + +The Tempo transaction type (`0x76`) supports **gas sponsorship** where a third party (fee payer) can pay transaction fees on behalf of the sender. This is achieved through dual signature domains: the sender signs with transaction type byte `0x76`, while the fee payer signs with magic byte `0x78` to ensure domain separation and prevent signature reuse attacks. + +#### Signing Domains + +##### Sender Signature + +For computing the transaction hash that the sender signs: + +- Fields are preceded by transaction type byte `0x76` +- Field 11 (`fee_token`) is encoded as empty string (`0x80`) **if and only if** `fee_payer_signature` is present. This allows the fee payer to specify the fee token. +- Field 12 (`fee_payer_signature`) is encoded as: + - Single byte `0x00` if fee payer signature will be present (placeholder) + - Empty string `0x80` if no fee payer + +**Sender Signature Hash:** + +```rust +// When fee_payer_signature is present: +sender_hash = keccak256(0x76 || rlp([ + chain_id, + max_priority_fee_per_gas, + max_fee_per_gas, + gas_limit, + calls, + access_list, + nonce_key, + nonce, + valid_before, + valid_after, + 0x80, // fee_token encoded as EMPTY (skipped) + 0x00 // placeholder byte for fee_payer_signature +])) + +// When no fee_payer_signature: +sender_hash = keccak256(0x76 || rlp([ + chain_id, + max_priority_fee_per_gas, + max_fee_per_gas, + gas_limit, + calls, + access_list, + nonce_key, + nonce, + valid_before, + valid_after, + fee_token, // fee_token is INCLUDED + 0x80 // empty for no fee_payer_signature +])) +``` + +##### Fee Payer Signature + +Only included for sponsored transactions. For computing the fee payer's signature hash: + +- Fields are preceded by **magic byte `0x78`** (different from transaction type `0x76`) +- Field 11 (`fee_token`) is **always included** (20-byte address or `0x80` for None) +- Field 12 is serialized as the **sender address** (20 bytes). This commits the fee payer to sponsoring a specific sender. + +**Fee Payer Signature Hash:** + +```rust +fee_payer_hash = keccak256(0x78 || rlp([ // Note: 0x78 magic byte + chain_id, + max_priority_fee_per_gas, + max_fee_per_gas, + gas_limit, + calls, + access_list, + nonce_key, + nonce, + valid_before, + valid_after, + fee_token, // fee_token ALWAYS included + sender_address, // 20-byte sender address + key_authorization, +])) +``` + +#### Key Properties + +1. **Sender Flexibility**: By omitting `fee_token` from sender signature when fee payer is present, the fee payer can specify which token to use for payment without invalidating the sender's signature +2. **Fee Payer Commitment**: Fee payer's signature includes `fee_token` and `sender_address`, ensuring they agree to: + - Pay for the specific sender + - Use the specific fee token +3. **Domain Separation**: Different magic bytes (`0x76` vs `0x78`) prevent signature reuse attacks between sender and fee payer roles +4. **Deterministic Fee Payer**: The fee payer address is statically recoverable from the transaction via secp256k1 signature recovery + +#### Validation Rules + +**Signature Requirements:** + +- Sender signature MUST be valid (secp256k1, P256, or WebAuthn depending on signature length) +- If `fee_payer_signature` is present: + - MUST be recoverable via secp256k1 (only secp256k1 supported for fee payers) + - Recovery MUST succeed, otherwise the transaction is invalid +- If `fee_payer_signature` is absent: + - Fee payer defaults to sender address (self-paid transaction) + +**Token Preference:** + +- When `fee_token` is `Some(address)`, this overrides any account-level or validator-level preferences +- Validation ensures the token is a valid TIP-20 token with sufficient balance/liquidity +- Failures reject the transaction before execution (see the token preferences spec) + +**Fee Payer Resolution:** + +- Fee payer signature present -> recovered address via `ecrecover` +- Fee payer signature absent -> sender address +- This address is used for all fee accounting (pre-charge, refund) via the TIP Fee Manager precompile + +#### Transaction Flow + +1. **User prepares transaction**: Sets `fee_payer_signature` to placeholder (`Some(Signature::default())`) +2. **User signs**: Computes sender hash (with `fee_token` skipped) and signs +3. **Fee payer receives** the user-signed transaction +4. **Fee payer verifies** the user signature is valid +5. **Fee payer signs**: Computes fee payer hash (with `fee_token` and `sender_address`) and signs +6. **Complete transaction**: Replace placeholder with actual fee payer signature +7. **Broadcast**: Transaction is sent to the network with both signatures + +#### Error Cases + +- `fee_payer_signature` present but unrecoverable -> invalid transaction +- Fee payer balance insufficient for `gas_limit * max_fee_per_gas` in fee token -> invalid +- Any sender signature failure -> invalid +- Malformed RLP -> invalid + +### RLP Encoding + +The transaction is RLP encoded as follows: + +**Signed Transaction Envelope:** + +``` +0x76 || rlp([ + chain_id, + max_priority_fee_per_gas, + max_fee_per_gas, + gas_limit, + calls, // RLP list of Call structs + access_list, + nonce_key, + nonce, + valid_before, // 0x80 if None + valid_after, // 0x80 if None + fee_token, // 0x80 if None + fee_payer_signature, // 0x80 if None, RLP list [v, r, s] if Some + aa_authorization_list, // EIP-7702 style authorization list with AA signatures + key_authorization?, // Only encoded if present (backwards compatible) + sender_signature // TempoSignature bytes (secp256k1, P256, WebAuthn, or Keychain) +]) +``` + +**Call Encoding:** + +``` +rlp([to, value, input]) +``` + +**Key Authorization Encoding:** + +``` +rlp([ + chain_id, + key_type, + key_id, + expiry?, // Optional trailing field (omitted or 0x80 if None) + limits?, // Optional trailing field (omitted or 0x80 if None) + signature // PrimitiveSignature bytes +]) +``` + +**Notes:** + +- Optional fields encode as `0x80` (`EMPTY_STRING_CODE`) when `None` +- The `key_authorization` field is truly optional - when `None`, no bytes are encoded (backwards compatible) +- The `calls` field is a list that must contain at least one `Call` (empty calls list is invalid) +- The `sender_signature` field is the final field and contains the TempoSignature bytes (secp256k1, P256, WebAuthn, or Keychain) +- `KeyAuthorization` uses RLP trailing field semantics for optional `expiry`, `limits`, and `allowed_calls` + +### WebAuthn Signature Verification + +WebAuthn verification follows the [Daimo P256 verifier approach](https://github.com/daimo-eth/p256-verifier/blob/master/src/WebAuthn.sol). + +#### Signature Format + +``` +signature = authenticatorData || clientDataJSON || r (32) || s (32) || pubKeyX (32) || pubKeyY (32) +``` + +Parse by working backwards: + +- Last 32 bytes: `pubKeyY` +- Previous 32 bytes: `pubKeyX` +- Previous 32 bytes: `s` +- Previous 32 bytes: `r` +- Remaining bytes: `authenticatorData || clientDataJSON` (requires parsing to split) + +#### Authenticator Data Structure (minimum 37 bytes) + +``` +Bytes 0-31: rpIdHash (32 bytes) +Byte 32: flags (1 byte) + - Bit 0 (0x01): User Presence (UP) - must be set +Bytes 33-36: signCount (4 bytes) +``` + +#### Verification Steps + +```python +def verify_webauthn(tx_hash: bytes32, signature: bytes, require_uv: bool) -> bool: + # 1. Parse signature + pubKeyY = signature[-32:] + pubKeyX = signature[-64:-32] + s = signature[-96:-64] + r = signature[-128:-96] + webauthn_data = signature[:-128] + + # Parse authenticatorData and clientDataJSON + # Minimum authenticatorData is 37 bytes + # Simple approach: try to decode clientDataJSON from different split points + authenticatorData, clientDataJSON = split_webauthn_data(webauthn_data) + + # 2. Validate authenticator data + if len(authenticatorData) < 37: + return False + + flags = authenticatorData[32] + if not (flags & 0x01): # UP bit must be set + return False + + # 3. Validate client data JSON + if not contains(clientDataJSON, '"type":"webauthn.get"'): + return False + + challenge_b64url = base64url_encode(tx_hash) + challenge_property = '"challenge":"' + challenge_b64url + '"' + if not contains(clientDataJSON, challenge_property): + return False + + # 4. Compute message hash + clientDataHash = sha256(clientDataJSON) + messageHash = sha256(authenticatorData || clientDataHash) + + # 5. Verify P256 signature + return p256_verify(messageHash, r, s, pubKeyX, pubKeyY) +``` + +#### What We Verify + +- Authenticator data minimum length (37 bytes) +- User Presence (UP) flag is set +- `"type":"webauthn.get"` in `clientDataJSON` +- Challenge matches `tx_hash` (Base64URL encoded) +- P256 signature validity + +#### What We Skip + +- Origin verification (not applicable to blockchain) +- RP ID hash validation (no central RP in decentralized context) +- Signature counter (anti-cloning left to application layer) +- Backup flags (account policy decision) + +#### Parsing `authenticatorData` and `clientDataJSON` + +Since `authenticatorData` has variable length, finding the split point requires: + +1. Check if the AT flag (bit 6) is set at byte 32 +2. If not set, `authenticatorData` is exactly 37 bytes +3. If set, parse CBOR credential data (complex, see implementation) +4. Everything after `authenticatorData` is `clientDataJSON` (valid UTF-8 JSON) + +**Simplified approach:** For Tempo transactions, wallets should send minimal `authenticatorData` (37 bytes, no AT/ED flags) to minimize gas costs and simplify parsing. + +### Access Keys + +A sender can choose to authorize an access key to sign transactions on the sender's behalf. This is useful to enable flows where a root key (for example, a passkey) provisions a short-lived, scoped access key that can sign transactions on the sender's behalf without inducing another passkey prompt. + +More information about access keys can be found in the [Account Keychain specification](https://docs.tempo.xyz/protocol/transactions/AccountKeychain). + +A sender can authorize a key by signing over a "key authorization" item that contains the following information: + +- **Chain ID** for replay protection (`0` = valid on any chain) +- **Key type** (`Secp256k1`, `P256`, or `WebAuthn`) +- **Key ID** (address derived from the public key) +- **Expiration** timestamp of when the key should expire (optional - `None` means never expires) +- TIP20 token **spending limits** for the key (optional - `None` means unlimited spending): + - Each limit carries a `period` (`0` = one-time, non-zero = recurring in seconds). Recurring limits roll over to `max` when `current_timestamp >= periodEnd`. + - The root key can update limits via `updateSpendingLimit()` without revoking the key. Updates reset `remaining` and `max` to `newLimit` while preserving `period` and `periodEnd`. + - Spending limits only apply to TIP20 token transfers, not ETH or other asset transfers. +- **Call scopes** for the key (optional - `None` means unrestricted): + - Each entry pins a `target` contract and a list of allowed selector rules. An empty selector list on a target means any selector is allowed on that target. + - Selector rules can additionally constrain TIP-20 recipient-bearing selectors to a recipient allowlist. + - `Some([])` (an empty top-level allowlist) means scoped deny-all. + +Access-key-signed transactions cannot perform contract creation. Calls within the batch that would `CREATE` or `CREATE2` (including via factory contracts) are rejected. Use the root key for deployment flows. + +#### RLP Encoding + +**Unsigned Format:** + +The root key signs over the `keccak256` hash of the RLP-encoded `KeyAuthorization`: + +``` +key_authorization_digest = keccak256(rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?])) + +chain_id = u64 (0 = valid on any chain) +key_type = 0 (Secp256k1) | 1 (P256) | 2 (WebAuthn) +key_id = Address (derived from the public key) +expiry = Option (unix timestamp, None = never expires; omitted expiry is translated to u64::MAX when the protocol calls the precompile) +limits = Option> (None = unlimited spending; period = 0 means one-time) +allowed_calls = Option> (None = unrestricted; Some([]) = scoped deny-all) +``` + +**Signed Format:** + +The signed format (`SignedKeyAuthorization`) includes all fields with the `signature` appended: + +``` +signed_key_authorization = rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, signature]) +``` + +The `signature` is a `PrimitiveSignature` (secp256k1, P256, or WebAuthn) signed by the root key. + +Note: `expiry`, `limits`, and `allowed_calls` use RLP trailing field semantics. They can be omitted entirely when `None`. + +**Expiry encoding** + +For `key_authorization`, the canonical non-expiring encoding omits `expiry` (`None`). + +There is one decoder nuance: because `KeyAuthorization` uses canonical trailing optional fields, an explicit empty `expiry` placeholder (`0x80`) is also interpreted as `None` when another trailing optional field follows it. But a final `expiry` encoded as zero/empty is rejected, and a literal `0x00` is invalid RLP for this field. + +Do not hand-encode `expiry = 0` or rely on `Some(0)` as a sentinel. The supported encoding to target is omission, and the protocol translates omitted expiry to `u64::MAX` when materializing the `AccountKeychain.authorizeKey(...)` call. + +Intrinsic gas for `key_authorization` accounts for the storage written for periodic-limit state and call-scope entries. See [TIP-1011](./tip-1011.md) for slot-counting rules. + +#### Keychain Precompile + +The Account Keychain precompile (deployed at address `0xAAAAAAAA00000000000000000000000000000000`) manages authorized access keys for accounts. It enables root keys to provision scoped access keys with expiry timestamps and per-TIP20 token spending limits. + +See the [Account Keychain specification](https://docs.tempo.xyz/protocol/transactions/AccountKeychain) for complete interface details, storage layout, and implementation. + +#### Protocol Behavior + +The protocol enforces access key authorization and spending limits natively. + +##### Transaction Validation + +When a `TempoTransaction` is received, the protocol: + +1. **Identifies the signing key** from the transaction signature + - If the signature is a `Keychain` variant: extracts the `keyId` (address) of the access key + - Otherwise: treats it as the root key (`keyId = address(0)`) +2. **Validates `KeyAuthorization`** (if present in the transaction) + - The `key_authorization` field in `TempoTransaction` provisions a new access key + - The root key MUST sign the `key_authorization` digest: `keccak256(rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?]))` + - The access key being authorized can sign the same tx in which it is authorized + - This enables "authorize and use" in a single transaction +3. **Sets transaction context** + - Stores `transactionKey[account] = keyId` in protocol state + - Used to enforce authorization hierarchy during execution and can also be used by dapps to see which key authorized the current tx +4. **Validates key authorization** (for access keys) + - Queries the precompile: `getKey(account, keyId)` returns `KeyInfo` + - Checks key is active (not revoked) + - Checks expiry: `current_timestamp < expiry`; non-expiring keys are stored with `expiry = u64::MAX` + - Rejects the transaction if validation fails + +##### Authorization Hierarchy Enforcement + +The protocol enforces a strict two-tier hierarchy: + +**Root Key** (`keyId = address(0)`): + +- The account's primary key (address matches account address) +- Can call all precompile functions +- Has no spending limits +- Can authorize, revoke, and update access keys + +**Access Keys** (`keyId != address(0)`): + +- Secondary keys authorized by the root key +- Cannot call mutable precompile functions (`authorizeKey`, `revokeKey`, `updateSpendingLimit`, `setAllowedCalls`, `removeAllowedCalls`) +- Precompile functions check `transactionKey[msg.sender] == 0` before allowing mutations +- Subject to per-TIP20 token spending limits and call-scope checks during execution +- Cannot create contracts (`CREATE` and `CREATE2` are rejected anywhere in the call batch) +- Can have expiry timestamps + +When an access key attempts to call any mutable keychain function: + +1. The transaction executes normally until the precompile call +2. The precompile checks `getTransactionKey()` and sees a non-zero key (access key) +3. The call reverts with `UnauthorizedCaller` +4. The entire transaction is reverted + +##### Spending Limit Enforcement + +The protocol tracks and enforces spending limits for TIP20 token transfers. + +**Scope:** Only TIP20 `transfer()`, `transferWithMemo()`, `approve()`, and `startReward()` calls are tracked. + +- Spending limits only apply when `msg.sender == tx.origin` (direct EOA calls) +- When a contract makes transfers on behalf of the user, spending limits do not apply (for example, `transferFrom()`) +- Native value transfers are not limited +- NFT transfers are not limited +- Other asset types are not limited + +**Tracking:** During transaction execution, when an access-key transaction directly calls TIP20 methods: + +1. The protocol intercepts `transfer(to, amount)`, `transferWithMemo()`, `approve(spender, amount)`, and `startReward()` calls +2. For `transfer` and `transferWithMemo`, the full `amount` is checked against the remaining limit +3. For `approve`, only **increases** in approval (new approval minus previous allowance) are checked and counted against the limit +4. The protocol queries `getRemainingLimitWithPeriod(account, keyId, token)`, which returns `(remaining, periodEnd)` and reflects any periodic rollover +5. The protocol checks that the relevant amount (`transfer` amount or approval increase) is `<= remaining` +6. If the check fails, execution reverts with `SpendingLimitExceeded` +7. If the check passes, the limit is decremented by the relevant amount +8. Updates are stored in precompile state + +**Root Key Behavior:** Spending limit checks are skipped entirely. + +**Recurring Limits:** When a `TokenLimit.period` is non-zero, the limit recurs. `remaining` rolls over to `max` once `current_timestamp >= periodEnd`, and `periodEnd` advances by `period`. Callers observe rollover state via `getRemainingLimitWithPeriod`. + +**Limit Updates:** + +- Limits deplete as tokens are spent +- The root key can call `updateSpendingLimit(keyId, token, newLimit)` to set new limits +- Setting a new limit replaces both `remaining` and `max` with `newLimit`. The configured `period` and current `periodEnd` are preserved + +##### Call Scope Enforcement + +When an access key has stored call scopes (`allowed_calls` was set at authorization, or `setAllowedCalls(...)` was called later), the protocol enforces them on top-level calls signed by that access key: + +1. For each call in the batch, look up the matching `(target, selector)` allowlist entry +2. If the target is not in the allowlist, or the selector is not allowed on that target, revert with `CallNotAllowed` +3. For recipient-bound TIP-20 selectors (for example, `transfer`, `transferFrom`, `transferWithMemo`), additionally enforce that the call's recipient is in the rule's recipient allowlist when non-empty +4. Access keys with `allowed_calls = None` are unrestricted; `Some([])` is scoped deny-all + +##### Contract Creation Restriction + +Access-key-signed transactions cannot perform contract creation. Any `CREATE` or `CREATE2` (including via factory contracts or internal calls) reverts the transaction. Use the root key for deployment flows. + +##### Creating and Using `KeyAuthorization` + +**First-Time Authorization Flow:** + +1. **Generate Access Key** + +```typescript +// Generate a new P256 or secp256k1 key pair +const accessKey = generateKeyPair("p256"); // or "secp256k1" +const keyId = deriveAddress(accessKey.publicKey); +``` + +2. **Create Authorization Message** + +```typescript +// Define key parameters +const keyAuth = { + chain_id: 1, + key_type: SignatureType.P256, // 1 + key_id: keyId, // address derived from public key + expiry: timestamp + 86400, // 24 hours from now; omit this field for a non-expiring key authorization + limits: [ + // One-time limit (period = 0) + { token: USDG_ADDRESS, limit: 1000000000, period: 0 }, // 1000 USDG (6 decimals), one-time + // Recurring weekly limit (period = 604800 seconds) + { token: DAI_ADDRESS, limit: 500000000000000000000n, period: 604800 } // 500 DAI / week + ], + // Optional call scopes - omit for an unrestricted key + allowed_calls: [ + { + target: USDG_ADDRESS, + selector_rules: [ + // transfer(address,uint256) restricted to a single recipient + { selector: "0xa9059cbb", recipients: [TRUSTED_RECIPIENT] } + ] + } + ] +}; + +// Compute digest: keccak256(rlp([chain_id, key_type, key_id, expiry, limits, allowed_calls])) +const authDigest = computeAuthorizationDigest(keyAuth); +``` + +3. **Root Key Signs Authorization** + +```typescript +// Sign with Root Key (for example, a passkey prompt) +const rootSignature = await signWithRootKey(authDigest); +``` + +4. **Build TempoTransaction** + +```typescript +const tx = { + chain_id: 1, + nonce: await getNonce(account), + nonce_key: 0, + calls: [{ to: recipient, value: 0, input: "0x" }], + gas_limit: 200000, + max_fee_per_gas: 1000000000, + max_priority_fee_per_gas: 1000000000, + key_authorization: { + authorization: keyAuth, + signature: rootSignature // Root Key's signature on authDigest + }, + // ... other fields +}; +``` + +5. **Access Key Signs Transaction** + +```typescript +// Sign transaction with the new Access Key being authorized +const txHash = computeTxSignatureHash(tx); +const accessSignature = await signWithAccessKey(txHash, accessKey); + +// Wrap in Keychain signature +const finalSignature = { + Keychain: { + user_address: account, + signature: { P256: accessSignature } // or Secp256k1 + } +}; +``` + +6. **Submit Transaction** + +- The protocol validates that the root key signed the `key_authorization` +- The protocol calls `authorizeKey()` on the precompile to store the key +- The protocol validates the access key signature on the transaction +- The transaction executes with spending limits enforced + +**Subsequent Usage (Key Already Authorized):** + +```typescript +// Access Key is already authorized, just sign transactions directly +const tx = { + chain_id: 1, + nonce: await getNonce(account), + calls: [{ to: recipient, value: 0, input: calldata }], + key_authorization: null, // No authorization needed + // ... other fields +}; + +const txHash = computeTxSignatureHash(tx); +const accessSignature = await signWithAccessKey(txHash, accessKey); + +const finalSignature = { + Keychain: { + user_address: account, + signature: { P256: accessSignature } + } +}; + +// Submit - protocol validates key is authorized and not expired +``` + +##### Key Management Operations + +**Revoking an Access Key:** + +```typescript +// Must be signed by Root Key +const tx = { + chain_id: 1, + nonce: await getNonce(account), + calls: [{ + to: ACCOUNT_KEYCHAIN_ADDRESS, + value: 0, + input: encodeCall("revokeKey", [keyId]) + }], + // ... sign with Root Key +}; +``` + +**Updating Spending Limits:** + +```typescript +// Must be signed by Root Key +const tx = { + chain_id: 1, + nonce: await getNonce(account), + calls: [{ + to: ACCOUNT_KEYCHAIN_ADDRESS, + value: 0, + input: encodeCall("updateSpendingLimit", [ + keyId, + USDG_ADDRESS, + 2000000000 // New limit: 2000 USDG + ]) + }], + // ... sign with Root Key +}; +``` + +**Note:** After updating, both `remaining` and `max` are set to `newLimit`. The configured `period` and current `periodEnd` are preserved. + +##### Querying Key State + +Applications can query key information and spending limits: + +```typescript +// Check if key is authorized and get info +const keyInfo = await precompile.getKey(account, keyId); +// Returns: { signatureType, keyId, expiry, enforceLimits, isRevoked } + +// Check remaining spending limit and current period end for a token +const { remaining, periodEnd } = await precompile.getRemainingLimitWithPeriod( + account, keyId, USDG_ADDRESS +); +// Returns: (uint256 remaining, uint64 periodEnd). Reflects periodic rollover. + +// Inspect call scopes +const { isScoped, scopes } = await precompile.getAllowedCalls(account, keyId); +// isScoped = false -> key is unrestricted +// isScoped = true, scopes = [...] -> key is scoped to those (target, selector, recipient) entries +// isScoped = true, scopes = [] -> scoped deny-all (also returned for missing/revoked/expired keys) + +// Get which key signed current transaction (callable from contracts) +const currentKey = await precompile.getTransactionKey(); +// Returns: address (0x0 for Root Key, keyId for Access Key) +``` + +## Rationale + +### Signature Type Detection by Length + +Using signature length for type detection avoids adding explicit type fields while maintaining deterministic parsing. The chosen lengths (`65`, `129`, and variable) are naturally distinct. + +### Linear Gas Scaling for Nonce Keys + +The progressive pricing model prevents state bloat while keeping initial keys affordable. The 20,000 gas increment approximates the long-term state cost of maintaining each additional nonce mapping. + +### No Nonce Expiry + +Avoiding expiry simplifies the protocol and prevents edge cases where in-flight transactions become invalid. Wallets handle nonce key allocation to prevent conflicts. + +### Backwards Compatibility + +This spec introduces a new transaction type and does not modify existing transaction processing. Legacy transactions continue to work unchanged. We special-case `nonce_key = 0` (also referred to as the protocol nonce key) to maintain compatibility with existing nonce behavior. + +## Gas Costs + +### Signature Verification Gas Schedule + +Different signature types incur different base transaction costs to reflect their computational complexity: + +| Signature Type | Base Gas Cost | Calculation | Rationale | +|----------------|---------------|-------------|-----------| +| **secp256k1** | 21,000 | Standard | Includes 3,000 gas for `ecrecover` precompile | +| **P256** | 26,000 | 21,000 + 5,000 | Base 21k + additional 5k for P256 verification | +| **WebAuthn** | 26,000 + variable data cost | 26,000 + calldata gas for `clientDataJSON` | Base P256 cost plus variable cost for `clientDataJSON` based on size | +| **Keychain** | Inner signature + 3,000 | `primitive_sig_cost + 3,000` | Inner signature cost + key validation overhead (2,100 SLOAD + 900 buffer) | + +**Rationale:** + +- The base 21,000 gas for standard transactions already includes the cost of secp256k1 signature verification via `ecrecover` (3,000 gas) +- [EIP-7951](https://eips.ethereum.org/EIPS/eip-7951) sets P256 verification cost at 6,900 gas. We add 1,100 gas to account for the additional 65 bytes of signature size (129 bytes total vs 64 bytes for secp256k1), giving 8,000 gas total. Since the base 21k already includes 3,000 gas for `ecrecover` (which P256 does not use), the net additional cost is `8,000 - 3,000 = 5,000 gas` +- WebAuthn signatures require additional computation to parse and validate the `clientDataJSON` structure. We cap the total signature size at 2 KB. The signature is also charged using the same gas schedule as calldata (16 gas per non-zero byte, 4 gas per zero byte) to prevent this signature space from being used for spam +- Keychain signatures wrap a primitive signature and are used by access keys. They add 3,000 gas to cover key validation during transaction validation (cold SLOAD to verify key exists + processing overhead) +- Individual per-signature-type gas costs let Tempo add more advanced verification methods in the future, such as multisigs, which could have dynamic gas pricing + +### Nonce Key Gas Schedule + +Transactions using parallelizable nonces incur additional costs based on the nonce key usage pattern. + +#### Case 1: Protocol Nonce (Key 0) + +- **Additional Cost:** 0 gas +- **Total:** 21,000 gas (base transaction cost) +- **Rationale:** Maintains backward compatibility with the existing transaction flow + +#### Case 2: Existing User Nonce Key (`nonce > 0`) + +- **Additional Cost:** 5,000 gas +- **Total:** 26,000 gas +- **Rationale:** Cold SLOAD (2,100) + warm SSTORE reset (2,900) for incrementing an existing nonce + +#### Case 3: New User Nonce Key (`nonce == 0`) + +- **Additional Cost:** 22,100 gas +- **Total:** 43,100 gas +- **Rationale:** Cold SLOAD (2,100) + SSTORE set (20,000) for writing to a new storage slot + +**Rationale for Fixed Pricing:** + +1. **Simplicity:** Fixed costs based on actual EVM storage operations are straightforward to reason about +2. **Storage Pattern Alignment:** Costs directly mirror EVM cold SSTORE costs for new vs existing slots +3. **State Growth:** Creating new nonce keys incurs the higher cost naturally through SSTORE set pricing + +### Key Authorization Gas Schedule + +When a transaction includes a `key_authorization` field to provision a new access key, additional intrinsic gas is charged to cover signature verification and storage operations. This gas is charged **before execution** as part of the transaction's intrinsic gas cost. + +#### Gas Components + +| Component | Gas Cost | Notes | +|-----------|----------|-------| +| **Signature verification** | 3,000 (secp256k1) / 8,000 (P256) / 8,000 + calldata (WebAuthn) | Verifying the root key's signature on the authorization | +| **Key storage** | 22,000 | Cold SSTORE to store new key (0 -> non-zero) | +| **Overhead buffer** | 5,000 | Buffer for event emission, storage reads, and other overhead | +| **Per spending limit** | 22,000 each | Cold SSTORE per token limit (0 -> non-zero) | + +**Signature verification rationale:** `KeyAuthorization` requires an *additional* signature verification beyond the transaction signature. Unlike the transaction signature, where the `ecrecover` cost is already included in the base 21k, `KeyAuthorization` must pay the full verification cost. + +- **secp256k1**: 3,000 gas (`ecrecover` precompile cost) +- **P256**: 8,000 gas (6,900 from EIP-7951 + 1,100 for signature size). The transaction signature schedule charges only 5,000 additional gas for P256 because it subtracts the 3,000 `ecrecover` savings already included in the base 21k. `KeyAuthorization` pays the full 8,000 +- **WebAuthn**: 8,000 gas + calldata gas for `webauthn_data` + +#### Gas Formula + +``` +KEY_AUTH_BASE_GAS = 30,000 # For secp256k1 signature (3,000 + 22,000 + 5,000) +KEY_AUTH_BASE_GAS = 35,000 # For P256 signature (5,000 + 3,000 + 22,000 + 5,000) +KEY_AUTH_BASE_GAS = 35,000 + webauthn_calldata_gas # For WebAuthn signature + +PER_LIMIT_GAS = 22,000 # Per spending limit entry + +total_key_auth_gas = KEY_AUTH_BASE_GAS + (num_limits * PER_LIMIT_GAS) +``` + +#### Examples + +| Configuration | Gas Cost | Calculation | +|--------------|----------|-------------| +| secp256k1, no limits | 30,000 | Base only | +| secp256k1, 1 limit | 52,000 | 30,000 + 22,000 | +| secp256k1, 3 limits | 96,000 | 30,000 + (3 x 22,000) | +| P256, no limits | 35,000 | Base with P256 verification | +| P256, 2 limits | 79,000 | 35,000 + (2 x 22,000) | + +#### Rationale + +1. **Pre-execution charging**: `KeyAuthorization` is validated and executed during transaction validation, before the EVM runs, so its gas must be included in intrinsic gas +2. **Storage cost alignment**: The 22,000 gas per storage slot approximates EVM cold SSTORE costs for new slots +3. **DoS prevention**: Progressive cost based on the number of limits prevents abuse through excessive limit creation + +### Reference Pseudocode + +```python +def calculate_calldata_gas(data: bytes) -> uint256: + """ + Calculate gas cost for calldata based on zero and non-zero bytes + + Args: + data: bytes to calculate cost for + + Returns: + gas_cost: uint256 + """ + CALLDATA_ZERO_BYTE_GAS = 4 + CALLDATA_NONZERO_BYTE_GAS = 16 + + gas = 0 + for byte in data: + if byte == 0: + gas += CALLDATA_ZERO_BYTE_GAS + else: + gas += CALLDATA_NONZERO_BYTE_GAS + + return gas + + +def calculate_signature_verification_gas(signature: PrimitiveSignature) -> uint256: + """ + Calculate gas cost for verifying a primitive signature. + + Returns the additional gas beyond the base 21k transaction cost. + - secp256k1: 0 (already included in base 21k via ecrecover) + - P256: 5,000 (8,000 full cost - 3,000 ecrecover already in base 21k) + - WebAuthn: 5,000 + calldata gas for webauthn_data + """ + # P256 full verification cost is 8,000 (6,900 from EIP-7951 + 1,100 for signature size) + # But base 21k already includes 3,000 for ecrecover, so additional cost is 5,000 + P256_ADDITIONAL_GAS = 5_000 + + if signature.type == Secp256k1: + return 0 # Already included in base 21k + elif signature.type == P256: + return P256_ADDITIONAL_GAS + elif signature.type == WebAuthn: + webauthn_data_gas = calculate_calldata_gas(signature.webauthn_data) + return P256_ADDITIONAL_GAS + webauthn_data_gas + else: + revert("Invalid signature type") + + +def calculate_key_authorization_gas(key_auth: SignedKeyAuthorization) -> uint256: + """ + Calculate the intrinsic gas cost for a KeyAuthorization. + + This is charged before execution as part of transaction validation. + + Args: + key_auth: SignedKeyAuthorization with fields: + - signature: PrimitiveSignature (root key's signature) + - limits: Optional[List[TokenLimit]] # each carries a `period` + - allowed_calls: Optional[List[CallScope]] # call-scope allowlist + + Returns: + gas_cost: uint256 + + Note: This is a simplified illustration. See TIP-1011 for the canonical + slot-counting rules covering periodic-limit state and call-scope storage. + """ + # Constants - KeyAuthorization pays full signature verification costs + # (not the "additional" costs used for transaction signatures) + ECRECOVER_GAS = 3_000 # Full ecrecover cost + P256_FULL_GAS = 8_000 # Full P256 cost (6,900 + 1,100) + COLD_SSTORE_SET_GAS = 22_000 # Storage cost for new slot + OVERHEAD_BUFFER = 5_000 # Buffer for event emission, storage reads, etc. + + gas = 0 + + # Step 1: Signature verification cost (full cost, not additional) + if key_auth.signature.type == Secp256k1: + gas += ECRECOVER_GAS # 3,000 + elif key_auth.signature.type == P256: + gas += P256_FULL_GAS # 8,000 + elif key_auth.signature.type == WebAuthn: + webauthn_data_gas = calculate_calldata_gas(key_auth.signature.webauthn_data) + gas += P256_FULL_GAS + webauthn_data_gas # 8,000 + calldata + + # Step 2: Key storage + gas += COLD_SSTORE_SET_GAS # 22,000 - store new key (0 -> non-zero) + + # Step 3: Overhead buffer + gas += OVERHEAD_BUFFER # 5,000 + + # Step 4: Per-limit storage cost (each TokenLimit carries period state) + num_limits = len(key_auth.limits) if key_auth.limits else 0 + gas += num_limits * COLD_SSTORE_SET_GAS # 22,000 per limit + + # Step 5: Per-call-scope storage cost (target + selector + recipients). + # See TIP-1011 for exact slot accounting; this counts one slot per + # (target, selector, recipient) triple as a conservative approximation. + num_scope_slots = 0 + if key_auth.allowed_calls: + for scope in key_auth.allowed_calls: + for rule in scope.selector_rules: + # one slot for the (target, selector) entry, plus one per recipient + num_scope_slots += 1 + max(len(rule.recipients), 0) + gas += num_scope_slots * COLD_SSTORE_SET_GAS + + return gas + + +def calculate_tempo_tx_base_gas(tx): + """ + Calculate the base gas cost for a TempoTransaction + + Args: + tx: TempoTransaction object with fields: + - signature: TempoSignature (variable length) + - nonce_key: uint192 + - nonce: uint64 + - sender_address: address + - key_authorization: Optional[SignedKeyAuthorization] + + Returns: + total_gas: uint256 + """ + + # Constants + BASE_TX_GAS = 21_000 + EXISTING_NONCE_KEY_GAS = 5_000 # Cold SLOAD (2,100) + warm SSTORE reset (2,900) + NEW_NONCE_KEY_GAS = 22_100 # Cold SLOAD (2,100) + SSTORE set (20,000) + KEYCHAIN_VALIDATION_GAS = 3_000 # 2,100 SLOAD + 900 processing buffer + + # Step 1: Determine signature verification cost + # For Keychain signatures, use the inner primitive signature + if tx.signature.type == Keychain: + inner_sig = tx.signature.inner_signature + else: + inner_sig = tx.signature + + signature_gas = BASE_TX_GAS + calculate_signature_verification_gas(inner_sig) + + # Add keychain validation overhead if using access key + if tx.signature.type == Keychain: + signature_gas += KEYCHAIN_VALIDATION_GAS + + # Step 2: Calculate nonce key cost + if tx.nonce_key == 0: + # Protocol nonce (backward compatible) + nonce_gas = 0 + else: + # User nonce key + current_nonce = get_nonce(tx.sender_address, tx.nonce_key) + + if current_nonce > 0: + # Existing nonce key - cold SLOAD + warm SSTORE reset + nonce_gas = EXISTING_NONCE_KEY_GAS + else: + # New nonce key - cold SLOAD + SSTORE set + nonce_gas = NEW_NONCE_KEY_GAS + + # Step 3: Calculate key authorization cost (if present) + if tx.key_authorization is not None: + key_auth_gas = calculate_key_authorization_gas(tx.key_authorization) + else: + key_auth_gas = 0 + + # Step 4: Calculate total base gas + total_gas = signature_gas + nonce_gas + key_auth_gas + + return total_gas +``` + +## Security Considerations + +### Mempool DOS Protection + +Transaction pools perform pre-execution validation checks before accepting transactions. These checks are performed for free by nodes, which makes them potential DoS vectors. The three primary validation checks are: + +1. **Signature verification** - must be valid +2. **Nonce verification** - must match the current account nonce +3. **Balance check** - the account must have sufficient balance to pay for the transaction + +This transaction type impacts all three areas. + +#### Signature Verification Impact + +- **P256 signatures**: Fixed computational cost similar to `ecrecover` +- **WebAuthn signatures**: Variable cost due to `clientDataJSON` parsing, but capped at 2 KB total signature size to prevent abuse +- **Mitigation**: All signature types have bounded computational costs that are in the same ballpark as standard `ecrecover` + +#### Nonce Verification Impact + +- **2D nonce lookup**: Requires an additional storage read from the nonce precompile +- **Cost**: Equivalent to a cold SLOAD (~2,100 gas worth of free computation) +- **Mitigation**: Cost is bounded to a manageable value + +#### Fee Payer Impact + +- **Additional account read**: When a fee payer is specified, the node must fetch the fee payer's account to verify balance +- **Cost**: Effectively doubles the free account-access work for sponsored transactions +- **Mitigation**: Cost is still bounded to a single additional account read + +#### Comparison to Ethereum + +The introduction of EIP-7702 delegated accounts already created complex cross-transaction dependencies in the mempool, which prevents static pool checks from being fully useful. A single transaction can invalidate multiple others by spending balances of multiple accounts. + +**Assessment:** While this transaction type introduces additional pre-execution validation costs, all costs are bounded to reasonable limits. The mempool complexity issues around cross-transaction dependencies already exist in Ethereum due to EIP-7702 and accounts with code, so the incremental cost from this transaction type is acceptable given these existing constraints. + +## T2 -> T3 Migration + +This section captures changes introduced by the [T3 network upgrade](https://docs.tempo.xyz/protocol/upgrades/t3) for integrators migrating from T2. The spec above is the canonical post-T3 specification. This appendix exists for reference. + +T3 expanded access keys through [TIP-1011](./tip-1011.md) in the following ways: + +- `KeyAuthorization` gained `allowed_calls` (call-scope allowlist) +- `TokenLimit` gained `period` (recurring vs one-time spending limits) +- The signed payload `SignedKeyAuthorization { authorization, signature }` is unchanged in shape, but `authorization` now uses the expanded `KeyAuthorization` and new RLP encoding. Low-level integrators that manually encode `key_authorization` must branch pre-T3 vs post-T3. The post-T3 digest and signed payload include `allowed_calls?` in addition to `expiry?` and `limits?` +- A non-expiring `key_authorization` omits `expiry` in tx RLP. At the Account Keychain ABI boundary, the protocol translates that omission to `u64::MAX`. Literal `0` is not a valid non-expiring sentinel to rely on +- Access-key validation gained two new execution checks: call scopes must pass before execution begins, and access-key-signed transactions may not perform contract creation anywhere in the batch +- The Account Keychain precompile ABI changed in lockstep. `authorizeKey(...)` now takes a `KeyRestrictions` tuple, `getRemainingLimit(...)` is replaced by `getRemainingLimitWithPeriod(...)`, and `setAllowedCalls(...)`, `removeAllowedCalls(...)`, and `getAllowedCalls(...)` are added. See the [Account Keychain specification](https://docs.tempo.xyz/protocol/transactions/AccountKeychain) for full details +- Intrinsic gas for `key_authorization` accounts for periodic-limit state and call-scope storage. See [TIP-1011](./tip-1011.md) for the canonical slot-counting rules + +### Pre-T3 `KeyAuthorization` (Reference Only) + +Before T3, `KeyAuthorization` did not include `allowed_calls`, and `TokenLimit` did not include `period`: + +```rust +pub struct KeyAuthorization { + chain_id: u64, + key_type: SignatureType, + key_id: Address, + expiry: Option, + limits: Option>, +} + +pub struct TokenLimit { + token: Address, + limit: U256, +} +``` + +The pre-T3 digest was `keccak256(rlp([chain_id, key_type, key_id, expiry?, limits?]))` and the signed payload was `rlp([chain_id, key_type, key_id, expiry?, limits?, signature])`.