Skip to content

Latest commit

 

History

History
629 lines (466 loc) · 28.1 KB

File metadata and controls

629 lines (466 loc) · 28.1 KB
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

TIP-1011: Enhanced Access Key Permissions

Abstract

This TIP extends Access Keys with three permission features:

  1. Periodic spending limits that reset on fixed intervals.
  2. Call scoping that limits what addresses a key can call and which selectors it can use.
  3. Limited calldata recipient scoping for token transfer/approval selectors.

Motivation

Currently Access Keys support per-token limits and expiry, but miss two practical controls.

Periodic Spending Limits

One-time limits cannot express recurring allowances.

Use cases:

  1. Subscription billing (10 USDC / month).
  2. Payroll schedules (monthly budgeted payouts).
  3. Rate-limited agent/API budgets.

Call Scoping (Target + Selector Set)

Users need finer controls than "any call". They want keys like:

  1. "Only call swap() and exactInput() on DEX X."
  2. "Only call gameplay methods on contract Y."
  3. "Only perform plain transfers, not token extension methods."
  4. "Only vote() on governance contracts."

Current workaround: Deploy a proxy contract that enforces destination/function restrictions, adding gas overhead and complexity.

Recipient-Bound Token Calls

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.


Specification

Extended Data Structures

Conventions used in this section:

  1. Protocol/RLP structs are written with Rust-like Option<...> notation.
  2. Solidity ABI structs are listed separately where ABI cannot directly represent protocol Option semantics.

TokenLimit

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:

  1. TokenLimitState is initialized when the key is authorized (or a limit is created via root mutation), not lazily at first spend.
  2. period == 0 initializes remainingInPeriod = limit and periodEnd = 0.
  3. period > 0 initializes remainingInPeriod = limit and periodEnd = authorize_time + period.
  4. For a given (account,key,token), there is exactly one active TokenLimit; duplicate token entries in a single authorization MUST be rejected.

CallScope

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:

  1. selectorRules = [] allows any selector on target.
  2. selectorRules = [r1, ...] allows exactly the listed selectors.
  3. To remove a target scope in the Solidity precompile API, callers MUST use removeAllowedCalls(keyId, target).

selector_rules behavior:

  1. []: allow any selector.
  2. [r1, r2, ...]: allow exactly the listed selector rules.

In the Solidity precompile API, omitting a target scope blocks that target; selectorRules = [] does not.

SelectorRule

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:

  1. recipients = [] allows any recipient for that selector.
  2. recipients = [a1, ...] constrains the selector to that recipient set.

SelectorRule.recipients behavior:

  1. [] => no calldata recipient checks for this selector.
  2. [a1, a2, ...] => enforce arg0 recipient membership for this selector.
  3. Selector rules MUST be unique per target (selector appears at most once).

Supported constrained selectors in this TIP:

  1. 0xa9059cbb => transfer(address,uint256)
  2. 0x095ea7b3 => approve(address,uint256)
  3. 0x95777d59 => transferWithMemo(address,uint256,bytes32)

If a selector rule uses recipients = [..], then:

  1. target MUST be a TIP-20 token address.
  2. selector MUST be one of the constrained selectors above.
  3. 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.

  1. Each SelectorRule.selector MUST be exactly 4 bytes.
  2. Implementations MUST revert when decoding or accepting any selector whose length is not exactly 4 bytes.
  3. 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.
  4. Contracts with non-standard selector parsing are NOT supported.

Examples:

  1. { target: 0x123, selector_rules: [{selector: 0xaabbccdd, recipients: []}, {selector: 0xeeff0011, recipients: []}] }: allow two selectors on one target.
  2. { target: 0x123, selector_rules: [] }: address-only scoping (any calldata shape on 0x123, including selectorless/fallback-style calls).
  3. allowedCalls = None: unrestricted key.
  4. allowedCalls = Some([]): key is authorized but cannot make scoped calls.
  5. { target: tokenX, selector_rules: [{selector: 0xa9059cbb, recipients: [0xReceiver]}] }: allow transfer only when to == 0xReceiver.
  6. { target: tokenX, selector_rules: [{selector: 0xa9059cbb, recipients: [0xA, 0xB]}] }: allow transfer only when to is in {0xA, 0xB}.
  7. Distinct target scopes are independent: allowing selector s on target A never allows selector s on target B.

