瀏覽代碼

Add ERC7913 signers and utilities (#5659)

Co-authored-by: Hadrien Croubois <hadrien.croubois@gmail.com>
Ernesto García 4 月之前
父節點
當前提交
1d9400e053

+ 5 - 0
.changeset/nice-rings-wish.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`ERC7913P256Verifier` and `ERC7913RSAVerifier`: Ready to use ERC-7913 verifiers that implement key verification for P256 (secp256r1) and RSA keys.

+ 5 - 0
.changeset/quiet-kiwis-feel.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`SignerERC7913`: Abstract signer that verifies signatures using the ERC-7913 workflow.

+ 5 - 0
.changeset/social-walls-obey.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`MultiSignerERC7913`: Implementation of `AbstractSigner` that supports multiple ERC-7913 signers with a threshold-based signature verification system.

+ 5 - 0
.changeset/sour-pens-shake.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`SignatureChecker`: Add support for ERC-7913 signatures alongside existing ECDSA and ERC-1271 signature verification.

+ 17 - 0
contracts/interfaces/IERC7913.sol

@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+/**
+ * @dev Signature verifier interface.
+ */
+interface IERC7913SignatureVerifier {
+    /**
+     * @dev Verifies `signature` as a valid signature of `hash` by `key`.
+     *
+     * MUST return the bytes4 magic value IERC7913SignatureVerifier.verify.selector if the signature is valid.
+     * SHOULD return 0xffffffff or revert if the signature is not valid.
+     * SHOULD return 0xffffffff or revert if the key is empty
+     */
+    function verify(bytes calldata key, bytes32 hash, bytes calldata signature) external view returns (bytes4);
+}

+ 33 - 0
contracts/mocks/account/AccountMock.sol

@@ -17,6 +17,8 @@ import {SignerECDSA} from "../../utils/cryptography/signers/SignerECDSA.sol";
 import {SignerP256} from "../../utils/cryptography/signers/SignerP256.sol";
 import {SignerRSA} from "../../utils/cryptography/signers/SignerRSA.sol";
 import {SignerERC7702} from "../../utils/cryptography/signers/SignerERC7702.sol";
+import {SignerERC7913} from "../../utils/cryptography/signers/SignerERC7913.sol";
+import {MultiSignerERC7913} from "../../utils/cryptography/signers/MultiSignerERC7913.sol";
 
 abstract contract AccountMock is Account, ERC7739, ERC7821, ERC721Holder, ERC1155Holder {
     /// Validates a user operation with a boolean signature.
@@ -136,3 +138,34 @@ abstract contract AccountERC7579HookedMock is AccountERC7579Hooked {
         _installModule(MODULE_TYPE_VALIDATOR, validator, initData);
     }
 }
+
+abstract contract AccountMultiSignerMock is Account, MultiSignerERC7913, ERC7739, ERC7821, ERC721Holder, ERC1155Holder {
+    constructor(bytes[] memory signers, uint64 threshold) {
+        _addSigners(signers);
+        _setThreshold(threshold);
+    }
+
+    /// @inheritdoc ERC7821
+    function _erc7821AuthorizedExecutor(
+        address caller,
+        bytes32 mode,
+        bytes calldata executionData
+    ) internal view virtual override returns (bool) {
+        return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData);
+    }
+}
+
+abstract contract AccountERC7913Mock is Account, SignerERC7913, ERC7739, ERC7821, ERC721Holder, ERC1155Holder {
+    constructor(bytes memory _signer) {
+        _setSigner(_signer);
+    }
+
+    /// @inheritdoc ERC7821
+    function _erc7821AuthorizedExecutor(
+        address caller,
+        bytes32 mode,
+        bytes calldata executionData
+    ) internal view virtual override returns (bool) {
+        return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData);
+    }
+}

+ 12 - 0
contracts/utils/cryptography/README.adoc

@@ -17,6 +17,8 @@ A collection of contracts and libraries that implement various signature validat
  * {ERC7739}: An abstract contract to validate signatures following the rehashing scheme from {ERC7739Utils}.
  * {SignerECDSA}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms.
  * {SignerERC7702}: Implementation of {AbstractSigner} that validates signatures using the contract's own address as the signer, useful for delegated accounts following EIP-7702.
+ * {SignerERC7913}, {MultiSignerERC7913}: Implementations of {AbstractSigner} that validate signatures based on ERC-7913. Including a simple multisignature scheme.
+ * {ERC7913P256Verifier}, {ERC7913RSAVerifier}: Ready to use ERC-7913 signature verifiers for P256 and RSA keys.
 
 == Utils
 
@@ -51,3 +53,13 @@ A collection of contracts and libraries that implement various signature validat
 {{SignerRSA}}
 
 {{SignerERC7702}}
+
+{{SignerERC7913}}
+
+{{MultiSignerERC7913}}
+
+== Verifiers
+
+{{ERC7913P256Verifier}}
+
+{{ERC7913RSAVerifier}}

+ 88 - 3
contracts/utils/cryptography/SignatureChecker.sol

@@ -5,19 +5,29 @@ pragma solidity ^0.8.24;
 
 import {ECDSA} from "./ECDSA.sol";
 import {IERC1271} from "../../interfaces/IERC1271.sol";
