Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-8273: Attestation-Gated Agentic Actions

An on-chain Agent Attestation Registry with transaction-scoped authorization via transient storage.

Authors Yunan Li, Qingzhi Zha (@rickzha610), Xianrui Qin (@xrqin), Vitto Rivabella (@eversmile12)
Created 2025-05-26
Discussion Link https://ethereum-magicians.org/t/erc-8273-attestation-gated-agentic-actions/28617
Requires EIP-165, EIP-1153

Abstract

This ERC defines a standard interface for an on-chain Agent Attestation Registry. The registry manages attestations issued by Attestors. Each attestation has a complete lifecycle, issuance and transaction-scoped consumption, and is described by an AttestationRecord containing:

  • subjectId / subjectType: identifies the attested subject, for example subjectId = agentId + subjectType = keccak256("ERC8004_AGENT"), independently of any specific identity system;
  • capability + actionDigest: two independent axes that identify the authorization scope. capability is a coarse-grained capability identifier, such as keccak256("DEFI_ACCESS_V1"), and expresses “which class of authorization this is.” actionDigest is a fine-grained action fingerprint and expresses “which concrete action this is bound to.” actionDigest = 0 denotes capability-only mode, where no concrete action is bound. A non-zero actionDigest is derived according to rules agreed between the Attestor and the integrating DApp, and must include the target contract, function selector, arguments, and a nonce or attestationId to provide replay protection. Per-chain deployment provides chain isolation; chainId need not appear in actionDigest.
  • evidenceHash: optionally references a hash of off-chain evidence.

Attestor is an existing role. It may attest to anything: whether an agent identity is genuine, what capability an agent has, agent reputation, or other claims. The precise semantics are defined by capability, and when needed by actionDigest; this ERC does not constrain them. The registry records, indexes, and enforces the attestation lifecycle. In the standard atomic path, an authorized Attestor calls the single bundled entry point attestAndCall; the registry opens an authorization window in transient storage, executes the action through a specified execution profile, and relies on the EVM to automatically clear the authorization state at the end of the transaction.

This ERC uses an atomic execution model: issuance and action execution for each attestation occur within a single transaction. There is no expiration mechanism, and there are no long-lived or session-based attestations. Active authorization state is stored through EIP-1153 TSTORE / TLOAD and is automatically cleared at the end of the transaction; no persistent active authorization exists. The authorization window is strictly limited to the transaction in which it is issued, and each attestation expresses its full authorization scope through capability + actionDigest. When an attestation must bind to a single concrete call, the integrating DApp uses a non-zero actionDigest in its query, so the authorization is valid only for the concrete action it computes and cannot be reused for unrelated operations under the same coarse-grained capability.

To ensure the target DApp sees the agent’s own wallet as msg.sender, this ERC abstracts “how the Registry causes the wallet to initiate the target call” as an execution profile. The direct wallet execution profile applies to ERC-7702 EOAs and AA wallets that support relayer execution: the Registry calls a relayer entry point exposed by the wallet, such as execute, and the wallet itself calls the target DApp. The ERC-4337 UserOperation profile applies to existing 4337 wallets: the Registry calls the EntryPoint and submits a UserOperation already authorized by the agentAA, and the EntryPoint follows the standard validateUserOp -> execute path. Atomicity is provided by the single attestAndCall entry point together with EIP-1153 transient storage.

This ERC does not specify how the Attestor evaluates subjects off-chain, what trust source it relies on, or what upper-layer platform architecture it uses. These are defined by concrete integrations, including but not limited to agent identity systems such as ERC-8004. This ERC only standardizes the on-chain attestation issuance entry point, lifecycle, and query interfaces. Questions such as which Attestors are trustworthy, how evaluation is performed, and what evidence format is used are left to upper-layer protocols or deployers.

Motivation

Problem Space

Directly assigning identity to Agents leaves several fundamental problems unresolved. For example, we cannot reliably guarantee that the same Agent always remains behind an ERC-8004 account. Identity can be assigned, but it is difficult to ensure that it remains bound to the same Agent over time.

The core motivation is not to prove “which Agent this is,” but to prove “whether the Agent executing this on-chain operation has the required qualification.” This is a subtle but important distinction, and it is the focus of this proposal.

This is particularly important for AI agent systems. When an on-chain transaction claims to be related to an agent, relying parties may need to answer several different questions:

  • Action provenance: “Was this operation really performed by an agent, or is a human pretending to be one?”
  • Operation authorization: “Is this agent allowed to perform this class of operation?”
  • Runtime verification: “Is the agent’s execution environment trustworthy?”
  • Compliance audit: “Which operations were autonomous, and which were human-directed?”

These questions are different, but they share the same structural need: an attestation record that is bound to a concrete operation and queryable on-chain. In this design, “queryable on-chain” represents active authorization only within the issuing transaction. After the transaction ends, the persistent record serves only as an audit record.

The current on-chain ecosystem lacks this standardized primitive. An identity NFT can tell you that “this address claims to be an agent,” but it cannot tell you anything about a particular operation.

This ERC provides attestation infrastructure, not attestation semantics. What exactly is being attested, action provenance, operation authorization, runtime verification, compliance status, or any combination of them, is defined by the Attestor through capability, and when needed through actionDigest.

Separation of Concerns: Identity vs. Per-Operation Attestation

Consider an analogy from aviation:

  • Identity — A pilot’s identity document proves who the person is. It is long-lived and independent of any particular flight.
  • Per-operation attestation — Each flight has its own flight log and dispatch release: who operated it, whether they were authorized, and whether the execution environment met requirements. These are operation-scoped and auditable.

The same separation applies to on-chain agent systems:

Layer Question Answered Corresponding System Lifecycle
Identity “Is this an agent? Who controls it?” ERC-8004 Long-lived, persistent
Per-operation attestation Any claim defined by an Attestor This ERC Per-operation, single transaction

Combining both layers into a single primitive would force impossible tradeoffs: identity that is too short-lived breaks long-term reputation, while per-operation attestations that are too persistent create residual false proofs. This ERC keeps per-operation attestation as a separate layer that composes cleanly with the identity layer.

Motivating Case 1: Agent Utility Tokens in a DeFi Protocol

Scenario: An AI agent autonomously operates in a DeFi liquidity protocol: performing cross-chain arbitrage, providing liquidity, and hedging risk positions. The protocol needs to distribute utility tokens to authenticated agents based on operational performance.

Problem: How can the RewardDistributor contract verify that the caller is an authenticated agent?

Solution: Before issuing an attestation, the Attestor performs an off-chain evaluation of the agent corresponding to the relevant capability, and when needed actionDigest. For DEFI_ACCESS_V1, this evaluation typically includes verifying the agent runtime integrity when necessary, such as through TEE remote attestation; reviewing the agent’s operational record according to protocol policy, such as performance, slashing history, and compliance screening; and confirming that the requested operation falls within the policy boundary expressed by the capability. The evaluation is performed under the Attestor’s own trust assumptions; this ERC does not specify its contents. The result of the evaluation is anchored on-chain through evidenceHash, which may be the keccak256 of an audit report, a Merkle root of evaluation items, a TEE attestation quote, or a ZK proof commitment.