KeyAuthorization

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
}

Interface Changes

Events

/// @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).

IAccountKeychain.sol

/// @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:

  1. isScoped = false, calls = []: unrestricted key.
  2. isScoped = true, calls = []: scoped key with no allowed targets.
  3. isScoped = true, calls = [c1, ...]: scoped key with the listed allowlist.
  4. Missing, revoked, or expired access keys return isScoped = true, calls = [].

Semantic Behavior

Periodic Limit Reset Logic

On each spend attempt for (account, key, token):

  1. Implementations MUST load the configured TokenLimit and runtime TokenLimitState.
  2. If period == 0, the limit is one-time and no period rollover is applied.
  3. If period > 0 and block.timestamp >= periodEnd, implementations MUST reset remainingInPeriod to limit and advance periodEnd by whole multiples of period so that periodEnd > block.timestamp.
  4. If amount > remainingInPeriod, implementations MUST revert SpendingLimitExceeded().
  5. Otherwise, implementations MUST decrement remainingInPeriod by amount.

updateSpendingLimit(account,key,token,newLimit) semantics:

  1. MUST update the configured limit for that (account,key,token).
  2. MUST set remainingInPeriod = newLimit.
  3. MUST NOT change period.
  4. MUST NOT change periodEnd.
  5. Therefore changing period requires re-authorizing the key (or removing and recreating that token limit entry).

Call Validation Logic

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.

Access-Key Contract Creation Ban

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:

  1. Whether allowed_calls is None or Some(...).
  2. Whether any target scope has selector_rules = [] (allow-any-selector).
  3. 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.

Single Call Validation

For each call:

  1. If the transaction uses an access key and the call is contract creation, implementations MUST reject the transaction as invalid before execution.
  2. If allowed_calls = None, implementations MUST allow the call (subject to the contract-creation ban).
  3. If allowed_calls = Some(...), implementations MUST enforce target and selector matching.
  4. If no target scope exists for destination, implementations MUST fail execution before the first user call begins.
  5. If the target scope is selector_rules = [], implementations MUST allow the call, including selectorless/fallback-style calldata.
  6. 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.
  7. 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.
  8. If the matched rule has recipients = [], implementations MUST allow the call.
  9. If the matched rule has recipients = [a1, ...], implementations MUST decode ABI argument 0 as an address and require membership in that list.
  10. For a selector rule with a non-empty recipients list, if calldata is shorter than 4 + 32 bytes, implementations MUST fail execution before the first user call begins.
  11. For a selector rule with a non-empty recipients list, implementations MUST enforce canonical ABI address encoding for argument 0 (upper 12 bytes zero) before membership check; otherwise implementations MUST fail execution before the first user call begins.

Batch Validation

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:

  1. The batch MUST fail atomically.
  2. No user call in the batch may execute.
  3. The failure MUST be reported as an execution failure rather than as an invalid transaction.

Root-Controlled Scope Updates

setAllowedCalls MUST be root-key-only and MUST apply create-or-replace semantics per target.

  1. setAllowedCalls(keyId, []) MUST revert; an empty scope batch is ambiguous and MUST NOT act as a no-op or mode toggle.
  2. selectorRules = [] sets selector_rules = None semantics (any selector allowed on target).
  3. removeAllowedCalls(keyId, target) disables that target scope.
  4. Implementations MUST enforce at most one scope per target for each (account, key).
  5. Selector rules MUST be unique by selector within a target scope.
  6. If any rule has recipients = Some([..]), target MUST be a TIP-20 token address.
  7. If any rule has recipients = Some([..]), its selector MUST be one of:
    1. 0xa9059cbb (transfer(address,uint256))
    2. 0x095ea7b3 (approve(address,uint256))
    3. 0x95777d59 (transferWithMemo(address,uint256,bytes32))
  8. If any rule has recipients = Some([..]), each recipient in that list MUST be non-zero.
  9. If any rule has recipients = Some([..]), recipients in that list MUST be unique.
  10. 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):

  1. This avoids unbounded gas from deletion-time slot iteration.
  2. Prior selector rows may remain in state, but the removed target scope no longer participates in matching.

