ERC-8273 - Attestation-Gated Agentic Actions

Created 2025-05-26
Status Draft
Category ERC
Type Standards Track
Authors
Requires

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:

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:

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:

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.

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

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.

Gating Primitives (Recommended for On-Chain Authorization)

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:

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:

Implementations must verify:

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

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:

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

Relationship with Existing Attestation Systems

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:

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:

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:

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

Copyright and related rights waived via CC0.