After the evaluation passes, the Attestor calls the registry’s single atomic bundled entry point attestAndCall, completing two phases in the same transaction:

  1. attestAndCall(...) internally creates a persistent audit record and writes the attestationId into transient storage slots keyed by (wallet, capability, actionDigest) and (subjectHash, capability, actionDigest).
  2. The registry executes the action according to the executionProfile: it may call a direct execution entry point on the agent wallet, or it may call the EntryPoint to submit a UserOperation already authorized by the agentAA. The target DApp gates by calling getActiveAttestationByWallet(msg.sender, capability, actionDigest). That function reads from transient storage and reverts if the slot is empty.

Both phases complete within the same transaction. At the end of the transaction, the EVM automatically clears transient storage; the attestation is no longer active and cannot be reused. The persistent AttestationRecord remains only as an audit record.

When using the ERC-4337 UserOperation profile, a typical call chain is:

Attestor
  -> Registry.attestAndCall(profile = ERC4337_USEROP_V1)
      -> TSTORE active attestation
      -> EntryPoint.handleOps([userOp])
          -> agentAA.execute(...)
              -> RewardDistributor.claimReward()
      -> transaction end clears transient storage

In this path, RewardDistributor.claimReward() is initiated by the agentAA, so the target DApp sees msg.sender as the agentAA, not the Registry or an external Multicall contract.

The value of fine-grained actionDigest can be illustrated by a constrained swap. Suppose the Attestor approves the action “swap at most 1,000 USDC into WETH and send the output back to the user’s Vault.” The target call is first encoded as data = abi.encodeCall(Vault.executeSwap, (USDC, WETH, 1000e6, minOut, userVault, nonce)), then actionDigest = keccak256(abi.encode(address(vault), data, nonce)) is computed. The Attestor calls attestAndCall with capability = DEFI_SWAP_V1 and that actionDigest. When executing, the Vault recomputes the actionDigest using the same rule and calls getActiveAttestationByWallet(msg.sender, DEFI_SWAP_V1, actionDigest). If someone changes the calldata to “swap 100,000 USDC into a low-quality token and send it to an attacker address,” then even if it still belongs to the broad DEFI_SWAP_V1 class, the recomputed actionDigest differs and the gating query reverts. This prevents an attestation approved for a small reviewed swap from being expanded into an asset-transfer authorization.

Motivating Case 2: Autonomous Token Issuance by an AI Agent

The same atomic pattern applies to more complex scenarios. An AI agent detects a viral event from real-time internet signals, autonomously decides to issue a meme token, and generates the token name, ticker, image, and complete reasoning process, referred to as the Intent Document.

The key difference in this scenario is how evidenceHash is used. The Attestor not only verifies the agent identity, but also hashes the agent’s full reasoning, the Intent Document, into evidenceHash, so the on-chain attestation also anchors an auditable record of the decision process. The TokenFactory contract gates with getActiveAttestationByWallet(msg.sender, capability, actionDigest), and the entire attestAndCall -> wallet / UserOperation executes mint -> transaction end clears authorization flow completes in one transaction.

Atomic Attestation Flow

This ERC uses only one protocol lifecycle model: the atomic attestation flow. The two steps, attestAndCall() -> action, must complete within a single transaction. Active authorization exists only in transient storage and is automatically cleared by the EVM at the end of the transaction.

After evaluation succeeds, the Attestor directly calls attestAndCall. The bundled entry point first writes the transient active attestation, then executes the action through the specified execution profile. Active authorization is limited to the current transaction and therefore cannot serve as a reusable cross-transaction authorization.

An execution profile only determines how the action originates from the agent wallet; it does not change the attestation lifecycle. The direct wallet profile and the ERC-4337 UserOperation profile must both reuse the same attestAndCall entry point.

Key Terms

This section defines terms used in this ERC.

  • Action — A set of on-chain operations gated by attestation within a single transaction, such as executing a DeFi swap, minting a token, or calling a privileged protocol function.
  • Attestation — A registry entry created in the registry by an authorized Attestor; its active state exists only in transient storage within the issuing transaction. An attestation itself is not necessarily a cryptographic proof. It is an on-chain recorded statement issued by an Attestor, and it may reference off-chain cryptographic evidence through evidenceHash, such as a ZK proof, TEE remote attestation, signed report, or execution trace commitment.
  • Capability (capability) — A bytes32 value representing a coarse-grained capability or standard, such as keccak256("DEFI_ACCESS_V1"). This is the “which class of authorization” axis.
  • Action digest (actionDigest) — A bytes32 value representing the concrete authorized action. actionDigest = 0 means capability-only mode, where the attestation is not bound to any concrete action. When actionDigest != 0, it must bind the target contract, function selector, arguments, and a nonce or other uniqueness source, such as an attestationId or user-provided salt, to provide replay protection. The concrete derivation rule is agreed between the integrating DApp and the Attestor. The derivation scheme SHOULD be encoded into the capability namespace (e.g. keccak256("DEFI_SWAP_V1:scheme=ABI_CALLS_ATTID_V1")) to avoid silent reverts from rule mismatch across Attestors. This is the “which action is it bound to” axis.
  • Attestor — An entity authorized to open a transaction-scoped attestation window through attestAndCall and select an execution profile to execute an action. Before issuing an attestation on-chain, the Attestor evaluates the subject off-chain under its own trust assumptions. The evaluation depends on the semantics of the selected capability, and when needed actionDigest: for example, action provenance verification, runtime / TEE proof, operational history review, or policy / compliance checks. This ERC does not specify those semantics. The result may be anchored on-chain through evidenceHash. When combined with ERC-8004, the Attestor is closer to a verifier in a synchronous on-chain gating flow: it does not replace ERC-8004 identity registration, but converts an evaluation result into a temporarily queryable on-chain attestation within one atomic transaction.
  • Execution Profile — A set of rules defining how attestAndCall causes the target call to originate from the agent wallet. The direct wallet profile can call AA / ERC-7702 wallets that support relayer execution. The ERC-4337 UserOperation profile can execute a UserOperation already authorized by the agentAA through the EntryPoint. An execution profile is an internal registry concept and does not enter the authorization identity storage key; DApps gate only by capability and actionDigest, without knowing which profile was used.
  • UserOperation orchestration — An execution profile. The Attestor submits a UserOperation already authorized by the agentAA. The UserOperation’s sender must equal the attested wallet, and its callData must execute the DApp action covered by capability + actionDigest. The registry must not treat the Attestor as the agentAA’s executor; whether the agentAA authorizes the UserOperation is determined by the EntryPoint calling validateUserOp.

This ERC does not prescribe the meaning of any specific capability; it is named or derived by the integrating DApp and the Attestor. Attestors may also define composite capabilities, such as AUTHORIZED_AGENT_ACTION_V1, covering multiple dimensions.

Specification

The keywords “MUST”, “MUST NOT”, “SHOULD”, “SHOULD NOT”, and “MAY” in this document are to be interpreted as described in RFC 2119 / RFC 8174.

Interfaces

This ERC splits functionality into four interfaces. The MUST / OPTIONAL relationship for implementations is as follows:

Interface Purpose Implementation Requirement
IERC8273 (core) Type definitions, Attested event, and read-only lookup functions by ID / tuple MUST: all standard implementations must support it
IERC8273AtomicAttestation The only external issuance entry point, attestAndCall MUST: this is the issuance path for standard implementations; it is named an “extension” only to separate it structurally from view interfaces
IERC8273ActiveAttestation Subject-keyed gating query getActiveAttestation, which reverts on absence SHOULD: strongly recommended for implementations that perform on-chain gating
IERC8273WalletAttestation Wallet-keyed gating and bool views, isAttestedAddress / getActiveAttestationByWallet SHOULD: this is the most common family for DApps that gate on msg.sender