+import {IERC7913SignatureVerifier} from "../../interfaces/IERC7913.sol";
+import {Bytes} from "../../utils/Bytes.sol";
 
 /**
- * @dev Signature verification helper that can be used instead of `ECDSA.recover` to seamlessly support both ECDSA
- * signatures from externally owned accounts (EOAs) as well as ERC-1271 signatures from smart contract wallets like
- * Argent and Safe Wallet (previously Gnosis Safe).
+ * @dev Signature verification helper that can be used instead of `ECDSA.recover` to seamlessly support:
+ *
+ * * ECDSA signatures from externally owned accounts (EOAs)
+ * * ERC-1271 signatures from smart contract wallets like Argent and Safe Wallet (previously Gnosis Safe)
+ * * ERC-7913 signatures from keys that do not have an Ethereum address of their own
+ *
+ * See https://eips.ethereum.org/EIPS/eip-1271[ERC-1271] and https://eips.ethereum.org/EIPS/eip-7913[ERC-7913].
  */
 library SignatureChecker {
+    using Bytes for bytes;
+
     /**
      * @dev Checks if a signature is valid for a given signer and data hash. If the signer has code, the
      * signature is validated against it using ERC-1271, otherwise it's validated using `ECDSA.recover`.
      *
      * NOTE: Unlike ECDSA signatures, contract signatures are revocable, and the outcome of this function can thus
      * change through time. It could return true at block N and false at block N+1 (or the opposite).
+     *
+     * NOTE: For an extended version of this function that supports ERC-7913 signatures, see {isValidERC7913SignatureNow}.
      */
     function isValidSignatureNow(address signer, bytes32 hash, bytes memory signature) internal view returns (bool) {
         if (signer.code.length == 0) {
@@ -47,4 +57,79 @@ library SignatureChecker {
             result.length >= 32 &&
             abi.decode(result, (bytes32)) == bytes32(IERC1271.isValidSignature.selector));
     }
+
+    /**
+     * @dev Verifies a signature for a given ERC-7913 signer and hash.
+     *
+     * The signer is a `bytes` object that is the concatenation of an address and optionally a key:
+     * `verifier || key`. A signer must be at least 20 bytes long.
+     *
+     * Verification is done as follows:
+     *
+     * * If `signer.length < 20`: verification fails
+     * * If `signer.length == 20`: verification is done using {isValidSignatureNow}
+     * * Otherwise: verification is done using {IERC7913SignatureVerifier}
+     *
+     * NOTE: Unlike ECDSA signatures, contract signatures are revocable, and the outcome of this function can thus
+     * change through time. It could return true at block N and false at block N+1 (or the opposite).
+     */
+    function isValidERC7913SignatureNow(
+        bytes memory signer,
+        bytes32 hash,
+        bytes memory signature
+    ) internal view returns (bool) {
+        if (signer.length < 20) {
+            return false;
+        } else if (signer.length == 20) {
+            return isValidSignatureNow(address(bytes20(signer)), hash, signature);
+        } else {
+            (bool success, bytes memory result) = address(bytes20(signer)).staticcall(
+                abi.encodeCall(IERC7913SignatureVerifier.verify, (signer.slice(20), hash, signature))
+            );
+            return (success &&
+                result.length >= 32 &&
+                abi.decode(result, (bytes32)) == bytes32(IERC7913SignatureVerifier.verify.selector));
+        }
+    }
+
+    /**
+     * @dev Verifies multiple ERC-7913 `signatures` for a given `hash` using a set of `signers`.
+     * Returns `false` if the number of signers and signatures is not the same.
+     *
+     * The signers should be ordered by their `keccak256` hash to ensure efficient duplication check. Unordered
+     * signers are supported, but the uniqueness check will be more expensive.
+     *
+     * NOTE: Unlike ECDSA signatures, contract signatures are revocable, and the outcome of this function can thus
+     * change through time. It could return true at block N and false at block N+1 (or the opposite).
+     */
+    function areValidERC7913SignaturesNow(
+        bytes32 hash,
+        bytes[] memory signers,
+        bytes[] memory signatures
+    ) internal view returns (bool) {
+        if (signers.length != signatures.length) return false;
+
+        bytes32 lastId = bytes32(0);
+
+        for (uint256 i = 0; i < signers.length; ++i) {
+            bytes memory signer = signers[i];
+
+            // If one of the signatures is invalid, reject the batch
+            if (!isValidERC7913SignatureNow(signer, hash, signatures[i])) return false;
+
+            bytes32 id = keccak256(signer);
+            // If the current signer ID is greater than all previous IDs, then this is a new signer.
+            if (lastId < id) {
+                lastId = id;
+            } else {
+                // If this signer id is not greater than all the previous ones, verify that it is not a duplicate of a previous one
+                // This loop is never executed if the signers are ordered by id.
+                for (uint256 j = 0; j < i; ++j) {
+                    if (id == keccak256(signers[j])) return false;
+                }
+            }
+        }
+
+        return true;
+    }
 }

+ 238 - 0
contracts/utils/cryptography/signers/MultiSignerERC7913.sol

@@ -0,0 +1,238 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.27;
+
+import {AbstractSigner} from "./AbstractSigner.sol";
+import {SignatureChecker} from "../SignatureChecker.sol";
+import {EnumerableSet} from "../../structs/EnumerableSet.sol";
+
+/**
+ * @dev Implementation of {AbstractSigner} using multiple ERC-7913 signers with a threshold-based
+ * signature verification system.
+ *
+ * This contract allows managing a set of authorized signers and requires a minimum number of
+ * signatures (threshold) to approve operations. It uses ERC-7913 formatted signers, which
+ * makes it natively compatible with ECDSA and ERC-1271 signers.
+ *
+ * Example of usage:
+ *
+ * ```solidity
+ * contract MyMultiSignerAccount is Account, MultiSignerERC7913, Initializable {
+ *     constructor() EIP712("MyMultiSignerAccount", "1") {}
+ *
+ *     function initialize(bytes[] memory signers, uint64 threshold) public initializer {
+ *         _addSigners(signers);
+ *         _setThreshold(threshold);
+ *     }
+ *
+ *     function addSigners(bytes[] memory signers) public onlyEntryPointOrSelf {
+ *         _addSigners(signers);
+ *     }
+ *
+ *     function removeSigners(bytes[] memory signers) public onlyEntryPointOrSelf {
+ *         _removeSigners(signers);
+ *     }
+ *
+ *     function setThreshold(uint64 threshold) public onlyEntryPointOrSelf {
+ *         _setThreshold(threshold);
+ *     }
+ * }
+ * ```
+ *
+ * IMPORTANT: Failing to properly initialize the signers and threshold either during construction
+ * (if used standalone) or during initialization (if used as a clone) may leave the contract
+ * either front-runnable or unusable.
+ */
+abstract contract MultiSignerERC7913 is AbstractSigner {
+    using EnumerableSet for EnumerableSet.BytesSet;
+    using SignatureChecker for *;
+
+    EnumerableSet.BytesSet private _signers;
+    uint64 private _threshold;
+
+    /// @dev Emitted when a signer is added.
+    event ERC7913SignerAdded(bytes indexed signers);
+
+    /// @dev Emitted when a signers is removed.
+    event ERC7913SignerRemoved(bytes indexed signers);
+
+    /// @dev Emitted when the threshold is updated.
+    event ERC7913ThresholdSet(uint64 threshold);
+
+    /// @dev The `signer` already exists.
+    error MultiSignerERC7913AlreadyExists(bytes signer);
+
+    /// @dev The `signer` does not exist.
+    error MultiSignerERC7913NonexistentSigner(bytes signer);
+
+    /// @dev The `signer` is less than 20 bytes long.
+    error MultiSignerERC7913InvalidSigner(bytes signer);
+
+    /// @dev The `threshold` is unreachable given the number of `signers`.
+    error MultiSignerERC7913UnreachableThreshold(uint64 signers, uint64 threshold);
+
+    /**
+     * @dev Returns a slice of the set of authorized signers.
+     *
+     * Using `start = 0` and `end = type(uint64).max` will return the entire set of signers.
+     *
+     * WARNING: Depending on the `start` and `end`, this operation can copy a large amount of data to memory, which
+     * can be expensive. This is designed for view accessors queried without gas fees. Using it in state-changing
+     * functions may become uncallable if the slice grows too large.
+     */
+    function getSigners(uint64 start, uint64 end) public view virtual returns (bytes[] memory) {
+        return _signers.values(start, end);
+    }
+
+    /// @dev Returns whether the `signer` is an authorized signer.
+    function isSigner(bytes memory signer) public view virtual returns (bool) {
+        return _signers.contains(signer);
+    }
+
+    /// @dev Returns the minimum number of signers required to approve a multisignature operation.
+    function threshold() public view virtual returns (uint64) {
+        return _threshold;
+    }
+
+    /**
+     * @dev Adds the `newSigners` to those allowed to sign on behalf of this contract.
+     * Internal version without access control.
+     *
+     * Requirements:
+     *
+     * * Each of `newSigners` must be at least 20 bytes long. Reverts with {MultiSignerERC7913InvalidSigner} if not.
+     * * Each of `newSigners` must not be authorized. See {isSigner}. Reverts with {MultiSignerERC7913AlreadyExists} if so.
+     */
+    function _addSigners(bytes[] memory newSigners) internal virtual {
+        for (uint256 i = 0; i < newSigners.length; ++i) {
+            bytes memory signer = newSigners[i];
+            require(signer.length >= 20, MultiSignerERC7913InvalidSigner(signer));
+            require(_signers.add(signer), MultiSignerERC7913AlreadyExists(signer));
+            emit ERC7913SignerAdded(signer);
+        }
+    }
+
+    /**
+     * @dev Removes the `oldSigners` from the authorized signers. Internal version without access control.
+     *
+     * Requirements:
+     *
+     * * Each of `oldSigners` must be authorized. See {isSigner}. Otherwise {MultiSignerERC7913NonexistentSigner} is thrown.
+     * * See {_validateReachableThreshold} for the threshold validation.
+     */
+    function _removeSigners(bytes[] memory oldSigners) internal virtual {
+        for (uint256 i = 0; i < oldSigners.length; ++i) {
+            bytes memory signer = oldSigners[i];
+            require(_signers.remove(signer), MultiSignerERC7913NonexistentSigner(signer));
+            emit ERC7913SignerRemoved(signer);
+        }
+        _validateReachableThreshold();
+    }
+
+    /**
+     * @dev Sets the signatures `threshold` required to approve a multisignature operation.
+     * Internal version without access control.
+     *
+     * Requirements:
+     *
+     * * See {_validateReachableThreshold} for the threshold validation.
+     */
+    function _setThreshold(uint64 newThreshold) internal virtual {
+        _threshold = newThreshold;
+        _validateReachableThreshold();
+        emit ERC7913ThresholdSet(newThreshold);
+    }
+
+    /**
+     * @dev Validates the current threshold is reachable.
+     *
+     * Requirements:
+     *
+     * * The {signers}'s length must be `>=` to the {threshold}. Throws {MultiSignerERC7913UnreachableThreshold} if not.
+     */
+    function _validateReachableThreshold() internal view virtual {
+        uint256 signersLength = _signers.length();
+        uint64 currentThreshold = threshold();
+        require(
+            signersLength >= currentThreshold,
+            MultiSignerERC7913UnreachableThreshold(
+                uint64(signersLength), // Safe cast. Economically impossible to overflow.
+                currentThreshold
+            )
+        );
+    }
+
+    /**
+     * @dev Decodes, validates the signature and checks the signers are authorized.
+     * See {_validateSignatures} and {_validateThreshold} for more details.
+     *
+     * Example of signature encoding:
+     *
+     * ```solidity
+     * // Encode signers (verifier || key)
+     * bytes memory signer1 = abi.encodePacked(verifier1, key1);
+     * bytes memory signer2 = abi.encodePacked(verifier2, key2);
+     *
+     * // Order signers by their id
+     * if (keccak256(signer1) > keccak256(signer2)) {
+     *     (signer1, signer2) = (signer2, signer1);
+     *     (signature1, signature2) = (signature2, signature1);
+     * }
+     *
+     * // Assign ordered signers and signatures
+     * bytes[] memory signers = new bytes[](2);
+     * bytes[] memory signatures = new bytes[](2);
+     * signers[0] = signer1;
+     * signatures[0] = signature1;
+     * signers[1] = signer2;
+     * signatures[1] = signature2;
+     *
+     * // Encode the multi signature
+     * bytes memory signature = abi.encode(signers, signatures);
+     * ```
+     *
+     * Requirements:
+     *
+     * * The `signature` must be encoded as `abi.encode(signers, signatures)`.
+     */
+    function _rawSignatureValidation(
+        bytes32 hash,
+        bytes calldata signature
+    ) internal view virtual override returns (bool) {
+        if (signature.length == 0) return false; // For ERC-7739 compatibility
+        (bytes[] memory signers, bytes[] memory signatures) = abi.decode(signature, (bytes[], bytes[]));
+        return _validateThreshold(signers) && _validateSignatures(hash, signers, signatures);
+    }
+
+    /**
+     * @dev Validates the signatures using the signers and their corresponding signatures.
+     * Returns whether whether the signers are authorized and the signatures are valid for the given hash.
+     *
+     * IMPORTANT: Sorting the signers by their `keccak256` hash will improve the gas efficiency of this function.
+     * See {SignatureChecker-areValidERC7913SignaturesNow} for more details.
+     *
+     * Requirements:
+     *
+     * * The `signatures` arrays must be at least as large as the `signers` arrays. Panics otherwise.
+     */
+    function _validateSignatures(
+        bytes32 hash,
+        bytes[] memory signers,
+        bytes[] memory signatures
+    ) internal view virtual returns (bool valid) {
+        for (uint256 i = 0; i < signers.length; ++i) {
+            if (!isSigner(signers[i])) {
+                return false;
+            }
+        }
+        return hash.areValidERC7913SignaturesNow(signers, signatures);
+    }
+
+    /**
+     * @dev Validates that the number of signers meets the {threshold} requirement.
+     * Assumes the signers were already validated. See {_validateSignatures} for more details.
+     */
+    function _validateThreshold(bytes[] memory validatingSigners) internal view virtual returns (bool) {
+        return validatingSigners.length >= threshold();
+    }
+}

+ 51 - 0
contracts/utils/cryptography/signers/SignerERC7913.sol

@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.24;
+
+import {AbstractSigner} from "./AbstractSigner.sol";
+import {SignatureChecker} from "../SignatureChecker.sol";
+
+/**
+ * @dev Implementation of {AbstractSigner} using
+ * https://eips.ethereum.org/EIPS/eip-7913[ERC-7913] signature verification.
+ *
+ * For {Account} usage, a {_setSigner} function is provided to set the ERC-7913 formatted {signer}.
+ * Doing so is easier for a factory, who is likely to use initializable clones of this contract.
+ *
+ * The signer is a `bytes` object that concatenates a verifier address and a key: `verifier || key`.
+ *
+ * Example of usage:
+ *
+ * ```solidity
+ * contract MyAccountERC7913 is Account, SignerERC7913, Initializable {
+ *     function initialize(bytes memory signer_) public initializer {
+ *       _setSigner(signer_);
+ *     }
+ * }
+ * ```
+ *
+ * IMPORTANT: Failing to call {_setSigner} either during construction (if used standalone)
+ * or during initialization (if used as a clone) may leave the signer either front-runnable or unusable.
+ */
+
+abstract contract SignerERC7913 is AbstractSigner {
+    bytes private _signer;
+
+    /// @dev Return the ERC-7913 signer (i.e. `verifier || key`).
+    function signer() public view virtual returns (bytes memory) {
+        return _signer;
+    }
+
+    /// @dev Sets the signer (i.e. `verifier || key`) with an ERC-7913 formatted signer.
+    function _setSigner(bytes memory signer_) internal {
+        _signer = signer_;
+    }
+
+    /// @dev Verifies a signature using {SignatureChecker-isValidERC7913SignatureNow} with {signer}, `hash` and `signature`.
+    function _rawSignatureValidation(
+        bytes32 hash,
+        bytes calldata signature
+    ) internal view virtual override returns (bool) {
+        return SignatureChecker.isValidERC7913SignatureNow(signer(), hash, signature);
+    }
+}

+ 26 - 0
contracts/utils/cryptography/verifiers/ERC7913P256Verifier.sol

@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {P256} from "../../../utils/cryptography/P256.sol";
+import {IERC7913SignatureVerifier} from "../../../interfaces/IERC7913.sol";
+
+/**
+ * @dev ERC-7913 signature verifier that support P256 (secp256r1) keys.
+ */
+contract ERC7913P256Verifier is IERC7913SignatureVerifier {
+    /// @inheritdoc IERC7913SignatureVerifier
+    function verify(bytes calldata key, bytes32 hash, bytes calldata signature) public view virtual returns (bytes4) {
+        // Signature length may be 0x40 or 0x41.
+        if (key.length == 0x40 && signature.length >= 0x40) {
+            bytes32 qx = bytes32(key[0x00:0x20]);
+            bytes32 qy = bytes32(key[0x20:0x40]);
+            bytes32 r = bytes32(signature[0x00:0x20]);
+            bytes32 s = bytes32(signature[0x20:0x40]);
+            if (P256.verify(hash, r, s, qx, qy)) {
+                return IERC7913SignatureVerifier.verify.selector;
+            }
+        }
+        return 0xFFFFFFFF;
+    }
+}

+ 20 - 0
contracts/utils/cryptography/verifiers/ERC7913RSAVerifier.sol

@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {RSA} from "../../../utils/cryptography/RSA.sol";
+import {IERC7913SignatureVerifier} from "../../../interfaces/IERC7913.sol";
+
+/**
+ * @dev ERC-7913 signature verifier that support RSA keys.
+ */
+contract ERC7913RSAVerifier is IERC7913SignatureVerifier {
+    /// @inheritdoc IERC7913SignatureVerifier
+    function verify(bytes calldata key, bytes32 hash, bytes calldata signature) public view virtual returns (bytes4) {
+        (bytes memory e, bytes memory n) = abi.decode(key, (bytes, bytes));
+        return
+            RSA.pkcs1Sha256(abi.encodePacked(hash), signature, e, n)
+                ? IERC7913SignatureVerifier.verify.selector
+                : bytes4(0xFFFFFFFF);
+    }
+}

+ 74 - 0
docs/modules/ROOT/pages/utilities.adoc

@@ -97,6 +97,80 @@ function _verify(
 
 IMPORTANT: Always use keys of at least 2048 bits. Additionally, be aware that PKCS#1 v1.5 allows for replayability due to the possibility of arbitrary optional parameters. To prevent replay attacks, consider including an onchain nonce or unique identifier in the message.
 
+=== Signature Verification
+
+The xref:api:utils.adoc#SignatureChecker[`SignatureChecker`] library provides a unified interface for verifying signatures from different sources. It seamlessly supports:
+
+* ECDSA signatures from externally owned accounts (EOAs)
+* ERC-1271 signatures from smart contract wallets like Argent and Safe Wallet
+* ERC-7913 signatures from keys that don't have their own Ethereum address
+
+This allows developers to write signature verification code once and have it work across all these different signature types.
+
+==== Basic Signature Verification
+
+For standard signature verification that supports both EOAs and ERC-1271 contracts:
+
+[source,solidity]
+----
+using SignatureChecker for address;
+
+function _verifySignature(address signer, bytes32 hash, bytes memory signature) internal view returns (bool) {
+    return SignatureChecker.isValidSignatureNow(signer, hash, signature);
+}
+----
+
+The library automatically detects whether the signer is an EOA or a contract and uses the appropriate verification method.
+
+==== ERC-1271 Contract Signatures
+
+For smart contract wallets that implement ERC-1271, you can explicitly use:
+
+[source,solidity]
+----
+function _verifyContractSignature(address signer, bytes32 hash, bytes memory signature) internal view returns (bool) {
+    return SignatureChecker.isValidERC1271SignatureNow(signer, hash, signature);
+}
+----
+
+==== ERC-7913 Extended Signatures
+
+ERC-7913 extends signature verification to support keys that don't have their own Ethereum address. This is useful for integrating non-Ethereum cryptographic curves, hardware devices, or other identity systems.
+
+A signer is represented as a `bytes` object that concatenates a verifier address and a key: `verifier || key`.
+
+[source,solidity]
+----
+function _verifyERC7913Signature(bytes memory signer, bytes32 hash, bytes memory signature) internal view returns (bool) {
+    return SignatureChecker.isValidERC7913SignatureNow(signer, hash, signature);
+}
+----
+
+The verification process works as follows:
+
+* If `signer.length < 20`: verification fails
+* If `signer.length == 20`: verification is done using standard signature checking
+* Otherwise: verification is done using an ERC-7913 verifier
+
+==== Batch Verification
+
+For verifying multiple ERC-7913 signatures at once:
+
+[source,solidity]
+----
+function _verifyMultipleSignatures(
+    bytes32 hash,
+    bytes[] memory signers,
+    bytes[] memory signatures
+) internal view returns (bool) {
+    return SignatureChecker.areValidERC7913SignaturesNow(hash, signers, signatures);
+}
+----
+
+This function will reject inputs that contain duplicated signers. Sorting the signers by their `keccak256` hash is recommended to minimize the gas cost.
+
+This unified approach allows smart contracts to accept signatures from any supported source without needing to implement different verification logic for each type.
+
 === Verifying Merkle Proofs
 
 Developers can build a Merkle Tree off-chain, which allows for verifying that an element (leaf) is part of a set by using a Merkle Proof. This technique is widely used for creating whitelists (e.g., for airdrops) and other advanced use cases.

+ 116 - 0
test/account/AccountERC7913.test.js

@@ -0,0 +1,116 @@
+const { ethers, entrypoint } = require('hardhat');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+const { getDomain } = require('../helpers/eip712');
+const { ERC4337Helper } = require('../helpers/erc4337');
+const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey } = require('../helpers/signers');
+const { PackedUserOperation } = require('../helpers/eip712-types');
+
+const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior');
+const { shouldBehaveLikeERC1271 } = require('../utils/cryptography/ERC1271.behavior');
+const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior');
+
+// Prepare signer in advance (RSA are long to initialize)
+const signerECDSA = ethers.Wallet.createRandom();
+const signerP256 = new NonNativeSigner(P256SigningKey.random());
+const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random());
+
+// Minimal fixture common to the different signer verifiers
+async function fixture() {
+  // EOAs and environment
+  const [beneficiary, other] = await ethers.getSigners();
+  const target = await ethers.deployContract('CallReceiverMock');
+
+  // ERC-7913 verifiers
+  const verifierP256 = await ethers.deployContract('ERC7913P256Verifier');
+  const verifierRSA = await ethers.deployContract('ERC7913RSAVerifier');
+
+  // ERC-4337 env
+  const helper = new ERC4337Helper();
+  await helper.wait();
+  const entrypointDomain = await getDomain(entrypoint.v08);
+  const domain = { name: 'AccountERC7913', version: '1', chainId: entrypointDomain.chainId }; // Missing verifyingContract,
+
+  const makeMock = signer =>
+    helper.newAccount('$AccountERC7913Mock', ['AccountERC7913', '1', signer]).then(mock => {
+      domain.verifyingContract = mock.address;
+      return mock;
+    });
+
+  const signUserOp = function (userOp) {
+    return this.signer
+      .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed)
+      .then(signature => Object.assign(userOp, { signature }));
+  };
+
+  return {
+    helper,
+    verifierP256,
+    verifierRSA,
+    domain,
+    target,
+    beneficiary,
+    other,
+    makeMock,
+    signUserOp,
+  };
+}
+
+describe('AccountERC7913', function () {
+  beforeEach(async function () {
+    Object.assign(this, await loadFixture(fixture));
+  });
+
+  // Using ECDSA key as verifier
+  describe('ECDSA key', function () {
+    beforeEach(async function () {
+      this.signer = signerECDSA;
+      this.mock = await this.makeMock(this.signer.address);
+    });
+
+    shouldBehaveLikeAccountCore();
+    shouldBehaveLikeAccountHolder();
+    shouldBehaveLikeERC1271({ erc7739: true });
+    shouldBehaveLikeERC7821();
+  });
+
+  // Using P256 key with an ERC-7913 verifier
+  describe('P256 key', function () {
+    beforeEach(async function () {
+      this.signer = signerP256;
+      this.mock = await this.makeMock(
+        ethers.concat([
+          this.verifierP256.target,
+          this.signer.signingKey.publicKey.qx,
+          this.signer.signingKey.publicKey.qy,
+        ]),
+      );
+    });
+
+    shouldBehaveLikeAccountCore();
+    shouldBehaveLikeAccountHolder();
+    shouldBehaveLikeERC1271({ erc7739: true });
+    shouldBehaveLikeERC7821();
+  });
+
+  // Using RSA key with an ERC-7913 verifier
+  describe('RSA key', function () {
+    beforeEach(async function () {
+      this.signer = signerRSA;
+      this.mock = await this.makeMock(
+        ethers.concat([
+          this.verifierRSA.target,
+          ethers.AbiCoder.defaultAbiCoder().encode(
+            ['bytes', 'bytes'],
+            [this.signer.signingKey.publicKey.e, this.signer.signingKey.publicKey.n],
+          ),
+        ]),
+      );
+    });
+
+    shouldBehaveLikeAccountCore();
+    shouldBehaveLikeAccountHolder();
+    shouldBehaveLikeERC1271({ erc7739: true });
+    shouldBehaveLikeERC7821();
+  });
+});

