Abstract

This proposal defines a standard interface for intent-centric smart accounts. It enables externally owned accounts (EOAs) to delegate contract code to a smart account implementation, allowing them to sign intents. These intents can then be executed by solvers (or relayers) on behalf of the account owner, streamlining interactions and expanding the capabilities of EOAs.

Motivation

Account Abstraction (AA) is a highly discussed topic in the blockchain industry, as it enhances the programmability of accounts, enabling features such as:

  • Batch Execution
  • Gas Sponsorship
  • Access Control

The introduction of ERC-4337 established a permissionless standard for AA, unlocking a wide range of powerful features. However, ERC-4337 has several limitations:

  • Complexity: The standard requires multiple interdependent components, including the Account, EntryPoint, Paymaster, Bundler, and additional plugins (ERC-6900, ERC-7579. Running a bundler demands significant engineering expertise and introduces operational overhead.
  • Compatibility: Component dependencies make upgrades cumbersome, often requiring multiple smart contracts to be updated simultaneously. This creates fragmentation within the ecosystem. one version update, also divides the ecosystem.
  • Cost: Processing UserOperation transactions consumes a high amount of gas.
  • Trust Assumption: Despite being designed as a permissionless standard, ERC-4337 still relies on centralized entities. Paymasters, for instance, are typically centralized, as they must either trust account owners to reimburse gas costs or manage external funding sources. Similarly, bundlers operate within a miner extractable value (MEV) environment, requiring users to trust them for transaction inclusion.

ERC-7521 introduced a smart contract account (SCA) solution with an intent-centric design. It allows solvers to fulfill account owners’ intents while maintaining flexibility for custom execution logic and ensuring forward compatibility.

With the introduction of SET_CODE_TX_TYPE=0x04, EOAs can now set contract code dynamically, granting them programmability similar to SCAs. This presents an opportunity to develop a new standard that extends AA capabilities to EOAs while addressing the aforementioned challenges.

By simplifying execution, improving efficiency, and enhancing user experience, this proposal aims to accelerate the adoption of intent-centric account abstraction smart contracts.

Solvers, Relayers, Paymasters, and Bundlers—All in One

In an intent-centric system, solvers play a crucial role in fulfilling user intents and are rewarded accordingly. This proposal introduces an open execution model, where any solver can participate, fostering a competitive environment that benefits users.

With integrated gas abstraction, solvers can cover gas fees using native tokens while receiving other tokens from the EOA account as compensation. Additionally, solvers can further optimize costs by bundling multiple intent executions into a single blockchain transaction.

Each solver is free to develop its own strategies for maximizing profitability. This proposal does not impose restrictions on how solvers execute intents, ensuring flexibility and adaptability in diverse execution scenarios.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

UserIntent schema

Each intent is a packed data structure containing sufficient information about the operations the account owner wants to execute. The core structure of a UserIntent object is as follows:

Field Type Description
sender address The address of the account initiating the intent.
standard address The IStandard implementation responsible for validating and parsing the UserIntent
header bytes Metadata associated with the UserIntent, interpreted by standard. Stored as bytes for flexibility.
instructions bytes[] The execution details of the UserIntent, interpreted by standard. Stored as bytes[] to allow flexibility.
signatures bytes[] Validatable signatures required for execution, interpreted by standard.

Fields Explanations

  • header: The bytes header can carry information about how to validate the intent or how to prevent double-spending. For example, header can contain an uint256 nonce to check if the nonce is used already.
  • instructions: These bytes instructions can just be concatenated (address,value,calldata) or can be standardized values, for example (erc20TokenAddress,1000) means the instructions can use up to 1000 of the specified ERC-20 token. It is NOT REQUIRED that all instructions MUST be provided by the EOA owner to allow dynamically carry out other operations during intent executions, but the IStandard design needs to carefully handle this case.
  • signatures: The bytes signatures field can support different signing methods. It is NOT REQUIRED that all signatures MUST be provided by the EOA owner, some of them MAY be provided by solver, relayer or anyone else.

Pack UserIntent as Bytes

The UserIntent object is packed and encoded into bytes calldata userIntent. There is no strict schema requirement for the data structure. Each IAccount and IStandard implementation can define its own encoding and decoding methods for handling the bytes data.

Here is an example of packed-encoded format:

Section Value Type Description
userIntent[0:20] address sender
userIntent[20:40] address standard
userIntent[40:42] uint16 Length of header
userIntent[42:44] uint16 Length of instructions
userIntent[44:46] uint16 Length of signatures
Next headerLength bytes bytes The actual header data
Next instructionsLength bytes bytes The actual instructions data
Next signatureLength bytes bytes The actual signatures data
Remaining bytes bytes Extra data, such as nested intents for further execution

IStandard Interface

Each standard defines how to parse and validate a UserIntent. Implementations of standard must conform to the IStandard interface:

interface IStandard {
    /**
     * Validate user's intent
     *
     * @dev returning validation result, the type uses bytes4 for extensibility purpose
     * @return result values representing validation outcomes
     */
    function validateUserIntent(bytes calldata intent) external view returns (bytes4 result);

    /**
     * Unpack user's intent, it is RECOMMENDED to validate intent while unpacking to save gas
     *
     * @dev returning unpacked result, the type uses bytes for extensibility purpose
     * @return result unpacked result status
     * @return operations unpacked operations that can be executed by the IAccount, NOT REQUIRED to match UserIntent.instructions
     */
    function unpackOperations(bytes calldata intent) external view returns (bytes4 result, bytes[] memory operations);
}

The IStandard interface is responsible for defining and enforcing the validation logic for UserIntent objects.

It operates similarly to the EntryPoint in ERC-4337 and ERC-7521.

The extensibility of bytes4 return types allows future upgrades without modifying the function signatures.

IAccount Interface

On the account side, IAccount provides the interface for executing bytes calldata intent:

interface IAccount {
    /**
     * Execute user's intent
     * 
     * @dev returning execution result, the type uses bytes for extensibility purpose
     * @return result values representing execution outcomes
     */
    function executeUserIntent(bytes calldata intent) external returns (bytes memory);
}

Using SET_CODE_TX_TYPE=0x04, EOAs can delegate contract code to an IAccount implementation, enabling them to function as smart accounts. A single account implementation can be shared across multiple EOAs, meaning:

  • It only needs to be deployed and audited once.
  • Each EOA owner is responsible for delegating their account to a secure IAccount implementation.

It is RECOMMENDED that each account leverages IStandard to validate and unpack operations, check Reference Implementation for examples. Account smart contract can be stateless to avoid sharing storage space with other delegated contracts.

Rationale

Usage of Bytes

Defining UserIntent object as a struct would improve readability and make it easier to work with in Solidity. For example:

struct UserIntent {
    address sender;
    address standard;
    bytes header;
    bytes[] instructions;
    bytes[] signatures;
}

However, this approach has several drawbacks:

  • Mandating all IAccount and IStandard implementations to follow this specific struct format reduces flexibility.
  • The use of bytes[] introduces additional gas costs due to Solidity’s dynamic array encoding.

Since all objects within the UserIntent structure are optional and their usage depends on IStandard and IAccount implementations, the bytes format ensures maximum flexibility while preserving compatibility.

Execution in EOA Contract Code

With SET_CODE_TX_TYPE=0x04, EOAs gain the ability to execute contract code. Executing transactions directly from an EOA provides several key benefits:

  • Preserves EOA Control: Execution remains fully controlled by the account owner. If needed, the EOA owner can easily disable all smart contract functionalities by un-delegating the contract code.
  • Consistent msg.sender Behavior: Since the execution originates from an EOA, msg.sender always resolves to the EOA address, simplifying authentication and permission checks.
  • Stateless Execution: The execution logic can be designed to be stateless, allowing the IAccount implementation to avoid storing persistent data, reducing storage costs.

If an EOA does not require smart contract execution, or if executing an intent is too expensive, the owner can still use the account as a regular EOA without any modifications.

Validation in the Standard Contract

Validation logic often relies on contract state. For example, a weighted multi-owner signature scheme needs to track the weight assigned to each signer. Keeping intent validation entirely within IStandard offers multiple advantages:

  • Simplified Implementation: By mirroring the EntryPoint concept from ERC-4337 but in a simpler form, IStandard focuses solely on validation.
  • Easier Auditing and Maintenance: Since IStandard is responsible only for validation, it becomes easier for contract engineers to implement, audit, and maintain.
  • Modular Validation: The IStandard interface is inherently modular, allowing for more complex validation mechanisms. For instance, a “compound” standard could decompose an intent into smaller components, validate each separately, and then combine the results.

Gas Abstraction

This design enables gasless transactions by allowing any address to initiate a transaction on behalf of the intent’s sender.

  • The sender can specify how and what to pay in the intent’s header or instructions.
  • Payments can be made in any token from the sender’s account.
  • The transaction cost can be covered by transferring tokens from the sender’s account to tx.origin (the address submitting the transaction).

No re-entry protection enforced

This proposal does not enforce built-in re-entry protection mechanisms such as nonces. The rationale behind this decision is that certain intents are inherently designed to be executed multiple times.

Instead of a global re-entry protection mechanism, each standard should define its own protection rules based on its intended use case. Implementers are encouraged to:

Backwards Compatibility

This IAccount standard shares the same backwards compatibility considerations as the introduction of EOA contract code execution (SET_CODE_TX_TYPE=0x04).

Reference Implementation

Helper Library

This PackedIntent is a library to decode (address sender, address standard, uint16 headerLength, uint16 instructionsLength, uint16 signaturesLength) from a packed encoded intent. The following IAccount and IStandrd implementations both follow PackedIntent schema.

/// @title PackedIntent
/// @notice This is a library that packs metadata of intent (sender, standard, lengths) into bytes
/// @dev the packed intent data schema is defined as follows:
/// @dev 1. sender: address, 20-bytes
/// @dev 2. standard: address, 20-bytes
/// @dev 3. headerLength: uint16, 2-bytes
/// @dev 4. instructionLength: uint16, 2-bytes
/// @dev 5. signatureLength: uint16, 2-bytes
library PackedIntent {
    /// @notice getSenderAndStandard is a function that gets the sender and standard from the intent
    /// @param intent The intent to get the sender and standard from
    /// @return sender The sender of the intent
    /// @return standard The standard of the intent
    function getSenderAndStandard(bytes calldata intent) external pure returns (address, address) {
        require(intent.length >= 40, "Intent too short");
        return (address(bytes20(intent[: 20])), address(bytes20(intent[20 : 40])));
    }

    /// @notice getLengths is a function that gets the lengths from the intent
    /// @param intent The intent to get the lengths from
    /// @return headerLength The length of the header
    /// @return instructionLength The length of the instructions
    /// @return signatureLength The length of the signature
    function getLengths(bytes calldata intent) external pure returns (uint256, uint256, uint256) {
        require(intent.length >= 46, "Missing length section");
        return (
        uint256(uint16(bytes2(intent[40 : 42]))),
        uint256(uint16(bytes2(intent[42 : 44]))),
        uint256(uint16(bytes2(intent[44 : 46])))
        );
    }

    /// @notice getSignatureLength is a function that gets the signature length from the intent
    /// @param intent The intent to get the signature length from
    /// @return signatureLength The length of the signature
    function getSignatureLength(bytes calldata intent) external pure returns (uint256) {
        require(intent.length >= 46, "Missing length section");
        return uint256(uint16(bytes2(intent[44 : 46])));
    }

    /// @notice getIntentLength is a function that gets the intent length from the intent
    /// @param intent The intent to get the intent length from
    /// @return result The sum of header, instruction and signature lengths
    function getIntentLength(bytes calldata intent) external pure returns (uint256) {
        require(intent.length >= 46, "Missing length section");
        uint256 headerLength = uint256(uint16(bytes2(intent[40 : 42])));
        uint256 instructionLength = uint256(uint16(bytes2(intent[42 : 44])));
        uint256 signatureLength = uint256(uint16(bytes2(intent[44 : 46])));
        return headerLength + instructionLength + signatureLength + 46;
    }

    /// @notice getIntentLengthFromSection is a function that gets the intent length from the length section
    /// @param lengthSection The length section to get the intent length from
    /// @return result The sum of header, instruction and signature lengths
    function getIntentLengthFromSection(bytes6 lengthSection) external pure returns (uint16 result) {
        assembly {
            let value := lengthSection
            let a := shr(240, value) // Extract first 2 bytes
            let b := and(shr(224, value), 0xFFFF) // Extract next 2 bytes
            let c := and(shr(208, value), 0xFFFF) // Extract last 2 bytes
            result := add(add(add(a, b), c), 46)
        }
    }
}

Relayed Execution Standard

This RelayedExecutionStandard allows relayer to execute the operations on chain and take ERC-20 token from the intent sender, thus achieve a gas-less experience for the sender.

import {MessageHashUtils}
import {ECDSA}
import {IERC20}
import {IStandard}
import {IAccount}
import {PackedIntent}

/// @title ERC7806Constants
/// @notice This is a library that defines the constants for the ERC7806 standard
library ERC7806Constants {
/// @notice VALIDATION_DENIED is the magic value of denied intent
bytes4 public constant VALIDATION_DENIED = 0x00000000;

/// @notice VALIDATION_APPROVED is the magic value of validated intent
bytes4 public constant VALIDATION_APPROVED = 0x00000001;
}

abstract contract HashGatedStandard is IStandard {
    event HashUsed(address sender, uint256 hash);

    mapping(bytes32 => bool) internal _hashes;

    function checkHash(address sender, uint256 hash) external view returns (bool) {
        bytes32 compositeKey = keccak256(abi.encode(sender, hash));
        return _hashes[compositeKey];
    }

    function markHash(uint256 hash) external {
        bytes32 compositeKey = keccak256(abi.encode(msg.sender, hash));
        _hashes[compositeKey] = true;

        emit HashUsed(msg.sender, hash);
    }
}

/*
RelayedExecutionStandard

This standard allows sender to define a list of execution instructions and asks the relayer to execute
on chain on behalf of the sender. It is hash and time gated means the intent can only be executed before
a timestamp and can only be executed once.

The first 20 bytes of the `intent` is sender address.
The next 20 bytes of the `intent` is the standard address, which should be equal to address of this standard.
The following is the length section, containing 3 uint16 defining header length, instructions length and signature length.

The header is either 8 bytes long or 28 bytes long.
The 8-byte part is the timestamp in epoch seconds.
The optional 20-byte defines the assigned relayer address if the sender only wants a specific relayer to execute.

The instructions contains 2 main part.
The first 36 bytes is a packed encoded (address, uint128) pair representing the 'payment' that the sender will pay to the
relayer. It should be an ERC20 token.
The following 1-byte is an uint8 defining the number of instructions to execute.
The instructions are concatenated together, the first 2 bytes (uint16) defines the length of each instruction, the following
is the instruction body. Instructions should be abi.encode(address, uint256, bytes) which can directly be executed by
the sender account.

The signature field is always 65 bytes long. It contains the signed bytes.concat(header, instructions).
*/
contract RelayedExecutionStandard is HashGatedStandard {
    using ECDSA for bytes32;

    string public constant ICS_NUMBER = "ICS1";
    string public constant DESCRIPTION = "Timed Hashed Relayed Execution Standard";
    string public constant VERSION = "0.0.0";
    string public constant AUTHOR = "hellohanchen";

    function validateUserIntent(bytes calldata intent) external view returns (bytes4) {
        (address sender, address standard) = PackedIntent.getSenderAndStandard(intent);
        require(standard == address(this), "Not this standard");
        (uint256 headerLength, uint256 instructionsLength, uint256 signatureLength) = PackedIntent.getLengths(intent);
        require(headerLength == 28 || headerLength == 8, "Invalid header length");
        require(instructionsLength >= 36, "Instructions too short");
        require(signatureLength == 65, "Invalid signature length");
        // end of instructions
        uint256 instructionsEndIndex = 46 + headerLength + instructionsLength;
        require(instructionsLength + signatureLength == intent.length, "Invalid intent length");

        // validate signature
        uint256 hash = _validateSignatures(sender, intent, instructionsEndIndex);
        require(!this.checkHash(sender, hash), "Hash is already executed");

        // header contains expiration timestamp and assigned relayer (optional)
        require(uint256(uint64(bytes8(intent[46 : 54]))) >= block.timestamp, "Intent expired");
        // assignedRelayerAddress = address(intent[54:74]) [optional]

        // end of header section / begin of instruction section
        uint256 headerEndIndex = 46 + headerLength;
        // first 20 bytes of instruction is out token address
        address outTokenAddress = address(bytes20(intent[headerEndIndex : headerEndIndex + 20]));
        // out token amount, use uint128 to shorten the intent
        uint256 outTokenAmount = uint256(uint128(bytes16(intent[headerEndIndex + 20 : headerEndIndex + 36])));
        if (outTokenAddress != address(0)) {
            (bool success, bytes memory data) = outTokenAddress.staticcall(
                abi.encodeWithSelector(IERC20.balanceOf.selector, sender)
            );
            if (!success || data.length != 32) {
                revert("Not ERC20 token");
            }
            require(abi.decode(data, (uint256)) >= outTokenAmount, "Insufficient token balance");
        } else {
            require(sender.balance >= outTokenAmount, "Insufficient eth balance");
        }

        // end of outToken instruction
        uint256 numExecutions = uint256(uint8(bytes1(intent[headerEndIndex + 36 : headerEndIndex + 37])));
        // instruction index
        uint256 instructionIndex = 0;
        // begin of the first instruction
        uint256 instructionStart;
        uint256 instructionEnd = headerEndIndex + 37;

        while (instructionIndex < numExecutions) {
            instructionStart = instructionEnd;
            require(instructionStart + 2 <= instructionsEndIndex, "Intent too short: instruction length");
            // end of this execution instruction
            instructionEnd = instructionStart + 2 + uint256(uint16(bytes2(intent[instructionStart : instructionStart + 2])));
            require(instructionEnd <= instructionsEndIndex, "Intent too short: single instruction");

            instructionIndex += 1;
        }
        require(instructionEnd == instructionsEndIndex, "Intent length doesn't match");

        return ERC7806Constants.VALIDATION_APPROVED;
    }

    function unpackOperations(bytes calldata intent) external view returns (bytes4 code, bytes[] memory unpackedInstructions) {
        (address sender, address standard) = PackedIntent.getSenderAndStandard(intent);
        require(standard == address(this), "Not this standard");
        (uint256 headerLength, uint256 instructionsLength, uint256 signatureLength) = PackedIntent.getLengths(intent);
        require(headerLength == 28 || headerLength == 8, "Invalid header length");
        require(instructionsLength >= 36, "Instructions too short");
        require(signatureLength == 65, "Invalid signature length");
        // end of instructions
        uint256 instructionsEndIndex = 46 + headerLength + instructionsLength;
        require(instructionsLength + signatureLength == intent.length, "Invalid intent length");

        // fetch header content (timestamp, relayer address [optional])
        require(uint256(uint64(bytes8(intent[46 : 54]))) >= block.timestamp, "Intent expired");
        if (headerLength == 28) {
            // assigned relayer
            require(tx.origin == address(bytes20(intent[54 : 74])), "Invalid relayer");
        }

        uint256 intentHash = _validateSignatures(sender, intent, instructionsEndIndex);
        require(!this.checkHash(sender, intentHash), "Hash is already executed");

        // begin of instructions
        uint256 headerEndIndex = headerLength + 46;
        // total instructions = mark hash + transfer token to relayer + executions
        // the first 36 bytes defines the payment to relayer
        // the next 1 byte defines the number of execution instructions
        unpackedInstructions = new bytes[](2 + uint8(bytes1(intent[headerEndIndex + 36 : headerEndIndex + 37])));
        // first instruction is mark hash to prevent re-entry attack
        unpackedInstructions[0] = abi.encode(
            address(this), 0, abi.encodeWithSelector(this.markHash.selector, intentHash));

        // the first 20 bytes of instructions is the out token address
        address outTokenAddress = address(bytes20(intent[headerEndIndex : headerEndIndex + 20]));
        // amount
        uint256 outTokenAmount = uint256(uint128(bytes16(intent[headerEndIndex + 20 : headerEndIndex + 36])));
        // out token instruction
        if (outTokenAddress == address(0)) {
            unpackedInstructions[1] = abi.encode(address(tx.origin), outTokenAmount, "");
        } else {
            unpackedInstructions[1] = abi.encode(
                outTokenAddress,
                uint256(0),
                abi.encodeWithSelector(IERC20.transfer.selector, address(tx.origin), outTokenAmount));
        }

        // instruction index
        uint256 instructionIndex = 2;
        uint256 instructionEndIndex = headerEndIndex + 37;
        uint256 instructionStartIndex;
        while (instructionIndex < unpackedInstructions.length) {
            // start of next execution instruction
            instructionStartIndex = instructionEndIndex;
            require(instructionStartIndex + 2 <= instructionEndIndex, "Intent too short: instruction length");
            // end of next execution instruction
            instructionEndIndex = instructionStartIndex + 2 + uint256(uint16(bytes2(intent[instructionStartIndex : instructionStartIndex + 2])));
            require(instructionEndIndex <= instructionsEndIndex, "Intent too short: single instruction");

            unpackedInstructions[instructionIndex] = intent[instructionStartIndex + 2 : instructionEndIndex];

            instructionIndex += 1;
        }
        require(instructionEndIndex == instructionsEndIndex, "Intent length doesn't match");

        return (ERC7806Constants.VALIDATION_APPROVED, unpackedInstructions);
    }

    function _validateSignatures(
        address sender, bytes calldata intent, uint256 sigStartIndex
    ) internal view returns (uint256) {
        bytes32 intentHash = keccak256(abi.encode(intent[46 : sigStartIndex], address(this), block.chainid));
        bytes32 messageHash = MessageHashUtils.toEthSignedMessageHash(intentHash);
        require(sender == messageHash.recover(intent[sigStartIndex : sigStartIndex + 65]), "Invalid sender signature");

        return uint256(intentHash);
    }

    // -------------
    // The following methods will be removed after testing
    // -------------
    function sampleIntent(
        address sender, address relayer,
        address outTokenAddress, uint128 outAmount,
        bytes[] memory executions
    ) external view returns (
        bytes memory intent, bytes32 intentHash
    ) {
        bytes memory header = relayer == address(0) ?
        abi.encodePacked(uint64((block.timestamp + 31536000) & 0xFFFFFFFFFFFFFFFF)) :
        abi.encodePacked(uint64((block.timestamp + 31536000) & 0xFFFFFFFFFFFFFFFF), relayer);

        bytes memory instructions = bytes.concat(bytes20(outTokenAddress), bytes16(outAmount), bytes1(uint8(executions.length)));
        for (uint256 i = 0; i < executions.length; i++) {
            uint16 length = uint16(executions[i].length);
            instructions = bytes.concat(instructions, bytes2(length), executions[i]);
        }

        bytes memory toSign = bytes.concat(header, instructions);
        intentHash = keccak256(abi.encode(toSign, address(this), block.chainid));

        intent = bytes.concat(bytes20(sender), bytes20(address(this)), bytes2(uint16(header.length)), bytes2(uint16(instructions.length)), bytes2(uint16(65)), toSign);

        return (intent, intentHash);
    }

    function sampleERC20Execution(
        address token, address receiver, uint256 amount
    ) external pure returns (bytes memory) {
        if (token == address(0)) {
            return abi.encode(receiver, amount, "");
        }

        return abi.encode(token, uint256(0), abi.encodeWithSelector(IERC20.transfer.selector, address(receiver), amount));
    }

    function executeUserIntent(bytes calldata intent) external returns (bytes memory) {
        (address sender,) = PackedIntent.getSenderAndStandard(intent);
        bytes memory executeCallData = abi.encodeWithSelector(IAccount.executeUserIntent.selector, intent);

        (, bytes memory result) = sender.call{value : 0, gas : gasleft()}(executeCallData);
        return result;
    }
}

Sample Account

The following IAccount implementation uses a StandardRegistry to maintain allowlist of standards and just batch execute all operations returned from IStandard.unpackOperations.

import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

/// @title StandardRegistry
/// @notice This is a registry for standards, determining whether an account accepts a standard
/// @dev EIP-712 is used for signature verification
contract StandardRegistry {
    using ECDSA for bytes32;

    /// @notice The event emitted when a standard is registered
    event StandardRegistered(address indexed signer, address indexed standard);
    /// @notice The event emitted when a standard is unregistered
    event StandardUnregistered(address indexed signer, address indexed standard);

    /// @notice The domain separator of this contract
    bytes32 public immutable DOMAIN_SEPARATOR;
    /// @notice The type hash of the signed data of this contract
    bytes32 public immutable SIGNED_DATA_TYPEHASH;

    /// @notice The mapping of nonces
    mapping(bytes32 nonce => bool used) private _nonces;
    /// @notice The mapping of registrations
    mapping(bytes32 standard => bool registered) private _registrations;

    /// @notice The constructor of this contract
    constructor() {
        DOMAIN_SEPARATOR = keccak256(
            abi.encode(
                keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
                keccak256(bytes("StandardRegistry")), // Contract name
                keccak256(bytes("2")), // Version
                block.chainid, // Chain ID
                address(this) // Contract address
            )
        );

        SIGNED_DATA_TYPEHASH = keccak256(
            "Permission(bool registering,address standard,uint256 nonce)"
        );
    }

    /// @notice The function to permit a standard, allowing a relayer to register or unregister a standard for a user
    /// @param registering Whether registering or unregistering
    /// @param signer The signer of the permission
    /// @param standard The standard to permit
    /// @param nonce The nonce of the permission
    /// @param signature The signature of the permission
    function permit(bool registering, address signer, address standard, uint256 nonce, bytes calldata signature) external {
        bytes32 compositeKey = keccak256(abi.encodePacked(signer, nonce));
        require(!_nonces[compositeKey], "Invalid nonce");

        // validate signature
        bytes32 structHash = keccak256(
            abi.encode(SIGNED_DATA_TYPEHASH, registering, standard, nonce)
        );
        bytes32 digest = MessageHashUtils.toTypedDataHash(DOMAIN_SEPARATOR, structHash);
        require(signer == digest.recover(signature), "Invalid signature");

        _process(registering, signer, standard, nonce);
    }

    /// @notice The function to update a standard registration directly
    /// @param registering Whether registering or unregistering
    /// @param standard The standard to update
    /// @param nonce The nonce of the update
    function update(bool registering, address standard, uint256 nonce) external {
        address signer = msg.sender;
        bytes32 compositeKey = keccak256(abi.encodePacked(signer, nonce));
        require(!_nonces[compositeKey], "Invalid nonce");

        _process(registering, signer, standard, nonce);
    }

    /// @notice The function to check if a nonce is used
    /// @param signer The signer of the nonce
    /// @param nonce The nonce to check
    /// @return result true if the nonce is used
    function isNonceUsed(address signer, uint256 nonce) external view returns (bool) {
        bytes32 compositeKey = keccak256(abi.encodePacked(signer, nonce));
        return _nonces[compositeKey];
    }

    /// @notice The function to check if a standard is registered
    /// @param signer The signer of the standard
    /// @param standard The standard to check
    /// @return result true if the standard is registered
    function isRegistered(address signer, address standard) external view returns (bool) {
        bytes32 compositeKey = keccak256(abi.encodePacked(signer, standard));

        return _registrations[compositeKey];
    }

    /// @notice The function to process a standard registration or unregistration
    /// @param registering Whether registering or unregistering
    /// @param signer The signer of the registration
    /// @param standard The standard to process
    /// @param nonce The nonce of the registration
    function _process(bool registering, address signer, address standard, uint256 nonce) internal {
        bytes32 compositeKey = keccak256(abi.encodePacked(signer, standard));

        if (registering) {
            _registrations[compositeKey] = true;
            emit StandardRegistered(signer, standard);
        } else {
            _registrations[compositeKey] = false;
            emit StandardUnregistered(signer, standard);
        }

        compositeKey = keccak256(abi.encodePacked(signer, nonce));
        _nonces[compositeKey] = true;
    }
}
contract AccountImplV0 {
    string public constant DESCRIPTION = "Account with Batch Execution, Standard Registry";
    string public constant VERSION = "0.0.0";
    string public constant AUTHOR = "hellohanchen";

    StandardRegistry public constant REGISTRY = StandardRegistry(address());
    bytes4 public constant VALIDATION_APPROVED = 0x00000001;
    bytes4 public constant VALIDATION_DENIED = 0x00000000;

    function executeOtherIntent(bytes calldata intent) override internal returns (bytes memory) {
        (address sender, address standard) = PackedIntent.getSenderAndStandard(intent);
        require(sender == address(this), "Intent is not from this account");
        require(REGISTRY.isRegistered(address(this), standard), "Standard not registered");
        // standard validation and unpack
        (bytes4 validationCode, bytes[] memory instructions) = IStandard(standard).unpackOperations(intent);
        require(validationCode == VALIDATION_APPROVED, "Validation failed");

        // batch execute
        for (uint256 i = 0; i < instructions.length; i++) {
            (address dest, uint256 value, bytes memory data) = abi.decode(instructions[i], (address, uint256, bytes));

            (bool success,) = dest.call{value : value, gas : gasleft()}(data);
            if (!success) {
                revert SelfExecutableAccount.ExecutionError();
            }
        }

        return new bytes(0);
    }

    receive() external payable {}
}

As shown above, the implementation of IAccount is stateless and simple, so that it can be compatible with different IStandard. While the IStandard implementation is complex because it needs to define its own schema. But both contracts will be public and audited, to ensure the security of intent execution.

Security Considerations

The security of this standard primarily depends on the implementation of both IStandard and IAccount. Each component must ensure that user intents are validated and executed safely. Additionally, solvers are responsible for securing their own execution environments to prevent unintended exploits.

Auditability of both Validation and Execution

To ensure security and maintain ecosystem integrity, it is critical that both the standard (IStandard) and account (IAccount) implementations are:

  • Publicly auditable: Open access to contract code allows security researchers to identify potential vulnerabilities.
  • Well-reviewed and shared: Public discussions and peer reviews help strengthen security assumptions.
  • Secure against compatibility risks: Ensuring compatibility between different standard and account implementations can prevent unintended interactions that may lead to exploits.

Delegated Contract Storage Risks

If an IAccount implementation maintains state (instead of being stateless), it could:

  • Interfere with other delegated contracts sharing the same storage.
  • Be manipulated by unauthorized users if storage is not properly protected.

Strongly RECOMMEND stateless execution to prevent storage conflicts.

Copyright and related rights waived via CC0.