Implementations declare supported interfaces through ERC-165. A DApp should first call supportsInterface to determine which query family the Registry exposes, then choose the corresponding gating primitive. Although IERC8273AtomicAttestation is structurally an “extension,” it is the core issuance path of the specification; an implementation that does not support it cannot be considered a complete implementation of this ERC.

Standard implementations MUST support at least one of IERC8273ActiveAttestation and IERC8273WalletAttestation, otherwise the gating requirements in the body of the specification have no standard query surface. Implementations that claim support for the ERC-8004 integration profile MUST support IERC8273WalletAttestation, because ERC-8004 integrations are indexed by agentId / address.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC165 {
    function supportsInterface(bytes4 interfaceId) external view returns (bool);
}

interface IERC8273 is IERC165 {
    // Active authorization is represented by transient storage, not by this enum.
    // AttestationStatus.None is the default zero-value returned for non-existent
    //      records (e.g. getAttestation(0)); all standard records are written as Recorded.
    // renamed Consumed -> Recorded (active state lives in transient storage).
    enum AttestationStatus { None, Recorded }

    struct SubjectRef {
        uint256 subjectId;
        bytes32 subjectType;
    }

    struct ExecutionRequest {
        bytes32 profileId;      // e.g. AGENT_EXECUTE_V1 or ERC4337_USEROP_V1
        bytes32 actionDigest;   // 0 = capability-only mode; non-zero = action-bound mode
        bytes   data;           // profile-specific execution payload
    }

    struct AttestationRecord {
        uint256 subjectId;
        bytes32 subjectType;
        address attestor;
        bytes32 capability;       // coarse-grained authorization class
        bytes32 actionDigest;     // 0 if capability-only; else specific action digest
        uint64  issuedAt;
        AttestationStatus status; // persistent records are always Recorded
        bytes32 evidenceHash;
        address wallet;
    }

    event Attested(
        uint256 indexed attestationId,
        address indexed wallet,
        bytes32 indexed capability,
        bytes32 actionDigest,
        bytes32 subjectHash,
        address attestor,
        uint256 subjectId,
        bytes32 subjectType,
        bytes32 evidenceHash
    );

    function isAttested(
        SubjectRef calldata subject,
        bytes32 capability,
        bytes32 actionDigest
    ) external view returns (bool);

    function latestAttestationId(
        SubjectRef calldata subject,
        bytes32 capability,
        bytes32 actionDigest
    ) external view returns (uint256);

    function getAttestation(uint256 attestationId)
        external view returns (AttestationRecord memory record);
}

interface IERC8273ActiveAttestation is IERC8273 {
    function getActiveAttestation(
        SubjectRef calldata subject,
        bytes32 capability,
        bytes32 actionDigest
    ) external view returns (AttestationRecord memory record);
}

interface IERC8273WalletAttestation is IERC8273 {
    function isAttestedAddress(
        address wallet,
        bytes32 capability,
        bytes32 actionDigest
    ) external view returns (bool);

    function getActiveAttestationByWallet(
        address wallet,
        bytes32 capability,
        bytes32 actionDigest
    ) external view returns (AttestationRecord memory record);
}

interface IERC8273AtomicAttestation is IERC8273 {
    // The only external issuance entry point.
    function attestAndCall(
        SubjectRef calldata subject,
        bytes32 capability,
        bytes32 evidenceHash,
        address wallet,
        ExecutionRequest calldata exec
    ) external payable returns (uint256 attestationId, bytes memory result);

}

interface IAgentExecute {
    struct AgentCall {
        address target;
        uint256 value;
        bytes data;
    }

    function executeFromRelayer(
        AgentCall[] calldata calls,
        bytes calldata authData
    ) external payable returns (bytes[] memory results);
}

Core Rules

  • subjectHash = keccak256(abi.encode(subjectId, subjectType)), used for internal lookup and event fields.
  • capability is a coarse-grained capability identifier, such as keccak256("DEFI_ACCESS_V1"); actionDigest is a fine-grained action fingerprint, where = 0 means capability-only mode. Together they identify the full authorization scope of the attestation. Both the registry write performed by attestAndCall and the DApp gating query must use the same (capability, actionDigest) pair.
  • An attestation is active if and only if, within the transaction in which it is issued, a non-zero attestationId exists in a transient storage slot keyed by keccak256(abi.encode("wallet", wallet, capability, actionDigest)) or keccak256(abi.encode("subject", subjectHash, capability, actionDigest)). The EVM automatically clears transient storage at the end of each transaction.
  • Persistent AttestationRecord entries have status = Recorded and serve only as immutable audit records. They do not represent active authorization.
  • attestationId == 0 is reserved as a “non-existent” sentinel value.
  • Implementations must declare support for the core interface and any implemented extensions through ERC-165. An extension’s interfaceId is the XOR of selectors declared directly in that extension (excluding inherited), matching Solidity’s type(I).interfaceId.
  • SubjectRef is used only as an input parameter type. AttestationRecord is used both for storage and return values, and includes subject information, attestation metadata, and wallet binding. capability and actionDigest together carry the full authorization scope.
  • ExecutionRequest is the execution profile parameter of attestAndCall. exec.profileId determines how the action is executed; exec.actionDigest is the action fingerprint of the attestation and directly becomes one axis of the storage key; exec.data is decoded by the corresponding profile.
  • attestAndCall must be able to receive native tokens. msg.value is the native-token amount attached to the call, not a calldata parameter. Each execution profile must define how msg.value is forwarded, used, or rejected. When native-token value affects the authorized action, exec.actionDigest must bind that amount.
  • Multiple issuances in the same transaction: when the same (wallet, capability, actionDigest) tuple is issued more than once in the same transaction, the second and subsequent TSTORE writes overwrite the attestationId in the transient slot, causing that slot to point to the latest attestation. Each attestAndCall still creates a new persistent AttestationRecord with a distinct attestationId, so all issuances are preserved at the audit layer. This ERC does not prohibit such duplicate issuance, but implementations should avoid depending on “which record was written into the transient slot” as business logic. When actionDigest carries a nonce, this collision naturally does not occur.
  • Capability-only normative constraints: when actionDigest == 0, authorization is bounded only by the Attestor’s off-chain evaluation, not by anything the protocol enforces on calldata. Capability-only mode MUST NOT be used for bare value transfer, privileged state changes, or any financial operation amplifiable by intra-transaction reentry — those MUST use action-bound mode, and the DApp MUST add a reentrancy guard (the transient slot stays active for the whole tx; the gate alone does not block reentry).

Function Specification

Functions are grouped by purpose. Contracts gating sensitive on-chain operations must use the gating primitives. View helpers are only for non-authoritative read paths such as off-chain indexers and UX display. Mixing these two categories is one of the most common integration errors.

Mutators

