ERC-7739: Readable Typed Signatures for Smart Accounts

Readable Typed Signatures for Smart Accounts

Description: A defensive rehashing scheme which prevents signature replays across smart accounts and preserves the readability of the signed contents.

Authors: vectorized (@vectorized), Sihoon Lee (@push0ebp), Francisco Giordano (@frangio), Im Juno (@junomonster), howydev (@howydev), 0xcuriousapple (@0xcuriousapple)

Requires: 191, 712, 1271, 5267

Preliminary Reading

Signature replay vulnerability with ERC-1271

Defensive rehashing implementations in smart accounts

Twitter thread on opaqueness in defensive rehashing:
https://twitter.com/howydev/status/1780353754333634738

Twitter thread on a possible phishing vector with readable defensive rehashing, and how it was fixed:
https://twitter.com/push0ebp/status/1786460154277572718

Code

Solady Solidity ERC-1271 implementation, which implements this ERC-7739.

Viem utilities for Solady ERC-1271:


Abstract

This proposal defines a standard to prevent signature replays across multiple smart accounts when they are owned by a single Externally Owned Account (EOA). This is achieved through a defensive rehashing scheme for ERC-1271 verification using specific nested EIP-712 typed structures, which preserves the readability of the signed contents during wallet client signature requests.

Motivation

Smart accounts can verify signatures with via ERC-1271 using the isValidSignature function.

A straightforward implementation as shown below, is vulnerable to signature replay attacks.

/// @dev This implementation is NOT safe.
function isValidSignature(
    bytes32 hash,
    bytes calldata signature
) external override view returns (bytes4) {
    if (ECDSA.recover(hash, signature) == owner) {
        return 0x1626ba7e;
    } else {
        return 0xffffffff;
    }
}

When a multiple smart accounts are owned by a single EOA, the same signature can be replayed across the smart accounts if the hash does not include the smart account address.

Unfortunately, this is the case for many popular applications (e.g. Permit2). As such, many smart account implementations perform some form of defensive rehashing. First, the smart account computes a final hash from minimally: (1) the hash, (2) its own address, (3) the chain ID. Then, the smart account verifies the final hash against the signature. Defensive rehashing can be implemented with EIP-712, but a straightforward implementation will make the signed contents opaque.

This standard provides a defensive rehashing scheme that makes the signed contents visible across all wallet clients that support EIP-712. It is designed for minimal adoption friction. Even if wallet clients or application frontends are not updated, users can still inject client side JavaScript to enable the defensive rehashing.

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.

Overview

Compliant smart account MUST implement the following:

  • EIP-712 Typed structured data hashing and signing.
    Provides the relevant typed data hashing logic internally, which is required to construct the final hashes.

  • ERC-1271 Standard Signature Validation Method for Contracts.
    Provides the isValidSignature(bytes32 hash, bytes calldata signature) function.

  • ERC-5267 Retrieval of EIP-712 domain.
    Provides the eip712Domain() function which is required to compute the final hashes.

This standard defines the behavior of the isValidSignature function for ERC-1271, which comprises of two workflows: (1) the TypedDataSign workflow, (2) the PersonalSign workflow.

TypedDataSign workflow

The TypedDataSign workflow handles the case where the hash is originally computed with EIP-712.

TypedDataSign final hash

The final hash for the TypedDataSign workflow is defined as:

keccak256(\x19\x01 ‖ APP_DOMAIN_SEPARATOR ‖
    hashStruct(TypedDataSign({
        contents: hashStruct(originalStruct),
        name: eip712Domain().name,
        version: eip712Domain().version,
        chainId: eip712Domain().chainId,
        verifyingContract: eip712Domain().verifyingContract,
        salt: eip712Domain().salt,
        extensions: eip712Domain().extensions
    }))
)

where denotes the concatenation operator for bytes.

In Solidity, this can be written as:

keccak256(
    abi.encodePacked(
        hex"1901",
        // Application specific domain separator. Passed via `signature`.
        bytes32(APP_DOMAIN_SEPARATOR),
        keccak256(
            abi.encode(
                // Computed on-the-fly with `contentsType`, which is passed via `signature`.
                typedDataSignTypehash, 
                // This is the `contents` struct hash, which is passed via `signature`.
                bytes32(hashStruct(originalStruct)),
                // `eip712Domain()` is from ERC-5267. 
                keccak256(bytes(eip712Domain().name)), 
                keccak256(bytes(eip712Domain().version)),
                uint256(eip712Domain().chainId),
                uint256(uint160(eip712Domain().verifyingContract)),
                bytes32(eip712Domain().salt),
                keccak256(abi.encodePacked(eip712Domain().extensions))
            )
        )
    )
)