Interaction Rules

  1. Keys may mix one-time and periodic token limits.
  2. Spending limits and call scopes are independent checks; both must pass.
  3. updateSpendingLimit() updates limit and remainingInPeriod, but does not change period or periodEnd.
  4. allowed_calls = None is unrestricted for non-create calls; Some([]) is scoped mode with no allowed calls.
  5. Every scope has an explicit target address, so there is no wildcard-target precedence ambiguity.
  6. Per-target updates are create-or-replace and duplicate target scopes are not allowed.
  7. Selector-level recipient allowlists are optional and only valid for TIP-20 targets and the constrained selectors above.
  8. Selector-level recipient allowlists are checked after selector match and before call execution.
  9. This TIP does not introduce generic calldata predicates, offset math, or wildcard argument matching.

Wallet UX recommendation (non-consensus):

  1. Wallets SHOULD default to scoped keys (non-empty selectorRules) and require explicit user opt-in for unrestricted target scopes (selectorRules = []).

Gas And Complexity Bounds

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:

  1. SSTORE_SET = sstore_set_without_load_cost.

  2. S = number of targets with configured call scope.

  3. K = total selector rules across all configured targets.

  4. C = total selector rules with a non-empty recipients list.

  5. W = total recipient entries across all constrained selector rules. Scoped-call storage writes counted for intrinsic gas:

  6. Restricted-mode marker: 1 slot when allowed_calls is Some(...).

  7. Each target scope writes 3 slots: target-set length, target-set value, and target-set position.

  8. Each selector rule writes 3 slots: selector-set length, selector-set value, and selector-set position.

  9. Each recipient-constrained selector writes 1 additional slot for recipient-set length.

  10. Each recipient entry writes 2 slots: 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.

Rounded Helper Overhead

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:

  1. Implementations MUST reject any selector rule with a non-empty recipients list whose target is not a TIP-20 token address.
  2. Implementations MUST reject any selector rule with a non-empty recipients list and selector outside the fixed constrained-selector set.
  3. Implementations MUST reject duplicate selector rules for the same (target, selector).
  4. 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.

Encoding

Signing Format

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:

  1. Implementations MUST use canonical RLP encoding for all fields.
  2. The signed payload is a typed RLP list; distinct field tuples produce distinct canonical encodings (no cross-field preimage ambiguity under canonical RLP).

Transaction Authorization 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:

  1. Optional scalar fields (expiry) use None => 0x80.
  2. limits = None uses 0x80.
  3. Top-level allowed_calls = None is canonically omitted on wire. Implementations MUST also accept explicit 0x80 for allowed_calls = None as equivalent non-canonical input.
  4. Nested scope-list fields (selector_rules and recipients) are always encoded explicitly. Allow-all uses RLP empty list (0xc0).
  5. Non-empty list values encode as normal lists.
  6. For TokenLimit, one-time limits (period == 0) canonically use the two-field form. Implementations MUST also accept the explicit three-field form with period = 0 as equivalent non-canonical input.
  7. Each SelectorRule.selector MUST decode to exactly 4 bytes; otherwise the authorization MUST be rejected.

Precompile Storage Changes

Current layout:

  1. keys[account][keyId] -> AuthorizedKey
  2. spending_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.

Hardfork-Gated Features