attestAndCall (extension IERC8273AtomicAttestation) — The only external issuance entry point. MUST only be callable by authorized Attestors. Implementations must:

  1. Check wallet != 0, capability != 0, and exec.profileId != 0, and accept either exec.actionDigest = 0 (capability-only mode) or a non-zero value (action-bound mode). Rejecting capability == 0 prevents an uninitialized variable from becoming an attack vector. If msg.value != 0, also require actionDigest != 0 so the native amount is bound by actionDigest.
  2. Write the attestation to persistent storage with status = Recorded and emit Attested.
  3. Use TSTORE to write attestationId into transient storage slots keyed by keccak256(abi.encode("subject", subjectHash, capability, exec.actionDigest)) and keccak256(abi.encode("wallet", wallet, capability, exec.actionDigest)).
  4. Select an execution profile according to exec.profileId and execute the action. Execution must cause the target DApp to see the attested wallet as msg.sender; the concrete mechanism is defined by each profile. See the Execution Profiles section.
  5. If the call carries msg.value, handle that value according to the execution profile rules. Native tokens must not be allowed to remain silently in the Registry. The ERC-4337 profile MUST reject msg.value (handleOps is not payable; prefund goes through EntryPoint.depositTo).
  6. If action execution fails, profile validation fails, or success cannot be confirmed, revert the entire transaction, including the persistent audit record and the transient authorization writes.
  7. Return directly after successful execution. Transient storage is automatically cleared at the end of the transaction.

These two functions are the recommended path for on-chain gating. Each function reads from transient storage using TLOAD, returns the active record on success, and reverts when the record is absent. The gated contract receives a record rather than a bool, and failure reverts the whole transaction, avoiding integration bugs such as forgetting to check a bool or mishandling an if branch. Integrators are still responsible for reentrancy protection inside the gated operation; see Security Considerations.

getActiveAttestation(subject, capability, actionDigest) (extension IERC8273ActiveAttestation) — Reads from transient storage using TLOAD(keccak256(abi.encode("subject", subjectHash, capability, actionDigest))). If the transient slot is non-zero, returns the AttestationRecord from persistent storage; if the slot is zero, must revert. actionDigest = 0 is used for capability-only mode queries.

getActiveAttestationByWallet(wallet, capability, actionDigest) (extension IERC8273WalletAttestation) — Reads from transient storage using TLOAD(keccak256(abi.encode("wallet", wallet, capability, actionDigest))). If the transient slot is non-zero, returns the AttestationRecord from persistent storage; if the slot is zero, must revert. This is the recommended primitive when a contract gates on msg.sender, as in Motivating Case 1. Capability-only mode queries pass actionDigest = 0; action-bound mode queries pass the recomputed concrete digest.

View Helpers (For Off-Chain Use Only; Must Not Be Used for Gating)

These functions return bool snapshots of the registry’s transient storage state in the current transaction. They are useful for off-chain indexing, UI display, and read-only tooling. They must not be the sole gate for sensitive on-chain operations.

isAttested(subject, capability, actionDigest) — Returns true if the transient slot corresponding to (subjectHash, capability, actionDigest) is non-zero in the current transaction.

isAttestedAddress(wallet, capability, actionDigest) (extension IERC8273WalletAttestation) — Returns true if the transient slot corresponding to (wallet, capability, actionDigest) is non-zero in the current transaction. Wallet bindings for different (capability, actionDigest) pairs are independent.

latestAttestationId(subject, capability, actionDigest) — Returns the ID of the most recent persistent attestation under the same (subject, capability, actionDigest); returns 0 if none exists. This value reflects audit records, not active authorization. In action-bound mode, most queries return 0 or a unique ID because actionDigest usually includes a nonce.

getAttestation(attestationId) — Looks up an attestation record by ID from persistent storage and returns the full AttestationRecord. All standard records have status = Recorded.

Non-Standard Helper

nextAttestationId() — Not part of the standard interface. Returns the ID that will be used by the next attestation, for off-chain batch construction. Implementations may optionally provide it.

Execution Profiles

An execution profile defines how attestAndCall causes the target action to originate from the agent wallet. All profiles must satisfy the same lifecycle constraint: first write the transient active attestation, then execute the action; if execution fails, revert the entire transaction; at transaction end, active attestation is automatically cleared.

Direct Wallet Execution Profile

AGENT_EXECUTE_V1 = keccak256("ERC8273_AGENT_EXECUTE_V1").

This profile applies to AA or ERC-7702 smart wallets that implement IAgentExecute. The Registry calls wallet.executeFromRelayer(calls, authData), and the wallet internally calls each calls[i].target, so the target DApp sees msg.sender == wallet. exec.data should be encoded as:

abi.encode(IAgentExecute.AgentCall[] calls, bytes authData)

Implementations must verify:

  • When exec.actionDigest != 0: the reference implementation’s minimal form is exec.actionDigest == keccak256(abi.encode(calls, attestationId))attestationId is allocated by the Registry at issuance time and is naturally per-attestation unique, satisfying the global MUST in the Security section “actionDigest derivation” (a non-zero actionDigest must include a nonce or equivalent uniqueness source). Integrators MAY adopt stronger formulas that explicitly include a user-supplied nonce, session salt, or additional bindings; they must not weaken the form — a bare keccak256(calls) is only compliant when calls[i].data already carries a nonce internally and is not recommended as the default.
  • When exec.actionDigest == 0: the profile still executes calls, but the authorization is capability-only, and the DApp side can only query in capability-only mode.
  • wallet != address(0);
  • IAgentExecute(wallet).executeFromRelayer(calls, authData) returns successfully;
  • authData is verified by the agent wallet, and must bind chainId, wallet, registry, profile, actionDigest, nonce, and validity period;
  • If calls includes a non-zero AgentCall.value, that value must be covered by exec.actionDigest in action-bound mode. If ETH enters through attestAndCall, the Registry must forward msg.value to the wallet’s execution entry (Direct Wallet profile only — the ERC-4337 profile MUST reject non-zero msg.value; see below).

This profile does not require all AA wallets to expose arbitrary external execute. Only wallets that explicitly implement IAgentExecute and can use authData for replay protection, domain separation, and call binding should claim support for this profile.

ERC-4337 UserOperation Profile

ERC4337_USEROP_V1 = keccak256("ERC8273_ERC4337_USEROP_V1").

This profile applies to existing ERC-4337 AA wallets. In this ERC, UserOperation execution is only one execution profile of attestAndCall; it does not introduce a second issuance entry point.

The concrete encoding of exec.data may be defined by an implementation or adapter, but it must bind at least:

  • entryPoint;
  • the submitted UserOperation or handleOps calldata;
  • the UserOperation’s sender;
  • adapter / receipt / postcondition data sufficient to confirm successful execution of the target action.

Implementations must verify:

  • the UserOperation’s sender == wallet;
  • the UserOperation is authorized by the agentAA’s own nonce, signature, session key, or module policy;
  • the target action covered by the UserOperation is consistent with exec.actionDigest in action-bound mode;
  • success of the target action must not be inferred solely from the fact that the low-level call to EntryPoint.handleOps did not revert. If the EntryPoint or account implementation may record UserOperation failure as an event rather than bubbling a revert, the profile adapter must confirm success through an account receipt, DApp receipt, event proof, or other verifiable postcondition; otherwise it must revert.

The value of this profile is compatibility with existing AA wallets that do not want to expose arbitrary external executeFromRelayer. The EntryPoint follows the standard validateUserOp -> execute path, and the agentAA calls the DApp from its execute, so at the target DApp msg.sender == agentAA, which equals the attested wallet. getActiveAttestationByWallet(msg.sender, capability, actionDigest) gates on that basis.

Wallet Binding