where typedDataSignTypehash is:

abi.encodePacked(
    "TypedDataSign(",
        contentsTypeName,
        "bytes1 fields,",
        "string name,",
        "string version,",
        "uint256 chainId,",
        "address verifyingContract,",
        "bytes32 salt,",
        "uint256[] extensions",
    ")",
    contentsType
)

If contentsType is "Mail(address from,address to,string message)", then contentsTypeName will be "Mail".

The contentsTypeName function can be computed with:

// `LibString`: https://github.com/Vectorized/solady/blob/main/src/utils/LibString.sol
//
// `slice(string memory subject, uint256 start, uint256 end)` 
// returns a copy of `subject` sliced from `start` to `end` (exclusive).
// `start` and `end` are byte offsets.
//
// `indexOf(string memory subject, string memory search)`
// Returns the byte index of the first location of `search` in `subject`,
// searching from left to right. Returns `2**256 - 1` if `search` is not found.
LibString.slice(t, 0, LibString.indexOf(t, "("));

For safety, smart accounts MUST treat the signature as invalid if any of the following is true:

  • contentsTypeName is the empty string (i.e. bytes(contentsTypeName).length == 0).
  • contentsTypeName starts with any of the following bytes abcdefghijklmnopqrstuvwxyz(.
  • contentsTypeName contains any of the following bytes , )\x00.

TypedDataSign signature

The signature passed into isValidSignature will be changed to:

originalSignature ‖ APP_DOMAIN_SEPARATOR ‖ contents ‖ contentsType ‖ uint16(contentsType.length)

where contents is the bytes32 struct hash of the original struct.

In Solidity, this can be written as:

abi.encodePacked(
    bytes(originalSignature),
    bytes32(APP_DOMAIN_SEPARATOR),
    bytes32(contents),
    bytes(contentsType),
    uint16(contentsType.length)
)

The appended APP_DOMAIN_SEPARATOR and contents struct hash will be used to verify if the hash passed into isValidSignature is indeed correct via:

hash == keccak256(
    abi.encodePacked(
        hex"1901",
        bytes32(APP_DOMAIN_SEPARATOR),
        bytes32(contents)
    )
)

PersonalSign workflow

This PersonalSign workflow handles the case where the hash is originally computed with EIP-191.

PersonalSign final hash

The final hash for the PersonalSign workflow is defined as:

keccak256(\x19\x01 ‖ ACCOUNT_DOMAIN_SEPARATOR ‖
    hashStruct(PersonalSign({
        prefixed: keccak256(bytes(\x19Ethereum Signed Message:\n ‖
        base10(bytes(someString).length) ‖ someString))
    }))
)

where denotes the concatenation operator for bytes.

In Solidity, this can be written as:

keccak256(
    abi.encodePacked(
        hex"1901",
        // Smart account domain separator.
        // Can be computed via `eip712Domain()` from ERC-5267.
        bytes32(ACCOUNT_DOMAIN_SEPARATOR),
        keccak256(
            abi.encode(
                // `PERSONAL_SIGN_TYPEHASH`.
                keccak256("PersonalSign(bytes prefixed)"),
                // `hash` is from `isValidSignature(hash, signature)`
                hash
            )
        )
    )
)

Here, hash is computed in the application contract and passed into isValidSignature.

The smart account does not need to know how hash is computed. For completeness, this is how it can be computed:

abi.encodePacked(
    "\x19Ethereum Signed Message:\n",
    // `LibString`: https://github.com/Vectorized/solady/blob/main/src/utils/LibString.sol
    //
    // `toString` returns the base10 representation of a uint256.
    LibString.toString(someString.length),
    // This is the original message to be signed.
    someString
)

PersonalSign signature

The PersonalSign workflow does not require additional data to be appended to the signature passed into isValidSignature.

supportsNestedTypedDataSign function for detection

To facilitate automatic detection, smart accounts SHOULD implement the following function:

/// @dev For automatic detection that the smart account supports the nested EIP-712 workflow.
/// By default, it returns `bytes32(bytes4(keccak256("supportsNestedTypedDataSign()")))`,
/// denoting support for the default behavior, as implemented in
/// `_erc1271IsValidSignatureViaNestedEIP712`, which is called in `isValidSignature`.
/// Future extensions should return a different non-zero `result` to denote different behavior.
/// This method intentionally returns bytes32 to allow freedom for future extensions.
function supportsNestedTypedDataSign() public view virtual returns (bytes32 result) {
    result = bytes4(0xd620c85a);
}

Workflow detection

The isValidSignature function MUST perform the TypedDataSign workflow if the signature contains the correct data to reconstruct the hash.

This can be checked via:

// If this is true, it means that the `signature` contains 
// the correct `APP_DOMAIN_SEPARATOR` and `contents`.
hash == keccak256(
    abi.encodePacked(
        hex"1901",
        bytes32(APP_DOMAIN_SEPARATOR),
        bytes32(contents)
    )
)

If this expression returns false, then the PersonalSign workflow MUST be attempted.

Conditional skipping of defensive rehashing

Smart accounts MAY skip the defensive rehashing workflows if any of the following is true:

  • isValidSignature is called off-chain.
  • The hash passed into isValidSignature has already included the address of the smart account.

Rationale

TypedDataSign structure

The typedDataSignTypehash must be constructed on-the-fly on-chain. This is to enforce that the signed contents will be visible in the signature request, by requiring that contents be a user defined type.

The structure is intentionally made flat with the fields of eip712Domain to make implementation feasible. Otherwise, smart accounts must implement on-chain lexographical sorting of strings for the struct type names when constructing typedDataSignTypehash.

supportsNestedTypedDataSign for detection

Without this function, this standard will not change the interface of the smart account, as it defines the behavior of isValidSignature without adding any new functions. As such, ERC-165 cannot be used.

For future extendability, supportsNestedTypedDataSign is defined to return a bytes32 as the first word of its returned data. For bytecode compactness and to leave space for bit packing, only the leftmost 4 bytes are set to the function selector of supportsNestedTypedDataSign.

The supportsNestedTypedDataSign function may be extended to return multiple values (e.g. bytes32 result, bytes memory data), as long as the first word of the returned data is a bytes32 identifier. This will not change the function selector.

Backwards Compatibility

No backwards compatibility issues.

Reference Implementation

The following reference implementation is production ready and optimized. It also includes relevant complementary features required for safety, flexibility, developer experience, and user experience.

It is intentionally not minimalistic. This is to avoid repeating the mistake of ERC-1271, where a reference implementation is wrongly assumed to be safe for production use.

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

// https://github.com/Vectorized/solady/blob/main/src/utils/EIP712.sol
import {EIP712} from "../utils/EIP712.sol";
// https://github.com/Vectorized/solady/blob/main/src/utils/SignatureCheckerLib.sol
import {SignatureCheckerLib} from "../utils/SignatureCheckerLib.sol";