The following MUST be fork-gated:

  1. New TokenLimit decode/encode behavior.
  2. allowed_calls decode/encode behavior.
  3. selector_rules decode/encode behavior.
  4. Periodic reset logic.
  5. Call-scope validation logic.
  6. Selector-rule recipient-allowlist calldata validation logic.
  7. New precompile storage writes/reads for periodic + call-scope data.
  8. New precompile storage writes/reads for selector-level recipient allowlists.
  9. Updated precompile read APIs (getAllowedCalls(account,key), richer getRemainingLimit).
  10. New mutator function setAllowedCalls, which can only be called by root key.
  11. Global ban on contract creation when using access keys.
  12. Selector-width enforcement (selector length == 4 only).
  13. Constrained-selector allowlist and argument-0 canonical address checks.
  14. TIP-20 target verification for selector rules with recipient allowlists.

Pre-fork blocks MUST replay with pre-fork semantics to preserve state roots.


Invariants

  1. periodEnd is monotonic and never set to the past.
  2. remainingInPeriod <= limit after any operation.
  3. Expiry check runs before spending and call-scope checks.
  4. If key != Address::ZERO, any contract-creation call MUST cause the transaction to be rejected as invalid before execution, regardless of allowed_calls.
  5. allowed_calls = None allows all non-create calls; allowed_calls = Some(...) requires target+selector-rule match and otherwise causes the transaction to be rejected as invalid before execution.
  6. 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.
  7. For each (account, key), target scopes are unique, selector rules are unique per target, and recipients are unique per selector rule.
  8. setAllowedCalls(..., scopes) with scope.selectorRules = [] for a scope allows any selector on that target; removeAllowedCalls(keyId, target) disables that target scope.
  9. Selector rules with recipient allowlists are valid only for TIP-20 targets and only for the fixed constrained selector set.
  10. For recipient-allowlisted rules, calldata argument 0 must be a canonically encoded ABI address and must be in the configured recipient set.
  11. In the Solidity ABI, selectorRules[i].recipients = [] means that selector has no recipient restriction.

Test Cases

  1. Periodic reset after elapsed period.
  2. No rollover of unused periodic allowance.
  3. Address + multi-selector scope allow.
  4. Address-only allow (selector_rules=[]).
  5. Deny when no scope matches.
  6. allowed_calls=None allows all non-create calls.
  7. allowed_calls=Some([]) denies all calls.
  8. Mixed one-time and periodic token limits.
  9. Existing keys continue to function after the fork.
  10. Batch validation rejects the transaction before execution when any call is invalid.
  11. Shared key IDs across accounts cannot overwrite each other’s scopes.
  12. Reject calls that do not provide at least 4 selector bytes when explicit selector matching is required.
  13. setAllowedCalls(..., scopes) with scope.selectorRules = [] for a scope allows any selector on that target.
  14. setAllowedCalls create-or-replace semantics are enforced.
  15. removeAllowedCalls(keyId, target) removes that target scope; if no target scopes remain, the key stays scoped but matches no calls.
  16. Address-only scopes allow selectorless/fallback-style calls to the scoped target.
  17. Access-key transactions with CREATE as first call are rejected.
  18. Access-key transactions with any CREATE in a batch are rejected.
  19. For constrained TIP-20 selectors (transfer, approve, transferWithMemo), calls succeed iff calldata argument 0 is in the configured recipient set.
  20. Single-recipient and multi-recipient selector rules both enforce the same membership rule.
  21. Reject the transaction before execution when a selector rule with a recipient allowlist is matched and calldata is shorter than 4 + 32 bytes.
  22. Reject the transaction before execution when a selector rule with a recipient allowlist is matched and ABI argument 0 is not canonically encoded as an address.
  23. Reject selector rules with recipient allowlists for selectors outside the fixed constrained-selector set.
  24. Reject duplicate selector rules for the same (target, selector).
  25. Reject duplicate recipients within a selector rule.
  26. Reject key authorization when selector rules with recipient allowlists are used on a non-TIP-20 target.

References