The wallet parameter has two purposes: it is the agent wallet that the target action is expected to represent, and it is the key used to write the transient authorization slot keccak256(abi.encode("wallet", wallet, capability, actionDigest)).

  • wallet == address(0): attestAndCall must reject this case. The zero address is not a valid wallet binding and cannot be used as a gating subject. Accepting the zero address would make any query path that failed to bind wallet before calling an attack vector.
  • wallet != 0: implementations must use TSTORE to write the transient slot keyed by keccak256(abi.encode("wallet", wallet, capability, actionDigest)). During the transaction, getActiveAttestationByWallet(wallet, capability, actionDigest) reads this slot using TLOAD and returns the record. After the transaction ends, the EVM automatically clears the slot.
  • Wallet binding is scoped by the transient slot (wallet, capability, actionDigest). Different (wallet, capability, actionDigest) tuples are independent and are all automatically cleared at the end of the transaction.
  • wallet must appear in the Attested event and should be an indexed topic to enable wallet-dimension indexing.

capability, actionDigest, and evidenceHash

capability expresses a coarse-grained authorization class, such as keccak256("DEFI_ACCESS_V1") or keccak256("MINT_AUTHORITY_V1"). capability is a flat namespace whose semantics are agreed by the integrating DApp and the Attestor.

actionDigest expresses fine-grained action binding: “which concrete action this is bound to.” The rules are:

  • actionDigest = 0: capability-only mode. The attestation is not bound to any concrete action; the DApp gates with getActive...(..., capability, 0).
  • actionDigest != 0: action-bound mode. The attestation is bound to a concrete action. The Attestor and integrating DApp must agree on a derivation rule, and the actionDigest inputs must include the target contract, function selector, arguments, and a nonce or other uniqueness source, such as an attestationId or user-provided salt, to provide replay protection. When the action carries native-token value, the value must also be included in actionDigest.

chainid is not included in any derivation: the Registry is deployed per chain, and storage is naturally chain-isolated, which structurally provides cross-chain replay protection. exec.profileId is not included in any derivation: the execution profile is an internal Registry concept that the DApp does not observe. If a particular DApp truly needs profile binding, it may explicitly include it in actionDigest derivation, but this is an exceptional choice, not the default.

A mismatch between the Attestor’s and DApp’s derivation rules is a critical integration error. It causes the Attestor to attest under (capability, digestA) while the DApp queries (capability, digestB), so the transient slot is not found and the gating query reverts. This is not a vulnerability, but it is a deployment error and must be covered by tests.

evidenceHash may point to arbitrary off-chain evidence: review reports, execution trace hashes, TEE remote attestation, signed verification reports, and so on. This ERC only standardizes the bytes32 commitment itself. Evidence storage, transport, and format are defined by upper layers. Evidence references should use content addressing, such as an IPFS CID or signed Merkle root, so the commitment is not weakened by mutable references.

Rationale

Design Decisions

  • Generic SubjectRef instead of address: Attestation needs are broader than any single identity scheme. A generic subject reference lets this ERC adapt to future identity systems without rewriting the standard. SubjectRef is used as an input parameter, and its fields are persisted in AttestationRecord, so callers do not need to manage two separate return values.
  • bytes32 evidenceHash instead of embedded metadata: Evidence may be large, private, or mutable. A hash commitment keeps the interface compact and supports many off-chain storage systems.
  • capability + actionDigest axes instead of derived scopeId: Earlier versions derived a single scopeId by hashing capability, wallet, chainid, profileId, and actionDigest. That design cannot distinguish “coarse-grained capability” from “action-bound fingerprint” at the type level: both are bytes32, and misconfiguration can fail silently. This ERC splits those semantic dimensions into independent axes: capability is the coarse-grained class; actionDigest is the fine-grained action binding, with = 0 denoting capability-only mode. The interface signature forces the mode choice. wallet is already a separate function parameter, so repeating it in a derivation is redundant. Per-chain Registry deployment provides chain isolation; chainId need not appear in actionDigest. profileId is an internal Registry concept and should not leak into the DApp interface.
  • No update / batch: In-place modification blurs history and complicates the authorization window. The atomic lifecycle only needs one attestAndCall per batch; more complex compositions should be expressed inside execution profiles, such as direct wallet calls or ERC-4337 UserOperations.
  • Transient storage for active authorization: EIP-1153 transient storage is automatically cleared at the end of each transaction. Active authorization therefore never enters persistent on-chain state, and the authorization window is structurally limited to a single transaction. The atomic model enforces short lifetimes through the EVM rather than operational discipline, preventing reusable authorization from remaining after transaction end.
  • attestAndCall as the only external issuance entry point: Issuance, transient authorization writes, and action execution are orchestrated by one entry point, ensuring that each active attestation is used for a corresponding action in the same call stack and that the authorization window is limited to a single transaction.
  • Unified AttestationRecord: Subject information (subjectId, subjectType) and wallet binding (wallet) are included in AttestationRecord, so the interface and implementation share one struct and no internal conversion layer is needed. SubjectRef remains as an input parameter type to preserve semantic grouping.
  • Wallet binding isolated by (capability, actionDigest): Wallet binding uses keccak256(abi.encode("wallet", wallet, capability, actionDigest)) as the transient storage slot key. This prevents cross-capability interference, and it also prevents action-bound and capability-only modes under the same capability from satisfying each other.
  • Split between gating primitives and view helpers: This ERC intentionally splits getActiveAttestation*, which reverts on absence, from isAttested*, which returns a bool snapshot. Both read from transient storage. The reverting variant is the recommended path for on-chain authorization because it forces the transaction to revert when the attestation is absent, eliminating common errors such as forgetting to check a bool or mishandling an if branch. The wallet-keyed variant getActiveAttestationByWallet(wallet, capability, actionDigest) ensures that the most common gating form also has a safe path without falling back to bool views.
  • Execution profiles instead of a single wallet assumption: This ERC does not hardcode IAgentExecute or ERC-4337 as the only execution mechanism. The direct wallet profile applies to agent wallets willing to expose relayer execution; the ERC-4337 UserOperation profile applies to existing AA wallets. Both share the same transient attestation lifecycle.

Relationship with Existing Attestation Systems

  • Ethereum Attestation Service (EAS): EAS is a schema-driven singleton deployment suited for broad ecosystem-level attestation use cases, and by default uses time-based expiration and persistent storage. An EAS resolver can execute custom logic in onAttest / onRevoke, so it can cover part of this ERC’s design space. However, that means every schema / resolver must define its own data format, execution method, and query functions, and resolvers written by different projects may not be compatible. By contrast, this ERC standardizes a fixed flow: attestations can point to non-address subjects, bind to a specific wallet, let an Attestor trigger transaction-scoped authorization and action execution through a uniform entry point, let DApps query the current transaction’s authorization through uniform functions, and automatically clear authorization at transaction end. EAS resolvers can approximate this flow for project-specific needs, but DApps cannot integrate it by relying only on the EAS standard interface; they must still understand the resolver’s custom rules.
  • Soulbound tokens (ERC-5484, ERC-5192, ERC-4973): SBTs use non-transferable ERC-721 tokens to represent credentials. This ERC uses a registry-based approach with built-in named standard identifiers. Unlike persistent SBTs, this ERC intentionally limits active authorization to a single transaction.
  • ERC-8004 (Validation Registry): ERC-8004 includes both identity registration and validation registry mechanisms: the Identity Registry handles agent identity registration, while the Validation Registry handles per-task validationRequest / validationResponse flows and is asynchronous and advisory. This ERC does not replace ERC-8004 identity registration, nor does it deny its validation layer. It adds a synchronous, transaction-scoped, directly queryable active authorization primitive at the same conceptual validation layer, and should be positioned as a companion or extension to the Validation Registry. This ERC defines a normative ERC-8004 integration profile: any implementation that claims support for that profile must set subjectId equal to the ERC-8004 agentId and subjectType equal to keccak256("ERC8004_AGENT"). This requirement only applies to implementations claiming ERC-8004 integration support, and does not restrict other subject namespaces. Other subject types may still be defined by other profiles or by a future namespace registration mechanism.

