| id | TIP-1011 |
|---|---|
| title | Enhanced Access Key Permissions |
| description | Extends Access Keys with periodic spending limits, destination/function scoping, and limited calldata recipient scoping. |
| authors | Tanishk Goyal |
| status | Mainnet |
| related | Tempo Transaction Spec |
| protocolVersion | T3 |
This TIP extends Access Keys with three permission features:
- Periodic spending limits that reset on fixed intervals.
- Call scoping that limits what addresses a key can call and which selectors it can use.
- Limited calldata recipient scoping for token transfer/approval selectors.
Currently Access Keys support per-token limits and expiry, but miss two practical controls.
One-time limits cannot express recurring allowances.
Use cases:
- Subscription billing (
10 USDC / month). - Payroll schedules (monthly budgeted payouts).
- Rate-limited agent/API budgets.
Users need finer controls than "any call". They want keys like:
- "Only call
swap()andexactInput()on DEX X." - "Only call gameplay methods on contract Y."
- "Only perform plain transfers, not token extension methods."
- "Only vote() on governance contracts."
Current workaround: Deploy a proxy contract that enforces destination/function restrictions, adding gas overhead and complexity.
Target + selector scoping still allows an access key to move funds to arbitrary recipients for token methods like transfer and approve.
Users need a narrower policy: the key may call transfer/approve selectors, but only when the recipient/spender matches a configured address.
This TIP intentionally adds a narrow calldata rule (first ABI address argument equality) instead of a generic calldata policy language.
Conventions used in this section:
- Protocol/RLP structs are written with Rust-like
Option<...>notation. - Solidity ABI structs are listed separately where ABI cannot directly represent protocol
Optionsemantics.
Current:
struct TokenLimit {
address token;
uint256 amount;
}Proposed:
struct TokenLimit {
address token;
uint256 amount; // One-time cap when period == 0, per-period cap when period > 0
uint64 period; // Period duration in seconds (0 = one-time limit)
}Design note: period is specified as an explicit field (instead of packed into token) to keep encoding/auditing straightforward and avoid migration risk for existing limit semantics.
Runtime state is derived and stored by the precompile (not signed):
TokenLimitState {
remainingInPeriod: uint256,
periodEnd: uint64,
}
Initialization and persistence:
TokenLimitStateis initialized when the key is authorized (or a limit is created via root mutation), not lazily at first spend.period == 0initializesremainingInPeriod = limitandperiodEnd = 0.period > 0initializesremainingInPeriod = limitandperiodEnd = authorize_time + period.- For a given
(account,key,token), there is exactly one activeTokenLimit; duplicate token entries in a single authorization MUST be rejected.
Call scoping uses explicit vectors in the protocol model:
CallScope {
target: address,
selector_rules: Vec<SelectorRule>, // [] => any selector on this target
}
Solidity ABI representation for precompile methods:
struct CallScope {
address target;
SelectorRule[] selectorRules;
}Solidity ABI and protocol semantics match directly:
selectorRules = []allows any selector ontarget.selectorRules = [r1, ...]allows exactly the listed selectors.- To remove a target scope in the Solidity precompile API, callers MUST use
removeAllowedCalls(keyId, target).
selector_rules behavior:
[]: allow any selector.[r1, r2, ...]: allow exactly the listed selector rules.
In the Solidity precompile API, omitting a target scope blocks that target; selectorRules = [] does not.
SelectorRule {
selector: bytes4,
recipients: Vec<address>, // [] => any recipient, [a1, ...] => only listed recipients
}
Solidity ABI representation for precompile methods:
struct SelectorRule {
bytes4 selector;
address[] recipients;
}Solidity ABI and protocol semantics match directly:
recipients = []allows any recipient for that selector.recipients = [a1, ...]constrains the selector to that recipient set.
SelectorRule.recipients behavior:
[]=> no calldata recipient checks for this selector.[a1, a2, ...]=> enforcearg0recipient membership for this selector.- Selector rules MUST be unique per target (
selectorappears at most once).
Supported constrained selectors in this TIP:
0xa9059cbb=>transfer(address,uint256)0x095ea7b3=>approve(address,uint256)0x95777d59=>transferWithMemo(address,uint256,bytes32)
If a selector rule uses recipients = [..], then:
targetMUST be a TIP-20 token address.selectorMUST be one of the constrained selectors above.- Otherwise, key authorization MUST be rejected.
For these selectors, the constrained field is ABI argument 0 (the first address argument).
Selector width is fixed at 4 bytes.
- Each
SelectorRule.selectorMUST be exactly 4 bytes. - Implementations MUST revert when decoding or accepting any selector whose length is not exactly 4 bytes.
- Selectorless calls (
calldata.length < 4) and fallback/receive routing are scope-matchable only for address-only scopes (selector_rules = []). They MUST be rejected when explicit selector matching is required. - Contracts with non-standard selector parsing are NOT supported.
Examples:
{ target: 0x123, selector_rules: [{selector: 0xaabbccdd, recipients: []}, {selector: 0xeeff0011, recipients: []}] }: allow two selectors on one target.{ target: 0x123, selector_rules: [] }: address-only scoping (any calldata shape on0x123, including selectorless/fallback-style calls).allowedCalls = None: unrestricted key.allowedCalls = Some([]): key is authorized but cannot make scoped calls.{ target: tokenX, selector_rules: [{selector: 0xa9059cbb, recipients: [0xReceiver]}] }: allowtransferonly whento == 0xReceiver.{ target: tokenX, selector_rules: [{selector: 0xa9059cbb, recipients: [0xA, 0xB]}] }: allowtransferonly whentois in{0xA, 0xB}.- Distinct target scopes are independent: allowing selector
son targetAnever allows selectorson targetB.
Existing fields remain, with a trailing optional call-scope field:
KeyAuthorization {
chain_id: u64,
key_type: SignatureType,
key_id: address,
expiry: Option<u64>,
limits: Option<Vec<TokenLimit>>,
allowed_calls: Option<Vec<CallScope>>, // New trailing field
}
/// @notice Emitted when an access key spends tokens against a spending limit
/// @param account The account whose key was used
/// @param publicKey The public key (address) that initiated the spend
/// @param token The token address being spent
/// @param amount The amount spent in this transaction
/// @param remainingLimit The remaining spending limit after this spend
event AccessKeySpend(
address indexed account,
address indexed publicKey,
address indexed token,
uint256 amount,
uint256 remainingLimit
);This event MUST be emitted whenever an access-key transaction deducts from a spending limit (one-time or periodic).
/// @notice Authorizes a key with enhanced permissions
/// @param keyId The key identifier (address derived from public key)
/// @param signatureType 0: secp256k1, 1: P256, 2: WebAuthn
/// @param expiry Block timestamp when key expires
/// @param enforceLimits Whether spending limits are enforced for this key
/// @param spendingLimits Token spending limits (may include periodic limits)
/// @param allowAnyCalls Whether the key is unrestricted (`true`) or scoped by `allowedCalls` (`false`)
/// @param allowedCalls Per-target call scopes for this key.
function authorizeKey(
address keyId,
SignatureType signatureType,
uint64 expiry,
bool enforceLimits,
TokenLimit[] calldata spendingLimits,
bool allowAnyCalls,
CallScope[] calldata allowedCalls
) external;
/// @notice Creates or replaces one or more target scopes for a key
/// @dev Root key only. For each scope, if `target` does not exist, creates a new scope; otherwise replaces it atomically.
/// @dev `scopes` MUST NOT be empty, and duplicate `target` entries MUST be rejected.
/// @dev `scope.selectorRules = []` allows any selector on `scope.target`; it does not block the target.
/// @dev If a selector rule has `recipients`, `target` MUST be TIP-20 and `selector` MUST be transfer/approve (+memo).
/// @dev For each selector rule, `recipients = []` means no recipient restriction.
function setAllowedCalls(
address keyId,
CallScope[] calldata scopes
) external;
/// @notice Removes one target scope for a key
function removeAllowedCalls(address keyId, address target) external;
/// @notice Returns whether a key is call-scoped together with its configured call scopes
/// @dev `isScoped = false` means unrestricted.
/// @dev `isScoped = true && calls.length == 0` means scoped deny-all.
function getAllowedCalls(
address account,
address keyId
) external view returns (bool isScoped, CallScope[] memory calls);
/// @notice Returns remaining limit for a token, accounting for period resets
function getRemainingLimit(
address account,
address keyId,
address token
) external view returns (uint256 remaining, uint64 periodEnd);getAllowedCalls(account, keyId) semantics:
isScoped = false, calls = []: unrestricted key.isScoped = true, calls = []: scoped key with no allowed targets.isScoped = true, calls = [c1, ...]: scoped key with the listed allowlist.- Missing, revoked, or expired access keys return
isScoped = true, calls = [].
On each spend attempt for (account, key, token):
- Implementations MUST load the configured
TokenLimitand runtimeTokenLimitState. - If
period == 0, the limit is one-time and no period rollover is applied. - If
period > 0andblock.timestamp >= periodEnd, implementations MUST resetremainingInPeriodtolimitand advanceperiodEndby whole multiples ofperiodso thatperiodEnd > block.timestamp. - If
amount > remainingInPeriod, implementations MUST revertSpendingLimitExceeded(). - Otherwise, implementations MUST decrement
remainingInPeriodbyamount.
updateSpendingLimit(account,key,token,newLimit) semantics:
- MUST update the configured
limitfor that(account,key,token). - MUST set
remainingInPeriod = newLimit. - MUST NOT change
period. - MUST NOT change
periodEnd. - Therefore changing
periodrequires re-authorizing the key (or removing and recreating that token limit entry).
Call-scope checks use map lookups keyed by (account_key, target, selector) plus optional selector-level recipient allowlists.
Scoped-call validation is performed in a metered pre-execution phase after transaction validation succeeds but before the first user call executes. It is not a transaction-validity condition.
If any call fails scoped-call validation, the transaction execution MUST fail atomically before any user call in the batch begins.
If a transaction is signed with an access key (key != Address::ZERO), contract creation MUST be rejected as an invalid transaction in all configurations.
This ban applies regardless of:
- Whether
allowed_callsisNoneorSome(...). - Whether any target scope has
selector_rules = [](allow-any-selector). - Whether the creation call appears in a batch.
Only the root key (key == Address::ZERO) may submit contract-creation calls; this is not a global create ban.
For each call:
- If the transaction uses an access key and the call is contract creation, implementations MUST reject the transaction as invalid before execution.
- If
allowed_calls = None, implementations MUST allow the call (subject to the contract-creation ban). - If
allowed_calls = Some(...), implementations MUST enforce target and selector matching. - If no target scope exists for
destination, implementations MUST fail execution before the first user call begins. - If the target scope is
selector_rules = [], implementations MUST allow the call, including selectorless/fallback-style calldata. - If the target scope has explicit selector rules and calldata does not provide at least 4 selector bytes, implementations MUST fail execution before the first user call begins.
- If the target scope has explicit selector rules, there MUST be a rule for the selector; otherwise implementations MUST fail execution before the first user call begins.
- If the matched rule has
recipients = [], implementations MUST allow the call. - If the matched rule has
recipients = [a1, ...], implementations MUST decode ABI argument0as anaddressand require membership in that list. - For a selector rule with a non-empty
recipientslist, if calldata is shorter than4 + 32bytes, implementations MUST fail execution before the first user call begins. - For a selector rule with a non-empty
recipientslist, implementations MUST enforce canonical ABIaddressencoding for argument0(upper 12 bytes zero) before membership check; otherwise implementations MUST fail execution before the first user call begins.
For AA transactions with multiple calls, each call MUST be validated independently in the metered pre-execution phase before execution of the first user call begins.
If any call fails scope validation:
- The batch MUST fail atomically.
- No user call in the batch may execute.
- The failure MUST be reported as an execution failure rather than as an invalid transaction.
setAllowedCalls MUST be root-key-only and MUST apply create-or-replace semantics per target.
setAllowedCalls(keyId, [])MUST revert; an empty scope batch is ambiguous and MUST NOT act as a no-op or mode toggle.selectorRules = []setsselector_rules = Nonesemantics (any selector allowed ontarget).removeAllowedCalls(keyId, target)disables that target scope.- Implementations MUST enforce at most one scope per target for each
(account, key). - Selector rules MUST be unique by
selectorwithin a target scope. - If any rule has
recipients = Some([..]),targetMUST be a TIP-20 token address. - If any rule has
recipients = Some([..]), itsselectorMUST be one of:0xa9059cbb(transfer(address,uint256))0x095ea7b3(approve(address,uint256))0x95777d59(transferWithMemo(address,uint256,bytes32))
- If any rule has
recipients = Some([..]), each recipient in that list MUST be non-zero. - If any rule has
recipients = Some([..]), recipients in that list MUST be unique. - If any selector-rule validity rule is violated, implementations MUST reject the authorization (or revert
setAllowedCalls).
Rationale for rule 2 (removeAllowedCalls disables a target scope):
- This avoids unbounded gas from deletion-time slot iteration.
- Prior selector rows may remain in state, but the removed target scope no longer participates in matching.
- Keys may mix one-time and periodic token limits.
- Spending limits and call scopes are independent checks; both must pass.
updateSpendingLimit()updates limit andremainingInPeriod, but does not changeperiodorperiodEnd.allowed_calls = Noneis unrestricted for non-create calls;Some([])is scoped mode with no allowed calls.- Every scope has an explicit target address, so there is no wildcard-target precedence ambiguity.
- Per-target updates are create-or-replace and duplicate target scopes are not allowed.
- Selector-level recipient allowlists are optional and only valid for TIP-20 targets and the constrained selectors above.
- Selector-level recipient allowlists are checked after selector match and before call execution.
- This TIP does not introduce generic calldata predicates, offset math, or wildcard argument matching.
Wallet UX recommendation (non-consensus):
- Wallets SHOULD default to scoped keys (non-empty
selectorRules) and require explicit user opt-in for unrestricted target scopes (selectorRules = []).
This TIP only specifies the additional intrinsic gas delta for call scopes in handler-side key_authorization charging.
Existing key-authorization charging (signature verification, existing-key read, base key write, token-limit writes, and buffer) remains unchanged.
Per-transaction scoped-call matching for access-key transactions is not charged as intrinsic gas. It is charged by normal metered execution in the scoped-call pre-execution phase described above.
Definitions:
-
SSTORE_SET = sstore_set_without_load_cost. -
S= number of targets with configured call scope. -
K= total selector rules across all configured targets. -
C= total selector rules with a non-emptyrecipientslist. -
W= total recipient entries across all constrained selector rules. Scoped-call storage writes counted for intrinsic gas: -
Restricted-mode marker:
1slot whenallowed_callsisSome(...). -
Each target scope writes
3slots: target-set length, target-set value, and target-set position. -
Each selector rule writes
3slots: selector-set length, selector-set value, and selector-set position. -
Each recipient-constrained selector writes
1additional slot for recipient-set length. -
Each recipient entry writes
2slots: recipient-set value and recipient-set position.
gas_key_authorization_new = gas_key_authorization_existing
+ SSTORE_SET * scope_slots
scope_slots = 0 if allowed_calls is None
= 1 if allowed_calls is Some([]) // explicit restricted-mode marker
= 1 + 3*S + 3*K + C + 2*W if allowed_calls is Some(scopes)
Justification for 1 + 3*S + 3*K + C + 2*W: 1 stores restricted mode, each target scope materializes as three set writes, each selector rule materializes as three set writes, each constrained selector writes one recipient-set length slot, and each recipient writes two set-membership slots.
Implementations may also charge a small rounded helper overhead for scoped-key authorization bookkeeping that is not captured by raw storage-row counts alone.
This overhead exists because fresh scope persistence includes additional bookkeeping such as clearing the empty scope tree, maintaining per-layer set metadata, and materializing recipient sets.
Tempo's T4 implementation rounds this overhead upward using the same scope cardinalities:
extra_scope_gas = 5_000 + 7_000*S + 7_000*K + 5_000*W
This rounding is intentional. The design goal is to avoid materially underpricing larger scope trees while keeping pricing simple and predictable; slight overcharging is acceptable.
Bounds:
- Implementations MUST reject any selector rule with a non-empty
recipientslist whosetargetis not a TIP-20 token address. - Implementations MUST reject any selector rule with a non-empty
recipientslist and selector outside the fixed constrained-selector set. - Implementations MUST reject duplicate selector rules for the same
(target, selector). - Implementations MUST reject duplicate recipients inside a selector rule.
No additional flat gas is specified here for precompile methods (setAllowedCalls, getAllowedCalls, etc.); those are charged by normal EVM metering at execution time.
Authorization digest format:
key_auth_digest = keccak256(rlp([
chain_id,
key_type,
key_id,
expiry?,
limits?,
allowed_calls?
]))
limits = rlp([token, limit]) if period == 0
= rlp([token, limit, period]) if period > 0
RLP safety note:
- Implementations MUST use canonical RLP encoding for all fields.
- The signed payload is a typed RLP list; distinct field tuples produce distinct canonical encodings (no cross-field preimage ambiguity under canonical RLP).
KeyAuthorization := RLP([
chain_id: u64,
key_type: u8,
key_id: address,
expiry?: uint64,
limits?: [TokenLimit, ...],
allowed_calls?: [CallScope, ...]
])
TokenLimit := RLP([
token: address,
limit: uint256,
period: uint64
])
// Canonical one-time form omits `period` entirely.
// Omitted `period` decodes to `period = 0`, i.e. a non-periodic one-time spending limit.
TokenLimit(one-time) := RLP([
token: address,
limit: uint256
])
CallScope := RLP([
target: address,
selector_rules: [SelectorRule, ...] | []
])
SelectorRule := RLP([
selector: bytes4,
recipients: [address, ...] | []
])
Optional encoding rules:
- Optional scalar fields (
expiry) useNone => 0x80. limits = Noneuses0x80.- Top-level
allowed_calls = Noneis canonically omitted on wire. Implementations MUST also accept explicit0x80forallowed_calls = Noneas equivalent non-canonical input. - Nested scope-list fields (
selector_rulesandrecipients) are always encoded explicitly. Allow-all uses RLP empty list (0xc0). - Non-empty list values encode as normal lists.
- For
TokenLimit, one-time limits (period == 0) canonically use the two-field form. Implementations MUST also accept the explicit three-field form withperiod = 0as equivalent non-canonical input. - Each
SelectorRule.selectorMUST decode to exactly 4 bytes; otherwise the authorization MUST be rejected.
Current layout:
keys[account][keyId] -> AuthorizedKeyspending_limits[(account,keyId)][token] -> U256
Additive periodic-limit layout:
| Mapping | Type | Description |
|---|---|---|
spending_limits[account_key][token] |
U256 |
Remaining amount / remainingInPeriod |
spending_limit_period_state[account_key][token] |
struct { max, period, period_end } |
Periodic limit metadata |
Call-scope storage is account-scoped and represented as nested scope membership, with a key-level scoped/unrestricted flag:
| Path | Type | Description |
|---|---|---|
key_scopes[account_key].is_scoped |
bool |
Whether the key is unrestricted or uses scoped target membership |
key_scopes[account_key].targets |
Set<address> |
Scoped target membership |
key_scopes[account_key].target_scopes[target].selectors |
Set<u32> |
Explicit selector membership |
key_scopes[account_key].target_scopes[target].selector_scopes[selector].recipients |
Set<address> |
Selector-level recipient membership |
Absent target or selector entries represent disabled inner scopes; implementations do not need separate target-level or selector-level mode bits.
account_key = keccak256(account || key_id) to avoid cross-account collisions for shared key IDs.
Implementations MAY maintain additional internal indexes or equivalent layouts so long as semantics remain unchanged.
The following MUST be fork-gated:
- New
TokenLimitdecode/encode behavior. allowed_callsdecode/encode behavior.selector_rulesdecode/encode behavior.- Periodic reset logic.
- Call-scope validation logic.
- Selector-rule recipient-allowlist calldata validation logic.
- New precompile storage writes/reads for periodic + call-scope data.
- New precompile storage writes/reads for selector-level recipient allowlists.
- Updated precompile read APIs (
getAllowedCalls(account,key), richergetRemainingLimit). - New mutator function
setAllowedCalls, which can only be called by root key. - Global ban on contract creation when using access keys.
- Selector-width enforcement (
selector length == 4only). - Constrained-selector allowlist and argument-0 canonical address checks.
- TIP-20 target verification for selector rules with recipient allowlists.
Pre-fork blocks MUST replay with pre-fork semantics to preserve state roots.
periodEndis monotonic and never set to the past.remainingInPeriod <= limitafter any operation.- Expiry check runs before spending and call-scope checks.
- If
key != Address::ZERO, any contract-creation call MUST cause the transaction to be rejected as invalid before execution, regardless ofallowed_calls. allowed_calls = Noneallows all non-create calls;allowed_calls = Some(...)requires target+selector-rule match and otherwise causes the transaction to be rejected as invalid before execution.- In scoped mode, calldata must contain at least 4 selector bytes only when explicit selector matching is required; address-only scopes allow selectorless/fallback-style calldata.
- For each
(account, key), target scopes are unique, selector rules are unique per target, and recipients are unique per selector rule. setAllowedCalls(..., scopes)withscope.selectorRules = []for a scope allows any selector on that target;removeAllowedCalls(keyId, target)disables that target scope.- Selector rules with recipient allowlists are valid only for TIP-20 targets and only for the fixed constrained selector set.
- For recipient-allowlisted rules, calldata argument
0must be a canonically encoded ABI address and must be in the configured recipient set. - In the Solidity ABI,
selectorRules[i].recipients = []means that selector has no recipient restriction.
- Periodic reset after elapsed period.
- No rollover of unused periodic allowance.
- Address + multi-selector scope allow.
- Address-only allow (
selector_rules=[]). - Deny when no scope matches.
allowed_calls=Noneallows all non-create calls.allowed_calls=Some([])denies all calls.- Mixed one-time and periodic token limits.
- Existing keys continue to function after the fork.
- Batch validation rejects the transaction before execution when any call is invalid.
- Shared key IDs across accounts cannot overwrite each other’s scopes.
- Reject calls that do not provide at least 4 selector bytes when explicit selector matching is required.
setAllowedCalls(..., scopes)withscope.selectorRules = []for a scope allows any selector on that target.setAllowedCallscreate-or-replace semantics are enforced.removeAllowedCalls(keyId, target)removes that target scope; if no target scopes remain, the key stays scoped but matches no calls.- Address-only scopes allow selectorless/fallback-style calls to the scoped target.
- Access-key transactions with CREATE as first call are rejected.
- Access-key transactions with any CREATE in a batch are rejected.
- For constrained TIP-20 selectors (
transfer,approve,transferWithMemo), calls succeed iff calldata argument0is in the configured recipient set. - Single-recipient and multi-recipient selector rules both enforce the same membership rule.
- Reject the transaction before execution when a selector rule with a recipient allowlist is matched and calldata is shorter than
4 + 32bytes. - Reject the transaction before execution when a selector rule with a recipient allowlist is matched and ABI argument
0is not canonically encoded as an address. - Reject selector rules with recipient allowlists for selectors outside the fixed constrained-selector set.
- Reject duplicate selector rules for the same
(target, selector). - Reject duplicate recipients within a selector rule.
- Reject key authorization when selector rules with recipient allowlists are used on a non-TIP-20 target.
- AccountKeychain docs
- Tempo Transactions
- IAccountKeychain.sol
- GitHub Issue #1865 - Periodic spending limits
- GitHub Issue #1491 - Destination address scoping