/// @notice ERC1271 mixin with nested EIP-712 approach.
/// @author Solady (https://github.com/vectorized/solady/blob/main/src/accounts/ERC1271.sol)
abstract contract ERC1271 is EIP712 {
    /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
    /*                         CONSTANTS                          */
    /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

    /// @dev `keccak256("PersonalSign(bytes prefixed)")`.
    bytes32 internal constant _PERSONAL_SIGN_TYPEHASH =
        0x983e65e5148e570cd828ead231ee759a8d7958721a768f93bc4483ba005c32de;

    /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
    /*                     ERC1271 OPERATIONS                     */
    /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

    /// @dev Validates the signature with ERC1271 return,
    /// so that this account can also be used as a signer.
    function isValidSignature(bytes32 hash, bytes calldata signature)
        public
        view
        virtual
        returns (bytes4 result)
    {
        bool success = _erc1271IsValidSignature(hash, _erc1271UnwrapSignature(signature));
        /// @solidity memory-safe-assembly
        assembly {
            // `success ? bytes4(keccak256("isValidSignature(bytes32,bytes)")) : 0xffffffff`.
            // We use `0xffffffff` for invalid, in convention with the reference implementation.
            result := shl(224, or(0x1626ba7e, sub(0, iszero(success))))
        }
    }

    /// @dev For automatic detection that the smart account supports the nested EIP-712 workflow.
    /// By default, it returns `bytes32(bytes4(keccak256("supportsNestedTypedDataSign()")))`,
    /// denoting support for the default behavior, as implemented in
    /// `_erc1271IsValidSignatureViaNestedEIP712`, which is called in `isValidSignature`.
    /// Future extensions should return a different non-zero `result` to denote different behavior.
    /// This method intentionally returns bytes32 to allow freedom for future extensions.
    function supportsNestedTypedDataSign() public view virtual returns (bytes32 result) {
        result = bytes4(0xd620c85a);
    }

    /// @dev Returns the ERC1271 signer.
    /// Override to return the signer `isValidSignature` checks against.
    function _erc1271Signer() internal view virtual returns (address);

    /// @dev Returns whether the `msg.sender` is considered safe, such
    /// that we don't need to use the nested EIP-712 workflow.
    /// Override to return true for more callers.
    /// See: https://mirror.xyz/curiousapple.eth/pFqAdW2LiJ-6S4sg_u1z08k4vK6BCJ33LcyXpnNb8yU
    function _erc1271CallerIsSafe() internal view virtual returns (bool) {
        // The canonical `MulticallerWithSigner` at 0x000000000000D9ECebf3C23529de49815Dac1c4c
        // is known to include the account in the hash to be signed.
        return msg.sender == 0x000000000000D9ECebf3C23529de49815Dac1c4c;
    }

    /// @dev Returns whether the `hash` and `signature` are valid.
    /// Override if you need non-ECDSA logic.
    function _erc1271IsValidSignatureNowCalldata(bytes32 hash, bytes calldata signature)
        internal
        view
        virtual
        returns (bool)
    {
        return SignatureCheckerLib.isValidSignatureNowCalldata(_erc1271Signer(), hash, signature);
    }

    /// @dev Unwraps and returns the signature.
    function _erc1271UnwrapSignature(bytes calldata signature)
        internal
        view
        virtual
        returns (bytes calldata result)
    {
        result = signature;
        /// @solidity memory-safe-assembly
        assembly {
            // Unwraps the ERC6492 wrapper if it exists.
            // See: https://eips.ethereum.org/EIPS/eip-6492
            if eq(
                calldataload(add(result.offset, sub(result.length, 0x20))),
                mul(0x6492, div(not(mload(0x60)), 0xffff)) // `0x6492...6492`.
            ) {
                let o := add(result.offset, calldataload(add(result.offset, 0x40)))
                result.length := calldataload(o)
                result.offset := add(o, 0x20)
            }
        }
    }

    /// @dev Returns whether the `signature` is valid for the `hash.
    function _erc1271IsValidSignature(bytes32 hash, bytes calldata signature)
        internal
        view
        virtual
        returns (bool)
    {
        return _erc1271IsValidSignatureViaSafeCaller(hash, signature)
            || _erc1271IsValidSignatureViaNestedEIP712(hash, signature)
            || _erc1271IsValidSignatureViaRPC(hash, signature);
    }

    /// @dev Performs the signature validation without nested EIP-712 if the caller is
    /// a safe caller. A safe caller must include the address of this account in the hash.
    function _erc1271IsValidSignatureViaSafeCaller(bytes32 hash, bytes calldata signature)
        internal
        view
        virtual
        returns (bool result)
    {
        if (_erc1271CallerIsSafe()) result = _erc1271IsValidSignatureNowCalldata(hash, signature);
    }

    /// @dev ERC1271 signature validation (Nested EIP-712 workflow).
    ///
    /// This uses ECDSA recovery by default (see: `_erc1271IsValidSignatureNowCalldata`).
    /// It also uses a nested EIP-712 approach to prevent signature replays when a single EOA
    /// owns multiple smart contract accounts,
    /// while still enabling wallet UIs (e.g. Metamask) to show the EIP-712 values.
    ///
    /// Crafted for phishing resistance, efficiency, flexibility.
    /// __________________________________________________________________________________________
    ///
    /// Glossary:
    ///
    /// - `APP_DOMAIN_SEPARATOR`: The domain separator of the `hash` passed in by the application.
    ///   Provided by the front end. Intended to be the domain separator of the contract
    ///   that will call `isValidSignature` on this account.
    ///
    /// - `ACCOUNT_DOMAIN_SEPARATOR`: The domain separator of this account.
    ///   See: `EIP712._domainSeparator()`.
    /// __________________________________________________________________________________________
    ///
    /// For the `TypedDataSign` workflow, the final hash will be:
    /// ```
    ///     keccak256(\x19\x01 ‖ APP_DOMAIN_SEPARATOR ‖
    ///         hashStruct(TypedDataSign({
    ///             contents: hashStruct(originalStruct),
    ///             name: keccak256(bytes(eip712Domain().name)),
    ///             version: keccak256(bytes(eip712Domain().version)),
    ///             chainId: eip712Domain().chainId,
    ///             verifyingContract: eip712Domain().verifyingContract,
    ///             salt: eip712Domain().salt,
    ///             extensions: keccak256(abi.encodePacked(eip712Domain().extensions))
    ///         }))
    ///     )
    /// ```
    /// where `‖` denotes the concatenation operator for bytes.
    /// The order of the fields is important: `contents` comes before `name`.
    ///
    /// The signature will be `r ‖ s ‖ v ‖
    ///     APP_DOMAIN_SEPARATOR ‖ contents ‖ contentsType ‖ uint16(contentsType.length)`,
    /// where `contents` is the bytes32 struct hash of the original struct.
    ///
    /// The `APP_DOMAIN_SEPARATOR` and `contents` will be used to verify if `hash` is indeed correct.
    /// __________________________________________________________________________________________
    ///
    /// For the `PersonalSign` workflow, the final hash will be:
    /// ```
    ///     keccak256(\x19\x01 ‖ ACCOUNT_DOMAIN_SEPARATOR ‖
    ///         hashStruct(PersonalSign({
    ///             prefixed: keccak256(bytes(\x19Ethereum Signed Message:\n ‖
    ///                 base10(bytes(someString).length) ‖ someString))
    ///         }))
    ///     )
    /// ```
    /// where `‖` denotes the concatenation operator for bytes.
    ///
    /// The `PersonalSign` type hash will be `keccak256("PersonalSign(bytes prefixed)")`.
    /// The signature will be `r ‖ s ‖ v`.
    /// __________________________________________________________________________________________
    ///
    /// For demo and typescript code, see:
    /// - https://github.com/junomonster/nested-eip-712
    /// - https://github.com/frangio/eip712-wrapper-for-eip1271
    ///
    /// Their nomenclature may differ from ours, although the high-level idea is similar.
    ///
    /// Of course, if you have control over the codebase of the wallet client(s) too,
    /// you can choose a more minimalistic signature scheme like
    /// `keccak256(abi.encode(address(this), hash))` instead of all these acrobatics.
    /// All these are just for widespread out-of-the-box compatibility with other wallet clients.
    /// We want to create bazaars, not walled castles.
    /// And we'll use push the Turing Completeness of the EVM to the limits to do so.
    function _erc1271IsValidSignatureViaNestedEIP712(bytes32 hash, bytes calldata signature)
        internal
        view
        virtual
        returns (bool result)
    {
        bytes32 t = _typedDataSignFields();
        /// @solidity memory-safe-assembly
        assembly {
            let m := mload(0x40) // Cache the free memory pointer.
            // `c` is `contentsType.length`, which is stored in the last 2 bytes of the signature.
            let c := shr(240, calldataload(add(signature.offset, sub(signature.length, 2))))
            for {} 1 {} {
                let l := add(0x42, c) // Total length of appended data (32 + 32 + c + 2).
                let o := add(signature.offset, sub(signature.length, l)) // Offset of appended data.
                mstore(0x00, 0x1901) // Store the "\x19\x01" prefix.
                calldatacopy(0x20, o, 0x40) // Copy the `APP_DOMAIN_SEPARATOR` and `contents` struct hash.
                // Use the `PersonalSign` workflow if the reconstructed hash doesn't match,
                // or if the appended data is invalid, i.e.
                // `appendedData.length > signature.length || contentsType.length == 0`.
                if or(xor(keccak256(0x1e, 0x42), hash), or(lt(signature.length, l), iszero(c))) {
                    t := 0 // Set `t` to 0, denoting that we need to `hash = _hashTypedData(hash)`.
                    mstore(t, _PERSONAL_SIGN_TYPEHASH)
                    mstore(0x20, hash) // Store the `prefixed`.
                    hash := keccak256(t, 0x40) // Compute the `PersonalSign` struct hash.
                    break
                }
                // Else, use the `TypedDataSign` workflow.
                // `TypedDataSign({ContentsName} contents,bytes1 fields,...){ContentsType}`.
                mstore(m, "TypedDataSign(") // Store the start of `TypedDataSign`'s type encoding.
                let p := add(m, 0x0e) // Advance 14 bytes to skip "TypedDataSign(".
                calldatacopy(p, add(o, 0x40), c) // Copy `contentsType` to extract `contentsName`.
                // `d & 1 == 1` means that `contentsName` is invalid.
                let d := shr(byte(0, mload(p)), 0x7fffffe000000000000010000000000) // Starts with `[a-z(]`.
                // Store the end sentinel '(', and advance `p` until we encounter a '(' byte.
                for { mstore(add(p, c), 40) } iszero(eq(byte(0, mload(p)), 40)) { p := add(p, 1) } {
                    d := or(shr(byte(0, mload(p)), 0x120100000001), d) // Has a byte in ", )\x00".
                }
                mstore(p, " contents,bytes1 fields,string n") // Store the rest of the encoding.
                mstore(add(p, 0x20), "ame,string version,uint256 chain")
                mstore(add(p, 0x40), "Id,address verifyingContract,byt")
                mstore(add(p, 0x60), "es32 salt,uint256[] extensions)")
                p := add(p, 0x7f)
                calldatacopy(p, add(o, 0x40), c) // Copy `contentsType`.
                // Fill in the missing fields of the `TypedDataSign`.
                calldatacopy(t, o, 0x40) // Copy the `contents` struct hash to `add(t, 0x20)`.
                mstore(t, keccak256(m, sub(add(p, c), m))) // Store `typedDataSignTypehash`.
                // The "\x19\x01" prefix is already at 0x00.
                // `APP_DOMAIN_SEPARATOR` is already at 0x20.
                mstore(0x40, keccak256(t, 0x120)) // `hashStruct(typedDataSign)`.
                // Compute the final hash, corrupted if `contentsName` is invalid.
                hash := keccak256(0x1e, add(0x42, and(1, d)))
                signature.length := sub(signature.length, l) // Truncate the signature.
                break
            }
            mstore(0x40, m) // Restore the free memory pointer.
        }
        if (t == bytes32(0)) hash = _hashTypedData(hash); // `PersonalSign` workflow.
        result = _erc1271IsValidSignatureNowCalldata(hash, signature);
    }

    /// @dev For use in `_erc1271IsValidSignatureViaNestedEIP712`,
    function _typedDataSignFields() private view returns (bytes32 m) {
        (
            bytes1 fields,
            string memory name,
            string memory version,
            uint256 chainId,
            address verifyingContract,
            bytes32 salt,
            uint256[] memory extensions
        ) = eip712Domain();
        /// @solidity memory-safe-assembly
        assembly {
            m := mload(0x40) // Grab the free memory pointer.
            mstore(0x40, add(m, 0x120)) // Allocate the memory.
            // Skip 2 words for the `typedDataSignTypehash` and `contents` struct hash.
            mstore(add(m, 0x40), shl(248, byte(0, fields)))
            mstore(add(m, 0x60), keccak256(add(name, 0x20), mload(name)))
            mstore(add(m, 0x80), keccak256(add(version, 0x20), mload(version)))
            mstore(add(m, 0xa0), chainId)
            mstore(add(m, 0xc0), shr(96, shl(96, verifyingContract)))
            mstore(add(m, 0xe0), salt)
            mstore(add(m, 0x100), keccak256(add(extensions, 0x20), shl(5, mload(extensions))))
        }
    }

    /// @dev Performs the signature validation without nested EIP-712 to allow for easy sign ins.
    /// This function must always return false or revert if called on-chain.
    function _erc1271IsValidSignatureViaRPC(bytes32 hash, bytes calldata signature)
        internal
        view
        virtual
        returns (bool result)
    {
        // Non-zero gasprice is a heuristic to check if a call is on-chain,
        // but we can't fully depend on it because it can be manipulated.
        // See: https://x.com/NoahCitron/status/1580359718341484544
        if (tx.gasprice == uint256(0)) {
            /// @solidity memory-safe-assembly
            assembly {
                mstore(gasprice(), gasprice())
                // See: https://gist.github.com/Vectorized/3c9b63524d57492b265454f62d895f71
                let b := 0x000000000000378eDCD5B5B0A24f5342d8C10485 // Basefee contract,
                pop(staticcall(0xffff, b, codesize(), gasprice(), gasprice(), 0x20))
                // If `gasprice < basefee`, the call cannot be on-chain, and we can skip the gas burn.
                if iszero(mload(gasprice())) {
                    let m := mload(0x40) // Cache the free memory pointer.
                    mstore(gasprice(), 0x1626ba7e) // `isValidSignature(bytes32,bytes)`.
                    mstore(0x20, b) // Recycle `b` to denote if we need to burn gas.
                    mstore(0x40, 0x40)
                    let gasToBurn := or(add(0xffff, gaslimit()), gaslimit())
                    // Burns gas computationally efficiently. Also, requires that `gas > gasToBurn`.
                    if or(eq(hash, b), lt(gas(), gasToBurn)) { invalid() }
                    // Make a call to this with `b`, efficiently burning the gas provided.
                    // No valid transaction can consume more than the gaslimit.
                    // See: https://ethereum.github.io/yellowpaper/paper.pdf
                    // Most RPCs perform calls with a gas budget greater than the gaslimit.
                    pop(staticcall(gasToBurn, address(), 0x1c, 0x64, gasprice(), gasprice()))
                    mstore(0x40, m) // Restore the free memory pointer.
                }
            }
            result = _erc1271IsValidSignatureNowCalldata(hash, signature);
        }
    }
}

Security Considerations

Rejecting invalid contentsTypeName

Current major implementations of eth_signTypedData do not sanitize the names of custom types.

A phishing website can craft a contentsTypeName with control characters to break out of the PersonalSign type encoding, resulting in the wallet client asking the user to sign an opaque hash.

Requiring on-chain sanitization of contentsTypeName will block this phishing attack vector.

Copyright

Copyright and related rights waived via CC0.

7 Likes

Hey @Vectorized

I have a question regarding supportsNestedTypedDataSign applied to modular smart accounts.

I’m working on Nexus modular smart account and also on the Smart Sessions module.

Initially we had 7739 implemented in Nexus, then we realized that implementing 7739 in the validator modules is the better approach, as validator modules will be able to use contents and other additional data for validation.

The same way modular SA’s forward 4337 validation to validator modules, they can forward 7739 validation to them.

With this in mind, what do you think, how should supportsNestedTypedDataSign behave for modular SA’s?

Since same SA may support or not support 7739 with different modules involved.
Not implementing supportsNestedTypedDataSign at all can be confusing.

One idea is to allow for an argument, like

function supportsNestedTypedDataSign(bytes calldata context) public view virtual returns (bytes32) {
    return(IValidator(context[0:20]).supportsNestedTypedDataSign());
}

Added a PR to Solady’s Receiver, which I believe the 7579 reference is based on.

We can override _beforeReceiverFallbackBody() to add extra logic to detect if the function selector is for supportsNestedTypedDataSign() (i.e. 0xd620c85a), and then forward it to the validator module (if any).

1 Like

For safety, smart accounts MUST treat the signature as invalid if any of the following is true:

  • contentsTypeName starts with any of the following bytes abcdefghijklmnopqrstuvwxyz(.

Disallowing lowercase is way too restrictive. There’s nothing in EIP-712 that says custom types have to start with an uppercase letter, so we have to assume some applications use lowercase letters. The smart account won’t be usable with those applications which is IMO a much more serious problem than what the restriction is trying to address.

I understand the intention was to prevent contentsTypeName being one of the built-in types (e.g. uint256), but is there any real risk from that?

Hello,

I’ve been spending a lot of time stydying and implementing this ERC latelly, and there is a point I’d like to discuss.

Why do we put expend the signer domain in the TypedDataSign instead of having the contents and the signerDomain as two clear objects?

With now, the TypedDataSign objects is

TypedDataSign(ContentTypeName contents,bytes1 fields,string name,string version,uint256 chainId,address verifyingContract,bytes32 salt,uint256[] extensions)ContentTypeName(...)

IMO it would be better to do

TypedDataSign(ContentTypeName contents,EIP712Domain signerDomain)ContentTypeName(...)EIP712Domain(...)
  • the fields and extensions are designed for a frontend to cleanly understand how the domain is constructed, but I’m not sure it essential here. Including the signerDomain should be enough.
  • the structHash for the signerDomain IS EXACTLY the signer’s domain separator. That is something the signer usually has available and doesn’t need to rehash.
  • That would simplify the logic for building the typedDataSignStructHash. We would not have to load all the ERC-5267 details (that the signer may not implement?). Instead we only need the domainSeparator (bytes32) and the EIP712DomainType (just a string) … when we currently load 2 strings (name/version), a uint256, an address, a bytes32 and even a uint256[]

So IMO, for the signer implementation in solidity that is a clear winner. What about the frontend.

The object to sign would be slightly different but not that much.

  • handling the contents part would be identical.
  • In both cases you must know the signer’s domain (that you’d likelly get through ERC-5267)
    • instead of adding everything directly in the message, you would format a domain object (just like is done for the app’s domain) and put that object in the message.
    • you’ll need to build the domainType, but that is logic that we already have for the app’s domain anyway.

IMO it would be better to see “here is the contents, and here is the domain of the signer” as 2 big chunks instead of “here is the contents and here are various field that are part of the domain of the signer”. Also, since its exactly the same domain, what we sing and what the wallet show can reuse components. Its just another embeded domain. For the few users that understand what a domain is, it would be clearer.




TLDR: I propose that signing a Permit through 5267 is done by doing using

{
    contents: {
        owner: '0x1ab5E417d9AF00f1ca9d159007e12c401337a4bb',
        spender: '0xD68E96620804446c4B1faB3103A08C98d4A8F55f',
        value: 1_000_000n,
        nonce: 0n,
        deadline: 1729253499n,
    },
    domain: {
        name: 'ERC5267SignerName',
        version: '1',
        chainId: 1n,
        verifyingContract: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512',
    },
}

TypedDataSign(Permit contents,EIP712Domain signerDomain)
Permit(owner address,spender address,value uint256,nonce uint256,deadline uint256)
EIP712Domain(name string,version string, uint256 chainId,address verifyingContract)

instead of

{
    contents: {
        owner: '0x1ab5E417d9AF00f1ca9d159007e12c401337a4bb',
        spender: '0xD68E96620804446c4B1faB3103A08C98d4A8F55f',
        value: 1_000_000n,
        nonce: 0n,
        deadline: 1729253499n,
    },    
    name: 'ERC5267SignerName',
    version: '1',
    chainId: 1n,
    verifyingContract: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512',
    salt: '0x0000000000000000000000000000000000000000000000000000000000000000'
    extensions: [],
}

TypedDataSign(Permit contents,bytes1 fields,string name,string version,uint256 chainId,address verifyingContract,bytes32 salt,uint256[] extensions)
Permit(owner address,spender address,value uint256,nonce uint256,deadline uint256)

The reason that doesn’t work is that structs types are alphabetically encoded in EIP-712 encodeType. So you don’t know where to place contentsType, because it could go before or after EIP712Domain, or even around it.


I think the fields are necessary, but the extensions are actually a problem. The extensions are not a part of the domain like this ERC is using them, they are an indication of how the domain should be constructed. See ERC-5267:

  • extensions: A list of EIP numbers, each of which MUST refer to an EIP that extends EIP-712 with new domain fields, along with a method to obtain the value for those fields, and potentially conditions for inclusion. The value of fields does not affect their inclusion.

@Amxx points out that this can be addressed by including the EIP712Domain inside contentsType. That sounds good.

Signing script example:

Notes:

  • The wallet will have to detect the signerDomain type to procude the right type. That problem can be fixed using 5267. Its not more difficult thant the problem of managing the appDomain. In most cases the wallet will know the domain without looking up (signer is its own contract?)
  • When preparing the TypedDataSign description, the signerDomain will have to be included. Note that its already the case today that the contentType may include multiple objects. But in that case, the EIP712Domain will NOT be part of the contentType, it will be appended by the signer:
TypedDataSign(${contentTypeName} contents, EIP712Domain signerDomain)${contentType}EIP712Domain(...)