From 451e46cff7a79fd6c19927e0953e24fb1ed94fff Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sat, 12 Apr 2025 12:56:20 -0600 Subject: [PATCH] Add ERC-7913 interface and utils --- contracts/interfaces/IERC7913.sol | 17 +++ contracts/mocks/ERC7913VerifierMock.sol | 28 ++++ contracts/mocks/import.sol | 1 + contracts/utils/cryptography/ERC7913Utils.sol | 49 +++++++ test/utils/cryptography/ERC7913Utils.test.js | 127 ++++++++++++++++++ 5 files changed, 222 insertions(+) create mode 100644 contracts/interfaces/IERC7913.sol create mode 100644 contracts/mocks/ERC7913VerifierMock.sol create mode 100644 contracts/utils/cryptography/ERC7913Utils.sol create mode 100644 test/utils/cryptography/ERC7913Utils.test.js diff --git a/contracts/interfaces/IERC7913.sol b/contracts/interfaces/IERC7913.sol new file mode 100644 index 00000000..58e33409 --- /dev/null +++ b/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); +} diff --git a/contracts/mocks/ERC7913VerifierMock.sol b/contracts/mocks/ERC7913VerifierMock.sol new file mode 100644 index 00000000..ac0eeff4 --- /dev/null +++ b/contracts/mocks/ERC7913VerifierMock.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC7913SignatureVerifier} from "../../contracts/interfaces/IERC7913.sol"; + +contract ERC7913VerifierMock is IERC7913SignatureVerifier { + // Store valid keys and their corresponding signatures + mapping(bytes32 => bool) private _validKeys; + mapping(bytes32 => mapping(bytes32 => bool)) private _validSignatures; + + constructor() { + // For testing purposes, we'll consider a specific key as valid + bytes32 validKeyHash = keccak256(abi.encodePacked("valid_key")); + _validKeys[validKeyHash] = true; + } + + function verify(bytes calldata key, bytes32 /* hash */, bytes calldata signature) external pure returns (bytes4) { + // For testing purposes, we'll only accept a specific key and signature combination + if ( + keccak256(key) == keccak256(abi.encodePacked("valid_key")) && + keccak256(signature) == keccak256(abi.encodePacked("valid_signature")) + ) { + return IERC7913SignatureVerifier.verify.selector; + } + return 0xffffffff; + } +} diff --git a/contracts/mocks/import.sol b/contracts/mocks/import.sol index a9ceef65..4c81918e 100644 --- a/contracts/mocks/import.sol +++ b/contracts/mocks/import.sol @@ -3,3 +3,4 @@ pragma solidity ^0.8.20; import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import {ERC1271WalletMock} from "@openzeppelin/contracts/mocks/ERC1271WalletMock.sol"; diff --git a/contracts/utils/cryptography/ERC7913Utils.sol b/contracts/utils/cryptography/ERC7913Utils.sol new file mode 100644 index 00000000..157dcbfb --- /dev/null +++ b/contracts/utils/cryptography/ERC7913Utils.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import {IERC7913SignatureVerifier} from "../../interfaces/IERC7913.sol"; + +/** + * @dev Library that provides common ERC-7913 utility functions. + * + * This library extends the functionality of + * https://docs.openzeppelin.com/contracts/5.x/api/utils#SignatureChecker[SignatureChecker] + * to support signature verification for keys that do not have an Ethereum address of their own + * as with ERC-1271. + * + * See https://eips.ethereum.org/EIPS/eip-7913[ERC-7913]. + */ +library ERC7913Utils { + /** + * @dev Verifies a signature for a given 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 {SignatureChecker} + * - Otherwise: verification is done using {IERC7913SignatureVerifier} + */ + function isValidSignatureNow( + bytes calldata signer, + bytes32 hash, + bytes memory signature + ) internal view returns (bool) { + if (signer.length < 20) { + return false; + } else if (signer.length == 20) { + return SignatureChecker.isValidSignatureNow(address(bytes20(signer)), hash, signature); + } else { + try IERC7913SignatureVerifier(address(bytes20(signer[0:20]))).verify(signer[20:], hash, signature) returns ( + bytes4 magic + ) { + return magic == IERC7913SignatureVerifier.verify.selector; + } catch { + return false; + } + } + } +} diff --git a/test/utils/cryptography/ERC7913Utils.test.js b/test/utils/cryptography/ERC7913Utils.test.js new file mode 100644 index 00000000..6760b839 --- /dev/null +++ b/test/utils/cryptography/ERC7913Utils.test.js @@ -0,0 +1,127 @@ +const { expect } = require('chai'); +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const TEST_MESSAGE = ethers.id('OpenZeppelin'); +const TEST_MESSAGE_HASH = ethers.hashMessage(TEST_MESSAGE); + +const WRONG_MESSAGE = ethers.id('Nope'); +const WRONG_MESSAGE_HASH = ethers.hashMessage(WRONG_MESSAGE); + +async function fixture() { + const [, signer, other] = await ethers.getSigners(); + const mock = await ethers.deployContract('$ERC7913Utils'); + + // Deploy a mock ERC-1271 wallet + const wallet = await ethers.deployContract('ERC1271WalletMock', [signer]); + + // Deploy a mock ERC-7913 verifier + const verifier = await ethers.deployContract('ERC7913VerifierMock'); + + // Create test keys + const validKey = ethers.toUtf8Bytes('valid_key'); + const invalidKey = ethers.randomBytes(32); + + // Create signer bytes (verifier address + key) + const validSignerBytes = ethers.concat([verifier.target, validKey]); + const invalidKeySignerBytes = ethers.concat([verifier.target, invalidKey]); + + // Create test signatures + const validSignature = ethers.toUtf8Bytes('valid_signature'); + const invalidSignature = ethers.randomBytes(65); + + // Get EOA signature from the signer + const eoaSignature = await signer.signMessage(TEST_MESSAGE); + + return { + signer, + other, + mock, + wallet, + verifier, + validKey, + invalidKey, + validSignerBytes, + invalidKeySignerBytes, + validSignature, + invalidSignature, + eoaSignature, + }; +} + +describe('ERC7913Utils', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('isValidSignatureNow', function () { + describe('with EOA signer', function () { + it('with matching signer and signature', async function () { + const eoaSigner = ethers.zeroPadValue(this.signer.address, 20); + await expect(this.mock.$isValidSignatureNow(eoaSigner, TEST_MESSAGE_HASH, this.eoaSignature)).to.eventually.be + .true; + }); + + it('with invalid signer', async function () { + const eoaSigner = ethers.zeroPadValue(this.other.address, 20); + await expect(this.mock.$isValidSignatureNow(eoaSigner, TEST_MESSAGE_HASH, this.eoaSignature)).to.eventually.be + .false; + }); + + it('with invalid signature', async function () { + const eoaSigner = ethers.zeroPadValue(this.signer.address, 20); + await expect(this.mock.$isValidSignatureNow(eoaSigner, WRONG_MESSAGE_HASH, this.eoaSignature)).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); + await expect(this.mock.$isValidSignatureNow(walletSigner, TEST_MESSAGE_HASH, this.eoaSignature)).to.eventually + .be.true; + }); + + it('with invalid signer', async function () { + const walletSigner = ethers.zeroPadValue(this.mock.target, 20); + await expect(this.mock.$isValidSignatureNow(walletSigner, TEST_MESSAGE_HASH, this.eoaSignature)).to.eventually + .be.false; + }); + + it('with invalid signature', async function () { + const walletSigner = ethers.zeroPadValue(this.wallet.target, 20); + await expect(this.mock.$isValidSignatureNow(walletSigner, WRONG_MESSAGE_HASH, this.eoaSignature)).to.eventually + .be.false; + }); + }); + + describe('with ERC-7913 verifier', function () { + it('with matching signer and signature', async function () { + await expect(this.mock.$isValidSignatureNow(this.validSignerBytes, TEST_MESSAGE_HASH, this.validSignature)).to + .eventually.be.true; + }); + + it('with invalid verifier', async function () { + const invalidVerifierSigner = ethers.concat([this.mock.target, this.validKey]); + await expect(this.mock.$isValidSignatureNow(invalidVerifierSigner, TEST_MESSAGE_HASH, this.validSignature)).to + .eventually.be.false; + }); + + it('with invalid key', async function () { + await expect(this.mock.$isValidSignatureNow(this.invalidKeySignerBytes, TEST_MESSAGE_HASH, this.validSignature)) + .to.eventually.be.false; + }); + + it('with invalid signature', async function () { + await expect(this.mock.$isValidSignatureNow(this.validSignerBytes, TEST_MESSAGE_HASH, this.invalidSignature)).to + .eventually.be.false; + }); + + it('with signer too short', async function () { + const shortSigner = ethers.randomBytes(19); + await expect(this.mock.$isValidSignatureNow(shortSigner, TEST_MESSAGE_HASH, this.validSignature)).to.eventually + .be.false; + }); + }); + }); +});