Attestor Operational Profile

The Attestor is on the synchronous hot path of the DApp call stack: every gated action requires the Attestor to evaluate and trigger attestAndCall within that transaction. If the Attestor is offline, it blocks all operations depending on that capability. This is a different model from the ERC-8004 asynchronous Validation Registry, which can tolerate validator unavailability, and ERC-7715-style static policy distribution, which can be pre-programmed into wallets. If the action can be audited asynchronously, use ERC-8004. If the policy can be made static, use ERC-7715. Use this ERC when per-action off-chain evaluation must gate on-chain execution.

Attestor services should provide low latency, high availability, idempotent execution, where the same evaluation corresponds to the same (capability, actionDigest), and auditable decisions anchored by evidenceHash.

Deployment Model

This ERC is an implementable interface standard. It does not require or assume a per-chain singleton Registry. Any team may deploy an independent Registry, choose its own Attestors and capability set, similar to the “standard + multiple deployments” model of ERC-721 / ERC-1155. This choice has an explicit cost: the same capability constant may not have the same meaning across different Registries. A DApp must not treat two Registries as equivalent authorization sources merely because they use the same capability name; it must also trust the specific Registry address, that Registry’s Attestor set, and the corresponding authorization policy.

When integrating, a DApp MUST explicitly declare which Registry deployments it trusts, rather than relying only on ERC-165 discovery. Different deployments may have entirely different Attestor sets and governance. Ecosystems that need cross-deployment composability should establish mutual recognition explicitly through a shared Registry, an upper-layer profile, governance agreement, or a future namespace registration mechanism.

Upgradeability

Implementations MAY use proxy upgrades, such as UUPS, Beacon, or Transparent proxies, chosen by the implementation. During upgrades, the following invariants should be preserved:

  • supportsInterface interface IDs remain stable;
  • Attested event signature, including indexed topic order, remains stable;
  • attestationId remains monotonic and continuous, and old IDs remain queryable through getAttestation;
  • AttestationRecord ABI remains backward-compatible: no fields are removed, no semantics are changed, and new fields are appended at the end.

Upgrades may add interfaces or execution profiles, but must not reinterpret existing capability, actionDigest, historical records, or the transient semantics of transaction-scoped active authorization. Changes that break core protocol semantics, including mode selection, transient lifecycle, msg.sender == wallet, or attestAndCall as the single entry point, SHOULD be deployed at a new address rather than performed in-place.

Backwards Compatibility

This ERC does not modify the behavior of any existing identity, token, or account standard. DApp contracts integrating this attestation mechanism discover the interfaces supported by the registry through ERC-165. Standard implementations must support IERC8273AtomicAttestation.attestAndCall and represent transaction-scoped active authorization through transient storage.

This ERC depends on EIP-1153. When deployed to chains where TSTORE / TLOAD are not enabled, implementations must use an equivalent transaction-scoped authorization mechanism, or they must not claim full compatibility with this ERC.

Any contract claiming to implement IERC8273AtomicAttestation MUST provide EIP-1153 or an equivalent transaction-scoped authorization clearing mechanism. If it cannot provide such a mechanism, it must not claim support for the interface, because doing so would break the normative invariant that active authorization does not remain across transactions.

Reference Implementation

