Skip to content

feat(precompiles): implement TIP-1011 restrictions#3385

Merged
legion2002 merged 20 commits intotanishk/tip-1011-primitives-exactfrom
tanishk/tip-1011-precompiles-exact
Apr 1, 2026
Merged

feat(precompiles): implement TIP-1011 restrictions#3385
legion2002 merged 20 commits intotanishk/tip-1011-primitives-exactfrom
tanishk/tip-1011-precompiles-exact

Conversation

@legion2002
Copy link
Copy Markdown
Contributor

@legion2002 legion2002 commented Mar 30, 2026

Stack Context

This is the middle PR in the TIP-1011 stack.

  • Parent: #3384 feat(primitives): add TIP-1011 wire format and ABI
  • Child: #3386 feat(node): enforce TIP-1011 restrictions

What This PR Does

This PR implements TIP-1011 inside the AccountKeychain precompile:

  • periodic spending-limit state and reset behavior
  • allowed-call target / selector / recipient scoping
  • precompile mutators and readers for the new restriction state
  • hardfork-gated dispatch and direct readers that depend on the new layout

This is the PR where the spec becomes concrete precompile and storage behavior. It intentionally stops short of handler and txpool runtime enforcement, which lands in PR3.

How To Review

Review this like a smart-contract PR.

  1. Start in crates/precompiles/src/account_keychain/mod.rs and verify the storage model and invariants: uniqueness, rollover behavior, normalization, selector and recipient validation, and any assumptions about reads and writes.
  2. Look for efficiency opportunities as you go: slot count, tight packing, unnecessary writes, and whether the chosen layout is the right tradeoff for expected access patterns.
  3. Check access gating carefully for the important mutators, especially authorizeKey, setAllowedCalls, removeAllowedCalls, and updateSpendingLimit.
  4. Review crates/precompiles/src/account_keychain/dispatch.rs for ABI routing and fork gating.
  5. As a final pass, think ahead to how the handler will interact with these precompile functions and where they will be used in #3386.
  6. Related context: #3370 is a draft PR that adds an enumerable map utility and makes the targets / target_scopes path more efficient. It is worth keeping in mind when evaluating this layout.

Note

If you want the full end-to-end diff in one place, see #3323. It is useful for seeing how the ideas interact across the stack, and Cyclops also runs there.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 30, 2026

⚠️ Changelog not found.

A changelog entry is required before merging. We've generated a suggested changelog based on your changes:

Preview
---
tempo-contracts: minor
tempo-precompiles: minor
---

Added TIP-1011 allowed call scoping to the AccountKeychain precompile with per-target and per-selector restriction support, periodic spending limits with automatic rollover, and T3 hardfork gating that rejects legacy `authorizeKey` selectors post-T3 while preserving backward compatibility pre-T3.

Add changelog to commit this to your branch.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 476b865f8b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Ok(())
}