+ 321 - 0
test/account/AccountMultiSigner.test.js

@@ -0,0 +1,321 @@
+const { ethers, entrypoint } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+const { getDomain } = require('../helpers/eip712');
+const { ERC4337Helper } = require('../helpers/erc4337');
+const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey, MultiERC7913SigningKey } = require('../helpers/signers');
+const { MAX_UINT64 } = require('../helpers/constants');
+
+const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior');
+const { shouldBehaveLikeERC1271 } = require('../utils/cryptography/ERC1271.behavior');
+const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior');
+const { PackedUserOperation } = require('../helpers/eip712-types');
+
+// Prepare signers in advance (RSA are long to initialize)
+const signerECDSA1 = ethers.Wallet.createRandom();
+const signerECDSA2 = ethers.Wallet.createRandom();
+const signerECDSA3 = ethers.Wallet.createRandom();
+const signerECDSA4 = ethers.Wallet.createRandom(); // Unauthorized signer
+const signerP256 = new NonNativeSigner(P256SigningKey.random());
+const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random());
+
+// Minimal fixture common to the different signer verifiers
+async function fixture() {
+  // EOAs and environment
+  const [beneficiary, other] = await ethers.getSigners();
+  const target = await ethers.deployContract('CallReceiverMock');
+
+  // ERC-7913 verifiers
+  const verifierP256 = await ethers.deployContract('ERC7913P256Verifier');
+  const verifierRSA = await ethers.deployContract('ERC7913RSAVerifier');
+
+  // ERC-4337 env
+  const helper = new ERC4337Helper();
+  await helper.wait();
+  const entrypointDomain = await getDomain(entrypoint.v08);
+  const domain = { name: 'AccountMultiSigner', version: '1', chainId: entrypointDomain.chainId }; // Missing verifyingContract
+
+  const makeMock = (signers, threshold) =>
+    helper.newAccount('$AccountMultiSignerMock', ['AccountMultiSigner', '1', signers, threshold]).then(mock => {
+      domain.verifyingContract = mock.address;
+      return mock;
+    });
+
+  // Sign user operations using MultiERC7913SigningKey
+  const signUserOp = function (userOp) {
+    return this.signer
+      .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed)
+      .then(signature => Object.assign(userOp, { signature }));
+  };
+
+  const invalidSig = function () {
+    return this.signer.signMessage('invalid');
+  };
+
+  return {
+    helper,
+    verifierP256,
+    verifierRSA,
+    domain,
+    target,
+    beneficiary,
+    other,
+    makeMock,
+    signUserOp,
+    invalidSig,
+  };
+}
+
+describe('AccountMultiSigner', function () {
+  beforeEach(async function () {
+    Object.assign(this, await loadFixture(fixture));
+  });
+
+  describe('Multi ECDSA signers with threshold=1', function () {
+    beforeEach(async function () {
+      this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1]));
+      this.mock = await this.makeMock([signerECDSA1.address], 1);
+    });
+
+    shouldBehaveLikeAccountCore();
+    shouldBehaveLikeAccountHolder();
+    shouldBehaveLikeERC1271({ erc7739: true });
+    shouldBehaveLikeERC7821();
+  });
+
+  describe('Multi ECDSA signers with threshold=2', function () {
+    beforeEach(async function () {
+      this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2]));
+      this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 2);
+    });
+
+    shouldBehaveLikeAccountCore();
+    shouldBehaveLikeAccountHolder();
+    shouldBehaveLikeERC1271({ erc7739: true });
+    shouldBehaveLikeERC7821();
+  });
+
+  describe('Mixed signers with threshold=2', function () {
+    beforeEach(async function () {
+      // Create signers array with all three types
+      signerP256.bytes = ethers.concat([
+        this.verifierP256.target,
+        signerP256.signingKey.publicKey.qx,
+        signerP256.signingKey.publicKey.qy,
+      ]);
+
+      signerRSA.bytes = ethers.concat([
+        this.verifierRSA.target,
+        ethers.AbiCoder.defaultAbiCoder().encode(
+          ['bytes', 'bytes'],
+          [signerRSA.signingKey.publicKey.e, signerRSA.signingKey.publicKey.n],
+        ),
+      ]);
+
+      this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerP256, signerRSA]));
+      this.mock = await this.makeMock([signerECDSA1.address, signerP256.bytes, signerRSA.bytes], 2);
+    });
+
+    shouldBehaveLikeAccountCore();
+    shouldBehaveLikeAccountHolder();
+    shouldBehaveLikeERC1271({ erc7739: true });
+    shouldBehaveLikeERC7821();
+  });
+
+  describe('Signer management', function () {
+    beforeEach(async function () {
+      this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2]));
+      this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 1);
+      await this.mock.deploy();
+    });
+
+    it('can add signers', async function () {
+      const signers = [signerECDSA3.address];
+
+      // Successfully adds a signer
+      const signersArrayBefore = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress));
+      await expect(this.mock.$_addSigners(signers))
+        .to.emit(this.mock, 'ERC7913SignerAdded')
+        .withArgs(signerECDSA3.address);
+      const signersArrayAfter = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress));
+      expect(signersArrayAfter.length).to.equal(signersArrayBefore.length + 1);
+      expect(signersArrayAfter).to.include(ethers.getAddress(signerECDSA3.address));
+
+      // Reverts if the signer was already added
+      await expect(this.mock.$_addSigners(signers))
+        .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913AlreadyExists')
+        .withArgs(...signers.map(s => s.toLowerCase()));
+    });
+
+    it('can remove signers', async function () {
+      const signers = [signerECDSA2.address];
+
+      // Successfully removes an already added signer
+      const signersArrayBefore = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress));
+      await expect(this.mock.$_removeSigners(signers))
+        .to.emit(this.mock, 'ERC7913SignerRemoved')
+        .withArgs(signerECDSA2.address);
+      const signersArrayAfter = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress));
+      expect(signersArrayAfter.length).to.equal(signersArrayBefore.length - 1);
+      expect(signersArrayAfter).to.not.include(ethers.getAddress(signerECDSA2.address));
+
+      // Reverts removing a signer if it doesn't exist
+      await expect(this.mock.$_removeSigners(signers))
+        .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913NonexistentSigner')
+        .withArgs(...signers.map(s => s.toLowerCase()));
+
+      // Reverts if removing a signer makes the threshold unreachable
+      await expect(this.mock.$_removeSigners([signerECDSA1.address]))
+        .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913UnreachableThreshold')
+        .withArgs(0, 1);
+    });
+
+    it('can change threshold', async function () {
+      // Reachable threshold is set
+      await expect(this.mock.$_setThreshold(2)).to.emit(this.mock, 'ERC7913ThresholdSet');
+
+      // Unreachable threshold reverts
+      await expect(this.mock.$_setThreshold(3)).to.revertedWithCustomError(
+        this.mock,
+        'MultiSignerERC7913UnreachableThreshold',
+      );
+    });
+
+    it('rejects invalid signer format', async function () {
+      const invalidSigner = '0x123456'; // Too short
+
+      await expect(this.mock.$_addSigners([invalidSigner]))
+        .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913InvalidSigner')
+        .withArgs(invalidSigner);
+    });
+
+    it('can read signers and threshold', async function () {
+      await expect(
+        this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress)),
+      ).to.eventually.have.deep.members([signerECDSA1.address, signerECDSA2.address]);
+
+      await expect(this.mock.threshold()).to.eventually.equal(1);
+    });
+
+    it('checks if an address is a signer', async function () {
+      // Should return true for authorized signers
+      await expect(this.mock.isSigner(signerECDSA1.address)).to.eventually.be.true;
+      await expect(this.mock.isSigner(signerECDSA2.address)).to.eventually.be.true;
+
+      // Should return false for unauthorized signers
+      await expect(this.mock.isSigner(signerECDSA3.address)).to.eventually.be.false;
+      await expect(this.mock.isSigner(signerECDSA4.address)).to.eventually.be.false;
+    });
+  });
+
+  describe('Signature validation', function () {
+    const TEST_MESSAGE = ethers.keccak256(ethers.toUtf8Bytes('Test message'));
+
+    beforeEach(async function () {
+      // Set up mock with authorized signers
+      this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 1);
+      await this.mock.deploy();
+    });
+
+    it('rejects signatures from unauthorized signers', async function () {
+      // Create signatures including an unauthorized signer
+      const authorizedSignature = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE));
+      const unauthorizedSignature = await signerECDSA4.signMessage(ethers.getBytes(TEST_MESSAGE));
+
+      // Prepare signers and signatures arrays
+      const signers = [
+        signerECDSA1.address,
+        signerECDSA4.address, // Unauthorized signer
+      ].sort((a, b) => (ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1));
+
+      const signatures = signers.map(signer => {
+        if (signer === signerECDSA1.address) return authorizedSignature;
+        return unauthorizedSignature;
+      });
+
+      // Encode the multi-signature
+      const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]);
+
+      // Should fail because one signer is not authorized
+      await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false;
+    });
+
+    it('rejects invalid signatures from authorized signers', async function () {
+      // Create a valid signature and an invalid one from authorized signers
+      const validSignature = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE));
+      const invalidSignature = await signerECDSA2.signMessage(ethers.toUtf8Bytes('Different message')); // Wrong message
+
+      // Prepare signers and signatures arrays
+      const signers = [signerECDSA1.address, signerECDSA2.address].sort((a, b) =>
+        ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1,
+      );
+
+      const signatures = signers.map(signer => {
+        if (signer === signerECDSA1.address) return validSignature;
+        return invalidSignature;
+      });
+
+      // Encode the multi-signature
+      const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]);
+
+      // Should fail because one signature is invalid
+      await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false;
+    });
+
+    it('rejects signatures from unsorted signers', async function () {
+      // Create a valid signature and an invalid one from authorized signers
+      const validSignature1 = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE));
+      const validSignature2 = await signerECDSA2.signMessage(ethers.getBytes(TEST_MESSAGE));
+
+      // Prepare signers and signatures arrays
+      const signers = [signerECDSA1.address, signerECDSA2.address].sort((a, b) =>
+        ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1,
+      );
+      const unsortedSigners = signers.reverse();
+      const signatures = unsortedSigners.map(signer => {
+        if (signer === signerECDSA1.address) return validSignature1;
+        return validSignature2;
+      });
+
+      // Encode the multi-signature
+      const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(
+        ['bytes[]', 'bytes[]'],
+        [unsortedSigners, signatures],
+      );
+
+      // Should fail because signers are not sorted
+      await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false;
+    });
+
+    it('rejects signatures when signers.length != signatures.length', async function () {
+      // Create a valid signature and an invalid one from authorized signers
+      const validSignature1 = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE));
+
+      // Prepare signers and signatures arrays
+      const signers = [signerECDSA1.address, signerECDSA2.address];
+      const signatures = [validSignature1];
+
+      // Encode the multi-signature
+      const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]);
+
+      // Should fail because signers and signatures arrays have different lengths
+      await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false;
+    });
+
+    it('rejects duplicated signers', async function () {
+      // Create a valid signature
+      const validSignature = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE));
+
+      // Prepare signers and signatures arrays
+      const signers = [signerECDSA1.address, signerECDSA1.address];
+      const signatures = [validSignature, validSignature];
+
+      // Encode the multi-signature
+      const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]);
+
+      // Should fail because of duplicated signers
+      await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false;
+    });
+  });
+});

+ 38 - 1
test/helpers/signers.js

@@ -1,4 +1,5 @@
 const {
+  AbiCoder,
   AbstractSigner,
   Signature,
   TypedDataEncoder,
@@ -13,6 +14,7 @@ const {
   hexlify,
   sha256,
   toBeHex,
+  keccak256,
 } = require('ethers');
 const { secp256r1 } = require('@noble/curves/p256');
 const { generateKeyPairSync, privateEncrypt } = require('crypto');
@@ -144,4 +146,39 @@ class RSASHA256SigningKey extends RSASigningKey {
   }
 }
 
-module.exports = { NonNativeSigner, P256SigningKey, RSASigningKey, RSASHA256SigningKey };
+class MultiERC7913SigningKey {
+  // this is a sorted array of objects that contain {signer, weight}
+  #signers;
+
+  constructor(signers) {
+    assertArgument(
+      Array.isArray(signers) && signers.length > 0,
+      'signers must be a non-empty array',
+      'signers',
+      signers.length,
+    );
+
+    // Sorting is done at construction so that it doesn't have to be done in sign()
+    this.#signers = signers.sort((s1, s2) => keccak256(s1.bytes ?? s1.address) - keccak256(s2.bytes ?? s2.address));
+  }
+
+  get signers() {
+    return this.#signers;
+  }
+
+  sign(digest /*: BytesLike*/ /*: Signature*/) {
+    assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
+
+    return {
+      serialized: AbiCoder.defaultAbiCoder().encode(
+        ['bytes[]', 'bytes[]'],
+        [
+          this.#signers.map(signer => signer.bytes ?? signer.address),
+          this.#signers.map(signer => signer.signingKey.sign(digest).serialized),
+        ],
+      ),
+    };
+  }
+}
+
+module.exports = { NonNativeSigner, P256SigningKey, RSASigningKey, RSASHA256SigningKey, MultiERC7913SigningKey };

+ 320 - 2
test/utils/cryptography/SignatureChecker.test.js

@@ -3,6 +3,7 @@ const { expect } = require('chai');
 const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
 
 const precompile = require('../../helpers/precompiles');
+const { P256SigningKey, NonNativeSigner } = require('../../helpers/signers');
 
 const TEST_MESSAGE = ethers.id('OpenZeppelin');
 const TEST_MESSAGE_HASH = ethers.hashMessage(TEST_MESSAGE);
@@ -10,14 +11,19 @@ const TEST_MESSAGE_HASH = ethers.hashMessage(TEST_MESSAGE);
 const WRONG_MESSAGE = ethers.id('Nope');
 const WRONG_MESSAGE_HASH = ethers.hashMessage(WRONG_MESSAGE);
 
+const aliceP256 = new NonNativeSigner(P256SigningKey.random());
+const bobP256 = new NonNativeSigner(P256SigningKey.random());
+
 async function fixture() {
-  const [signer, other] = await ethers.getSigners();
+  const [signer, extraSigner, other] = await ethers.getSigners();
   const mock = await ethers.deployContract('$SignatureChecker');
   const wallet = await ethers.deployContract('ERC1271WalletMock', [signer]);
+  const wallet2 = await ethers.deployContract('ERC1271WalletMock', [extraSigner]);
   const malicious = await ethers.deployContract('ERC1271MaliciousMock');
   const signature = await signer.signMessage(TEST_MESSAGE);
+  const verifier = await ethers.deployContract('ERC7913P256Verifier');
 
-  return { signer, other, mock, wallet, malicious, signature };
+  return { signer, other, extraSigner, mock, wallet, wallet2, malicious, signature, verifier };
 }
 
 describe('SignatureChecker (ERC1271)', function () {
@@ -72,4 +78,316 @@ describe('SignatureChecker (ERC1271)', function () {
       });
     }
   });
+
+  describe('ERC7913', function () {
+    describe('isValidERC7913SignatureNow', function () {
+      describe('with EOA signer', function () {
+        it('with matching signer and signature', async function () {
+          const eoaSigner = ethers.zeroPadValue(this.signer.address, 20);
+          const signature = await this.signer.signMessage(TEST_MESSAGE);
+          await expect(this.mock.$isValidERC7913SignatureNow(eoaSigner, TEST_MESSAGE_HASH, signature)).to.eventually.be
+            .true;
+        });
+
+        it('with invalid signer', async function () {
+          const eoaSigner = ethers.zeroPadValue(this.other.address, 20);
+          const signature = await this.signer.signMessage(TEST_MESSAGE);
+          await expect(this.mock.$isValidERC7913SignatureNow(eoaSigner, TEST_MESSAGE_HASH, signature)).to.eventually.be
+            .false;
+        });
+
+        it('with invalid signature', async function () {
+          const eoaSigner = ethers.zeroPadValue(this.signer.address, 20);
+          const signature = await this.signer.signMessage(TEST_MESSAGE);
+          await expect(this.mock.$isValidERC7913SignatureNow(eoaSigner, WRONG_MESSAGE_HASH, signature)).to.eventually.be
+            .false;
+        });
+      });
+
+      describe('with ERC-1271 wallet', function () {
+        it('with matching signer and signature', async function () {
+          const walletSigner = ethers.zeroPadValue(this.wallet.target, 20);
+          const signature = await this.signer.signMessage(TEST_MESSAGE);
+          await expect(this.mock.$isValidERC7913SignatureNow(walletSigner, TEST_MESSAGE_HASH, signature)).to.eventually
+            .be.true;
+        });
+
+        it('with invalid signer', async function () {
+          const walletSigner = ethers.zeroPadValue(this.mock.target, 20);
+          const signature = await this.signer.signMessage(TEST_MESSAGE);
+          await expect(this.mock.$isValidERC7913SignatureNow(walletSigner, TEST_MESSAGE_HASH, signature)).to.eventually
+            .be.false;
+        });
+
+        it('with invalid signature', async function () {
+          const walletSigner = ethers.zeroPadValue(this.wallet.target, 20);
+          const signature = await this.signer.signMessage(TEST_MESSAGE);
+          await expect(this.mock.$isValidERC7913SignatureNow(walletSigner, WRONG_MESSAGE_HASH, signature)).to.eventually
+            .be.false;
+        });
+      });
+
+      describe('with ERC-7913 verifier', function () {
+        it('with matching signer and signature', async function () {
+          const signer = ethers.concat([
+            this.verifier.target,
+            aliceP256.signingKey.publicKey.qx,
+            aliceP256.signingKey.publicKey.qy,
+          ]);
+          const signature = await aliceP256.signMessage(TEST_MESSAGE);
+
+          await expect(this.mock.$isValidERC7913SignatureNow(signer, TEST_MESSAGE_HASH, signature)).to.eventually.be
+            .true;
+        });
+
+        it('with invalid verifier', async function () {
+          const signer = ethers.concat([
+            this.mock.target, // invalid verifier
+            aliceP256.signingKey.publicKey.qx,
+            aliceP256.signingKey.publicKey.qy,
+          ]);
+          const signature = await aliceP256.signMessage(TEST_MESSAGE);
+
+          await expect(this.mock.$isValidERC7913SignatureNow(signer, TEST_MESSAGE_HASH, signature)).to.eventually.be
+            .false;
+        });
+
+        it('with invalid key', async function () {
+          const signer = ethers.concat([this.verifier.target, ethers.randomBytes(32)]);
+          const signature = await aliceP256.signMessage(TEST_MESSAGE);
+
+          await expect(this.mock.$isValidERC7913SignatureNow(signer, TEST_MESSAGE_HASH, signature)).to.eventually.be
+            .false;
+        });
+
+        it('with invalid signature', async function () {
+          const signer = ethers.concat([
+            this.verifier.target,
+            aliceP256.signingKey.publicKey.qx,
+            aliceP256.signingKey.publicKey.qy,
+          ]);
+          const signature = ethers.randomBytes(65); // invalid (random) signature
+
+          await expect(this.mock.$isValidERC7913SignatureNow(signer, TEST_MESSAGE_HASH, signature)).to.eventually.be
+            .false;
+        });
+
+        it('with signer too short', async function () {
+          const signer = ethers.randomBytes(19); // too short
+          const signature = await aliceP256.signMessage(TEST_MESSAGE);
+          await expect(this.mock.$isValidERC7913SignatureNow(signer, TEST_MESSAGE_HASH, signature)).to.eventually.be
+            .false;
+        });
+      });
+    });
+
+    describe('areValidERC7913SignaturesNow', function () {
+      const sortSigners = (...signers) =>
+        signers.sort(({ signer: a }, { signer: b }) => ethers.keccak256(b) - ethers.keccak256(a));
+
+      it('should validate a single signature', async function () {
+        const signer = ethers.zeroPadValue(this.signer.address, 20);
+        const signature = await this.signer.signMessage(TEST_MESSAGE);
+
+        await expect(this.mock.$areValidERC7913SignaturesNow(TEST_MESSAGE_HASH, [signer], [signature])).to.eventually.be
+          .true;
+      });
+
+      it('should validate multiple signatures with different signer types', async function () {
+        const signers = sortSigners(
+          {
+            signer: ethers.zeroPadValue(this.signer.address, 20),
+            signature: await this.signer.signMessage(TEST_MESSAGE),
+          },
+          {
+            signer: ethers.zeroPadValue(this.wallet.target, 20),
+            signature: await this.signer.signMessage(TEST_MESSAGE),
+          },
+          {
+            signer: ethers.concat([
+              this.verifier.target,
+              aliceP256.signingKey.publicKey.qx,
+              aliceP256.signingKey.publicKey.qy,
+            ]),
+            signature: await aliceP256.signMessage(TEST_MESSAGE),
+          },
+        );
+
+        await expect(
+          this.mock.$areValidERC7913SignaturesNow(
+            TEST_MESSAGE_HASH,
+            signers.map(({ signer }) => signer),
+            signers.map(({ signature }) => signature),
+          ),
+        ).to.eventually.be.true;
+      });
+
+      it('should validate multiple EOA signatures', async function () {
+        const signers = sortSigners(
+          {
+            signer: ethers.zeroPadValue(this.signer.address, 20),
+            signature: await this.signer.signMessage(TEST_MESSAGE),
+          },
+          {
+            signer: ethers.zeroPadValue(this.extraSigner.address, 20),
+            signature: await this.extraSigner.signMessage(TEST_MESSAGE),
+          },
+        );
+
+        await expect(
+          this.mock.$areValidERC7913SignaturesNow(
+            TEST_MESSAGE_HASH,
+            signers.map(({ signer }) => signer),
+            signers.map(({ signature }) => signature),
+          ),
+        ).to.eventually.be.true;
+      });
+
+      it('should validate multiple ERC-1271 wallet signatures', async function () {
+        const signers = sortSigners(
+          {
+            signer: ethers.zeroPadValue(this.wallet.target, 20),
+            signature: await this.signer.signMessage(TEST_MESSAGE),
+          },
+          {
+            signer: ethers.zeroPadValue(this.wallet2.target, 20),
+            signature: await this.extraSigner.signMessage(TEST_MESSAGE),
+          },
+        );
+
+        await expect(
+          this.mock.$areValidERC7913SignaturesNow(
+            TEST_MESSAGE_HASH,
+            signers.map(({ signer }) => signer),
+            signers.map(({ signature }) => signature),
+          ),
+        ).to.eventually.be.true;
+      });
+
+      it('should validate multiple ERC-7913 signatures (ordered by ID)', async function () {
+        const signers = sortSigners(
+          {
+            signer: ethers.concat([
+              this.verifier.target,
+              aliceP256.signingKey.publicKey.qx,
+              aliceP256.signingKey.publicKey.qy,
+            ]),
+            signature: await aliceP256.signMessage(TEST_MESSAGE),
+          },
+          {
+            signer: ethers.concat([
+              this.verifier.target,
+              bobP256.signingKey.publicKey.qx,
+              bobP256.signingKey.publicKey.qy,
+            ]),
+            signature: await bobP256.signMessage(TEST_MESSAGE),
+          },
+        );
+
+        await expect(
+          this.mock.$areValidERC7913SignaturesNow(
+            TEST_MESSAGE_HASH,
+            signers.map(({ signer }) => signer),
+            signers.map(({ signature }) => signature),
+          ),
+        ).to.eventually.be.true;
+      });
+
+      it('should validate multiple ERC-7913 signatures (unordered)', async function () {
+        const signers = sortSigners(
+          {
+            signer: ethers.concat([
+              this.verifier.target,
+              aliceP256.signingKey.publicKey.qx,
+              aliceP256.signingKey.publicKey.qy,
+            ]),
+            signature: await aliceP256.signMessage(TEST_MESSAGE),
+          },
+          {
+            signer: ethers.concat([
+              this.verifier.target,
+              bobP256.signingKey.publicKey.qx,
+              bobP256.signingKey.publicKey.qy,
+            ]),
+            signature: await bobP256.signMessage(TEST_MESSAGE),
+          },
+        ).reverse(); // reverse
+
+        await expect(
+          this.mock.$areValidERC7913SignaturesNow(
+            TEST_MESSAGE_HASH,
+            signers.map(({ signer }) => signer),
+            signers.map(({ signature }) => signature),
+          ),
+        ).to.eventually.be.true;
+      });
+
+      it('should return false if any signature is invalid', async function () {
+        const signers = sortSigners(
+          {
+            signer: ethers.zeroPadValue(this.signer.address, 20),
+            signature: await this.signer.signMessage(TEST_MESSAGE),
+          },
+          {
+            signer: ethers.zeroPadValue(this.extraSigner.address, 20),
+            signature: await this.extraSigner.signMessage(WRONG_MESSAGE),
+          },
+        );
+
+        await expect(
+          this.mock.$areValidERC7913SignaturesNow(
+            TEST_MESSAGE_HASH,
+            signers.map(({ signer }) => signer),
+            signers.map(({ signature }) => signature),
+          ),
+        ).to.eventually.be.false;
+      });
+
+      it('should return false if there are duplicate signers', async function () {
+        const signers = sortSigners(
+          {
+            signer: ethers.zeroPadValue(this.signer.address, 20),
+            signature: await this.signer.signMessage(TEST_MESSAGE),
+          },
+          {
+            signer: ethers.zeroPadValue(this.signer.address, 20),
+            signature: await this.signer.signMessage(TEST_MESSAGE),
+          },
+        );
+
+        await expect(
+          this.mock.$areValidERC7913SignaturesNow(
+            TEST_MESSAGE_HASH,
+            signers.map(({ signer }) => signer),
+            signers.map(({ signature }) => signature),
+          ),
+        ).to.eventually.be.false;
+      });
+
+      it('should return false if signatures array length does not match signers array length', async function () {
+        const signers = sortSigners(
+          {
+            signer: ethers.zeroPadValue(this.signer.address, 20),
+            signature: await this.signer.signMessage(TEST_MESSAGE),
+          },
+          {
+            signer: ethers.zeroPadValue(this.extraSigner.address, 20),
+            signature: await this.extraSigner.signMessage(TEST_MESSAGE),
+          },
+        );
+
+        await expect(
+          this.mock.$areValidERC7913SignaturesNow(
+            TEST_MESSAGE_HASH,
+            signers.map(({ signer }) => signer),
+            signers.map(({ signature }) => signature).slice(1),
+          ),
+        ).to.eventually.be.false;
+      });
+
+      it('should pass with empty arrays', async function () {
+        await expect(this.mock.$areValidERC7913SignaturesNow(TEST_MESSAGE_HASH, [], [])).to.eventually.be.true;
+      });
+    });
+  });
 });