The following implementation illustrates the transient-storage lifecycle and the shape of two execution profiles. Both profiles ship with a minimal runnable action-bound digest rule — keccak256(abi.encode(calls, attestationId)) for Direct Wallet and keccak256(abi.encode(wallet, handleOpsCalldata, attestationId)) for ERC-4337 — where attestationId is allocated by _attestTransient before dispatch and serves as a natural per-attestation uniqueness source. Production implementations MAY swap in stronger formulas (user-supplied nonce, session salts, etc.). The ERC-4337 profile additionally MUST decode the UserOperation to verify sender == wallet and prove target action success — the reference implementation does not decode calldata and only enforces the minimal digest binding. The Direct Wallet capability-only path (actionDigest == 0) runs out of the box and is convenient as an end-to-end entry point for testing the transient lifecycle.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract AttestationRegistry is
    IERC8273ActiveAttestation,
    IERC8273WalletAttestation,
    IERC8273AtomicAttestation
{
    bytes32 public constant AGENT_EXECUTE_V1 =
        keccak256("ERC8273_AGENT_EXECUTE_V1");
    bytes32 public constant ERC4337_USEROP_V1 =
        keccak256("ERC8273_ERC4337_USEROP_V1");

    mapping(uint256 => AttestationRecord) private _records;
    mapping(bytes32 => uint256) private _latestAttestations;
    uint256 private _nextId = 1;

    address public owner;
    mapping(address => bool) public authorizedAttestors;

    constructor() {
        owner = msg.sender;
        authorizedAttestors[msg.sender] = true;
    }

    modifier onlyAuthorizedAttestor() {
        require(authorizedAttestors[msg.sender], "not authorized attestor");
        _;
    }

    // each extension's id is XOR of ONLY its own declared selectors (matches `type(I).interfaceId`).
    bytes4 private constant _IERC8273_ID =
        IERC8273.isAttested.selector ^
        IERC8273.latestAttestationId.selector ^
        IERC8273.getAttestation.selector;
    bytes4 private constant _IERC8273_ACTIVE_ID =
        IERC8273ActiveAttestation.getActiveAttestation.selector;
    bytes4 private constant _IERC8273_WALLET_ID =
        IERC8273WalletAttestation.isAttestedAddress.selector ^
        IERC8273WalletAttestation.getActiveAttestationByWallet.selector;
    bytes4 private constant _IERC8273_ATOMIC_ID =
        IERC8273AtomicAttestation.attestAndCall.selector;

    function supportsInterface(bytes4 interfaceId)
        external pure override returns (bool)
    {
        return
            interfaceId == type(IERC165).interfaceId ||
            interfaceId == _IERC8273_ID ||
            interfaceId == _IERC8273_ACTIVE_ID ||
            interfaceId == _IERC8273_WALLET_ID ||
            interfaceId == _IERC8273_ATOMIC_ID;
    }

    function _subjectHash(uint256 subjectId, bytes32 subjectType)
        internal pure returns (bytes32)
    {
        return keccak256(abi.encode(subjectId, subjectType));
    }

    function _lookupKey(bytes32 sh, bytes32 capability, bytes32 actionDigest)
        internal pure returns (bytes32)
    {
        return keccak256(abi.encode(sh, capability, actionDigest));
    }

    function _walletTSlot(address wallet, bytes32 capability, bytes32 actionDigest)
        internal pure returns (bytes32)
    {
        return keccak256(abi.encode("wallet", wallet, capability, actionDigest));
    }

    function _subjectTSlot(bytes32 subjectHash, bytes32 capability, bytes32 actionDigest)
        internal pure returns (bytes32)
    {
        return keccak256(abi.encode("subject", subjectHash, capability, actionDigest));
    }

    function attestAndCall(
        SubjectRef calldata subject,
        bytes32 capability,
        bytes32 evidenceHash,
        address wallet,
        ExecutionRequest calldata exec
    )
        external
        payable
        override
        onlyAuthorizedAttestor
        returns (uint256 attestationId, bytes memory result)
    {
        require(wallet != address(0), "zero wallet");
        require(capability != bytes32(0), "zero capability"); // prevent zero-capability attack vector
        require(exec.profileId != bytes32(0), "zero profile");
        // exec.actionDigest == 0 is allowed and means capability-only mode.
        // native value MUST go through action-bound mode.
        require(
            msg.value == 0 || exec.actionDigest != bytes32(0),
            "native value requires action-bound mode"
        );

        attestationId = _attestTransient(
            subject, capability, exec.actionDigest, evidenceHash, wallet
        );

        if (exec.profileId == AGENT_EXECUTE_V1) {
            result = _executeAgentProfile(wallet, exec, attestationId);
        } else if (exec.profileId == ERC4337_USEROP_V1) {
            result = _executeUserOpProfile(wallet, exec, attestationId);
        } else {
            revert("unsupported execution profile");
        }
    }

    function _attestTransient(
        SubjectRef calldata subject,
        bytes32 capability,
        bytes32 actionDigest,
        bytes32 evidenceHash,
        address wallet
    ) internal returns (uint256 attestationId) {
        attestationId = _nextId++;
        bytes32 sh = _subjectHash(subject.subjectId, subject.subjectType);

        _records[attestationId] = AttestationRecord({
            subjectId: subject.subjectId,
            subjectType: subject.subjectType,
            attestor: msg.sender,
            capability: capability,
            actionDigest: actionDigest,
            issuedAt: uint64(block.timestamp),
            status: AttestationStatus.Recorded,
            evidenceHash: evidenceHash,
            wallet: wallet
        });

        _latestAttestations[_lookupKey(sh, capability, actionDigest)] = attestationId;

        bytes32 wSlot = _walletTSlot(wallet, capability, actionDigest);
        bytes32 sSlot = _subjectTSlot(sh, capability, actionDigest);
        assembly {
            tstore(wSlot, attestationId)
            tstore(sSlot, attestationId)
        }

        emit Attested(
            attestationId,
            wallet,
            capability,
            actionDigest,
            sh,
            msg.sender,
            subject.subjectId,
            subject.subjectType,
            evidenceHash
        );
    }

    // both profiles use `attestationId` as replay-safety source (allocated before dispatch). Production MAY swap in stronger rules.

    function _executeAgentProfile(
        address wallet,
        ExecutionRequest calldata exec,
        uint256 attestationId
    ) internal returns (bytes memory result) {
        (IAgentExecute.AgentCall[] memory calls, bytes memory authData) =
            abi.decode(exec.data, (IAgentExecute.AgentCall[], bytes));
        // Action-bound: minimal digest = keccak256(calls, attestationId). Capability-only skips the check.
        if (exec.actionDigest != bytes32(0)) {
            require(
                exec.actionDigest == keccak256(abi.encode(calls, attestationId)),
                "bad action digest"
            );
        }
        bytes[] memory results =
            IAgentExecute(wallet).executeFromRelayer{value: msg.value}(calls, authData);
        result = abi.encode(results);
    }

    function _executeUserOpProfile(
        address wallet,
        ExecutionRequest calldata exec,
        uint256 attestationId
    ) internal returns (bytes memory result) {
        // EntryPoint.handleOps is not payable; native prefund must use depositTo.
        require(msg.value == 0, "4337 profile rejects native value; use EntryPoint.depositTo");

        (address entryPoint, bytes memory handleOpsCalldata, ) =
            abi.decode(exec.data, (address, bytes, bytes));
        require(entryPoint != address(0), "zero entryPoint");

        // Minimal digest binds (wallet, handleOpsCalldata, attestationId).
        // Production adapters MUST also decode the UserOp, verify sender == wallet, and prove action success.
        if (exec.actionDigest != bytes32(0)) {
            require(
                exec.actionDigest ==
                    keccak256(abi.encode(wallet, handleOpsCalldata, attestationId)),
                "bad action digest"
            );
        }

        (bool ok, bytes memory ret) = entryPoint.call(handleOpsCalldata);
        if (!ok) {
            assembly {
                revert(add(ret, 32), mload(ret))
            }
        }
        result = ret;
    }

    function isAttested(
        SubjectRef calldata subject,
        bytes32 capability,
        bytes32 actionDigest
    ) external view override returns (bool) {
        bytes32 sh = _subjectHash(subject.subjectId, subject.subjectType);
        bytes32 slot = _subjectTSlot(sh, capability, actionDigest);
        uint256 id;
        assembly { id := tload(slot) }
        return id != 0;
    }

    function latestAttestationId(
        SubjectRef calldata subject,
        bytes32 capability,
        bytes32 actionDigest
    ) external view override returns (uint256) {
        return _latestAttestations[_lookupKey(
            _subjectHash(subject.subjectId, subject.subjectType),
            capability,
            actionDigest
        )];
    }

    function getAttestation(uint256 attestationId)
        external view override returns (AttestationRecord memory record)
    {
        record = _records[attestationId];
    }

    function getActiveAttestation(
        SubjectRef calldata subject,
        bytes32 capability,
        bytes32 actionDigest
    ) external view override returns (AttestationRecord memory record) {
        bytes32 sh = _subjectHash(subject.subjectId, subject.subjectType);
        bytes32 slot = _subjectTSlot(sh, capability, actionDigest);
        uint256 id;
        assembly { id := tload(slot) }
        require(id != 0, "no active attestation");
        record = _records[id];
    }

    function isAttestedAddress(
        address wallet,
        bytes32 capability,
        bytes32 actionDigest
    ) external view override returns (bool) {
        bytes32 slot = _walletTSlot(wallet, capability, actionDigest);
        uint256 id;
        assembly { id := tload(slot) }
        return id != 0;
    }

    function getActiveAttestationByWallet(
        address wallet,
        bytes32 capability,
        bytes32 actionDigest
    ) external view override returns (AttestationRecord memory record) {
        bytes32 slot = _walletTSlot(wallet, capability, actionDigest);
        uint256 id;
        assembly { id := tload(slot) }
        require(id != 0, "no active attestation");
        record = _records[id];
    }
}

Security Considerations

Transient Storage Authorization Model

This ERC stores active authorization entirely in transient storage. Transient storage is automatically cleared by the EVM at the end of each transaction, structurally preventing an attestation from surviving beyond the transaction in which it is issued. The risk of active authorization remaining after transaction end is eliminated at the protocol layer rather than relying on the Attestor or operational discipline.

If an execution profile reverts for any reason, the entire transaction reverts, including TSTORE writes and persistent audit records. The system cannot enter a state where the action failed but authorization remains.

Execution Profile Security

Each execution profile must clearly define:

  • the encoding of exec.data;
  • how exec.actionDigest is derived from the authorized action, only required in action-bound mode; in capability-only mode actionDigest = 0, and the profile does not perform digest validation;
  • how the target call’s msg.sender is guaranteed to be wallet, with the concrete mechanism defined by each profile; see the Execution Profiles section above;
  • how successful execution of the target action is confirmed;
  • how msg.value is handled, including whether it is forwarded, whether non-zero value is rejected, whether the wallet may use an existing balance, and how native value is included in exec.actionDigest;
  • who is responsible for replay protection and domain separation.

Additional note on capability-only mode: when exec.actionDigest = 0, the profile does not validate matching between calls and digest. The attestation authorizes the wallet to perform “any” action accepted by that profile under the capability. Before issuing a capability-only attestation, the Attestor must confirm in its off-chain evaluation that the submitted calls fall within the policy boundary of the capability. Profile implementations should document this clearly, so capability-only mode is not misunderstood as “calls do not need safety review.”

The direct wallet profile must trust the wallet to correctly verify authData. If executeFromRelayer is designed without msg.sender restrictions, then authData must bind chainId, wallet, registry, profile, actionDigest, nonce, and validity period. If a caller with valid authData bypasses the Registry and calls the wallet directly, the target DApp’s attestation gate will revert because no transient slot was written; however, this assumes that the DApp actually performs getActiveAttestationByWallet gating.

The ERC-4337 UserOperation profile must confirm that the UserOperation’s sender == wallet and that the UserOperation is authorized by the agentAA’s own nonce, signature, or module policy. The agent wallet SHOULD NOT grant the Attestor reusable execution authority (long-lived session keys, expiry-less module authorizations). This ERC cannot enforce that at the protocol layer, but ignoring it widens a compromised Attestor’s blast radius from “one evaluation” to “the agent’s entire assets”. The Attestor should submit only the UserOp approved by the current evaluation.

Implementations must not infer successful target action execution solely because the low-level call to EntryPoint.handleOps did not revert. If the EntryPoint or account implementation may record UserOperation execution failure as an event rather than bubbling a revert, the profile adapter must confirm success through an account receipt, DApp receipt, or another verifiable postcondition; otherwise it must revert.

Choosing the Correct Gating Primitive

Contracts gating sensitive on-chain operations must use getActiveAttestation(subject, capability, actionDigest) or getActiveAttestationByWallet(wallet, capability, actionDigest), which revert when the transient slot is empty. They must not use isAttested / isAttestedAddress, which are bool-returning view helpers.

Bool-returning view functions make it easy for integrators to write incorrect conditional branches or forget the check. The reverting variants cause the whole transaction to revert when an attestation is absent, structurally eliminating this class of error. The following pattern is not recommended:

// Bad example: sensitive on-chain gating must not rely only on a bool snapshot.
require(
    registry.isAttestedAddress(msg.sender, capability, actionDigest),
    "no attestation"
);
_doSensitive();

The recommended pattern is:

IERC8273.AttestationRecord memory record =
    registry.getActiveAttestationByWallet(msg.sender, capability, actionDigest);
_doSensitive();
Function Semantics Recommended Use
getActiveAttestation(..., capability, actionDigest) / getActiveAttestationByWallet(..., capability, actionDigest) Reads transient storage; reverts if the slot is empty On-chain authorization gating (recommended)
isAttested(..., capability, actionDigest) / isAttestedAddress(..., capability, actionDigest) Reads transient storage; returns bool Off-chain indexers and UX display; must not be the sole gate for sensitive on-chain operations

actionDigest Derivation

The actionDigest derivation rule is negotiated by the DApp integrating this attestation mechanism and the Attestor, but it must satisfy the following constraints:

  • When actionDigest != 0, it must bind the target contract, function selector, arguments, and a nonce or other uniqueness source, such as an attestationId or user-supplied salt. Without a nonce, two identical actions have the same actionDigest, which theoretically leaves room for replay.
  • When the action carries native tokens, the amount must be included in actionDigest.
  • The DApp must recompute expectedActionDigest using the same rule at the gating point and query with that value.
  • chainid need not be included in actionDigest, because per-chain registry deployment already provides isolation. exec.profileId need not be included in actionDigest, because the execution profile is an internal Registry concept. Only DApps whose security model truly needs to distinguish call paths should explicitly include it.
  • A mismatch between Attestor and DApp derivation rules is a common integration error: the Attestor attests under (capability, digestA) while the DApp queries (capability, digestB), causing the gating query to revert. This is not a vulnerability, but it is a deployment error and must be covered by tests.

Capability-only mode (actionDigest == 0) means that “for this wallet and this capability, authorization covers any action included under that capability.” It should only be used when the gated action itself is a coarse-grained capability check, such as “is this an authenticated agent.” High-risk actions should use action-bound mode.

Reentrancy

getActiveAttestation* reads from transient storage at call time and does not lock state for the remainder of the transaction. Important: in capability-only mode (actionDigest == 0), the transient slot remains active during the issuing transaction, meaning the attestation gate itself does not prevent reentrant calls within the same transaction. If an attacker can reenter the gated function during the action execution stack, the second call still passes the gate. This differs from the intuition of “single-use.” In action-bound mode, because actionDigest usually includes a nonce, the DApp can prevent reentry by invalidating the nonce after execution, but this is the DApp’s responsibility, not a guarantee provided by the Registry. In all modes, contracts gating sensitive operations must use a reentrancy guard around the gated operation.

Registry Trust

DApp contracts integrating this attestation mechanism trust the registry’s Attestor authorization policy. Weak governance may issue unsafe attestations. Implementations should provide a bounded authorized Attestor set and robust processes for adding and removing Attestors. Specific risks include: compromise of a single Attestor can cause arbitrary actions within its authority to be attested; governance multisig latency can increase damage during the compromise window.

Attestor Compromise

A compromised Attestor can issue attestations for arbitrary subjects within its authority. Each attestation is active only within its issuing transaction, so compromise does not leave persistent unauthorized active state. However, during the compromise window, the compromised party can initiate any number of transactions, each with an attestation. “Blast radius limited to one transaction” refers to the blast radius of a single attestation, not the total damage from the compromise. Total damage depends on how quickly the compromise is detected and the Attestor’s authority is revoked. Implementations should use the capability namespace to constrain Attestor authority; operators should monitor Attested events and their corresponding execution profile calls.

Subject Control Change

If the effective controller of a subject changes, historical attestations may no longer be valid. High-risk scenarios should require fresh attestation rather than relying on previously issued attestations. Because all active authorization is transient, there is no persistent authorization that needs to be invalidated; this concern primarily applies to audit records in _records.

Evidence Integrity

Off-chain evidence must remain consistent with evidenceHash. Immutable references such as IPFS are recommended. Mutable storage, such as an HTTP URL without content addressing, must not be the sole backing reference for evidenceHash.

Cross-Chain Limits

SubjectRef does not include chainId, and each registry is deployed per chain. A subject reference on one chain must not be assumed to have meaning on another chain. Cross-chain DApp contracts should re-attest on each chain rather than relying on bridged attestations.

attestationId is not globally unique: _nextId is monotonic only within a single Registry on a single chain. IDs may collide across Registries or chains. Indexers, bridges, and audit tools MUST use the (chainId, registry, attestationId) tuple. The Attested event itself does not include the first two fields; the indexing layer must inject them.

Wallet Binding Scope

isAttestedAddress(wallet, capability, actionDigest) and getActiveAttestationByWallet(wallet, capability, actionDigest) are scoped by the (wallet, capability, actionDigest) tuple and by a single transaction. Transient storage slots for different tuples are independent and are all automatically cleared at the end of the transaction.

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Yunan Li, Qingzhi Zha (@rickzha610), Xianrui Qin (@xrqin), Vitto Rivabella (@eversmile12), "ERC-8273: Attestation-Gated Agentic Actions [DRAFT]," Ethereum Improvement Proposals, no. 8273, May 2025. Available: https://eips.ethereum.org/EIPS/eip-8273.