fn validate_selector_rules(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems that this check is entirely stateless and is something that we would want to do in the pool so ideally we have it as a helper on CallScope

@legion2002 legion2002 force-pushed the tanishk/tip-1011-precompiles-exact branch 2 times, most recently from 9f1ed91 to 5434753 Compare March 31, 2026 10:10
Comment on lines +1334 to +1335
let mut limit_state = self.spending_limits[limit_key][token].read()?;
limit_state.remaining = limit_state.remaining.saturating_add(amount);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, if a period rolls over resetting remaining to max and then a fee refund arrives for a transaction charged in the previous period, remaining gets pushed above max because saturating_add has no upper bound. We should clamp remaining to min(remaining, amount) after the add when period > 0 so that refunds never grant more budget than the owner originally authorized per period.

Copy link
Copy Markdown
Contributor Author

@legion2002 legion2002 Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I had thought about this, but don't think we can ever reach this path.
the refund is an atomic operation, so period rollover cannot happen in the middle of a tx, and the refund can never be more than the initial spending limit.

But I can add a min() clamp as a defense-in-depth improvement if you want, because it should be cheap to do the check.

@legion2002 legion2002 force-pushed the tanishk/tip-1011-precompiles-exact branch from 5434753 to 42d8683 Compare March 31, 2026 19:04
/// Key-level call scope.
#[derive(Debug, Clone, Storable, Default)]
pub struct KeyScope {
pub is_scoped: bool,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that we need this flag for KeyScope to distinguish between the key not allowing any calls and the key allowing all calls

however, for TargetScope and SelectorScope this flag seems redundant? given that we track Set of keys that actually exist it should not be possible to confuse a non-existing selector/target scope with an existing one because we would always perform the inclusion check via the Set?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my intuition here was to keep things symmetrical, and explicit to understand.
If we remove the flag from TargetScope/SelectorScope. An empty array in these scopes means "allow-all", but an empty array in "KeyScope" would mean deny-all.

Having thought about it more, I am changing my mental model to -

  • it doesn't make logical sense to keep a scope around if all the selectors/entries in that scope are blocked. So in this case the default meaning of an empty list is "allow-all"

But we have an exception in call scope, because it is the topmost level, so we can't just remove the entry, for the deny-all case.
In this case we add an explicit bool for "allow-all", and the default empty list value is "deny-all"

I think this is still slightly confusing in the implementation, but people would not be interacting with storage slots directly in most cases.
And I have also added explicit scoping bools to the get_allowed_calls return value to make this more clear.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in 72809e7

Comment on lines +839 to +849
let target = scope.target;

if !scope.selectorRules.is_empty() {
self.validate_selector_rules(target, &scope.selectorRules)?;
}

if !self.key_scopes[account_key].targets.contains(&target)? {
let count = self.key_scopes[account_key].targets.len()?;
if count >= MAX_CALL_SCOPES as usize {
return Err(AccountKeychainError::scope_limit_exceeded().into());
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upsert_target_scope doesn't reject Address::ZERO as target. IIUC Address::ZERO is used as the sentinel value in get_allowed_calls to represent "scoped but no targets".

If a user calls setAllowedCalls with target = address(0), it'll be inserted into the targets set, corrupting the sentinel distinction, get_allowed_calls would return it as a real target, and validate_call_scope_for_transaction would allow calls to address(0).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in 72809e7 and 66421f5

Copy link
Copy Markdown
Member

@fgimenez fgimenez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, a couple suggestions

Comment on lines +491 to +493
if scopes.is_empty() {
return Ok(());
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm i don't think it's expected that we'd just ignore this call if scopes are empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have a separate endpoint "removeAllowedCalls", if people want to remove scopes.
do you mean we should revert here instead?

We can't iterate over the scopes and remove them ourselves, because we don't have an enumerable map for this onchain. We expect that people will call removeAllowedCalls with the scopes instead. Or disable the key altogether.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then empty scopes should probably make the key unrestricted?

Comment on lines +1109 to +1111
// Check key is valid (exists and not revoked)
let current_timestamp = self.storage.timestamp().saturating_to::<u64>();
let key = self.load_active_key(account, key_id, current_timestamp)?;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

couldn't load_active_key fetch timestamp internally?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are a couple of places where the timestamp is already available, so we don't have to fetch it again, like the validate_keychain_authorization fn

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not expensive to fetch it at all, we carry it around as a u64

@legion2002 legion2002 force-pushed the tanishk/tip-1011-precompiles-exact branch from a29e4af to e51b9cf Compare April 1, 2026 13:01
## Stack Context

This is the top PR in the TIP-1011 stack.
- Parent: [#3385](#3385)
`feat(precompiles): implement TIP-1011 restrictions`
- Child: none

## What This PR Does

This PR enforces TIP-1011 at runtime:
- handler-side validation and intrinsic gas accounting for
`key_authorization`
- execution-time enforcement of periodic limits and call scopes
- rejection of invalid access-key contract-creation or scoped calls
before execution
- txpool validation so invalid transactions are filtered before
propagation

This is the integration layer where the protocol shape from PR1 and the
precompile semantics from PR2 are actually enforced by the node.

## How To Review

1. Start with `crates/revm/src/handler.rs`. This is the core of the PR:
hardfork gating, intrinsic gas, same-transaction authorization behavior,
and per-call enforcement.
2. Then review `crates/transaction-pool/src/validator.rs` and confirm
txpool admission matches the runtime rules closely enough to reject bad
transactions early without diverging from execution.
3. Review the remaining runtime tests and regressions after that.
4. Think about the stack as a whole while reviewing this PR: how the
signed payload from [#3384](#3384)
feeds the precompile state from
[#3385](#3385), and how this
handler and validator layer consumes both.

## Note

If you want the full end-to-end diff in one place, see
[#3323](#3323). It is useful for
seeing how the ideas interact across the stack, and Cyclops also runs
there.
@legion2002 legion2002 requested a review from rakita as a code owner April 1, 2026 13:11
@legion2002 legion2002 merged commit d604c15 into tanishk/tip-1011-primitives-exact Apr 1, 2026
17 of 28 checks passed
@legion2002 legion2002 deleted the tanishk/tip-1011-precompiles-exact branch April 1, 2026 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants