From 10eb64b40c231d496e7bc19960424bc68fd990f9 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 27 Aug 2025 10:11:30 -1000 Subject: [PATCH 1/2] Add ERC7579 validators --- contracts/account/README.adoc | 8 + .../account/modules/ERC7579Signature.sol | 86 ++++++++++ .../account/modules/ERC7579Validator.sol | 101 ++++++++++++ .../account/modules/ERC7579Module.behavior.js | 70 ++++++++ test/account/modules/ERC7579Signature.test.js | 151 ++++++++++++++++++ test/account/modules/ERC7579Validator.test.js | 57 +++++++ 6 files changed, 473 insertions(+) create mode 100644 contracts/account/modules/ERC7579Signature.sol create mode 100644 contracts/account/modules/ERC7579Validator.sol create mode 100644 test/account/modules/ERC7579Module.behavior.js create mode 100644 test/account/modules/ERC7579Signature.test.js create mode 100644 test/account/modules/ERC7579Validator.test.js diff --git a/contracts/account/README.adoc b/contracts/account/README.adoc index dc3c9a010a7..1351fd52c82 100644 --- a/contracts/account/README.adoc +++ b/contracts/account/README.adoc @@ -7,6 +7,8 @@ This directory includes contracts to build accounts for ERC-4337. These include: * {Account}: An ERC-4337 smart account implementation that includes the core logic to process user operations. * {AccountERC7579}: An extension of `Account` that implements support for ERC-7579 modules. * {AccountERC7579Hooked}: An extension of `AccountERC7579` with support for a single hook module (type 4). + * {ERC7579Validator}: Abstract validator module for ERC-7579 accounts that provides base implementation for signature validation. + * {ERC7579Signature}: Implementation of {ERC7579Validator} using ERC-7913 signature verification for address-less cryptographic keys and account signatures. * {ERC7821}: Minimal batch executor implementation contracts. Useful to enable easy batch execution for smart contracts. * {ERC4337Utils}: Utility functions for working with ERC-4337 user operations. * {ERC7579Utils}: Utility functions for working with ERC-7579 modules and account modularity. @@ -23,6 +25,12 @@ This directory includes contracts to build accounts for ERC-4337. These include: {{ERC7821}} +=== Validators + +{{ERC7579Validator}} + +{{ERC7579Signature}} + == Utilities {{ERC4337Utils}} diff --git a/contracts/account/modules/ERC7579Signature.sol b/contracts/account/modules/ERC7579Signature.sol new file mode 100644 index 00000000000..45e1e60850b --- /dev/null +++ b/contracts/account/modules/ERC7579Signature.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7579Module} from "../../interfaces/draft-IERC7579.sol"; +import {SignatureChecker} from "../../utils/cryptography/SignatureChecker.sol"; +import {ERC7579Validator} from "./ERC7579Validator.sol"; + +/** + * @dev Implementation of {ERC7579Validator} module using ERC-7913 signature verification. + * + * This validator allows ERC-7579 accounts to integrate with address-less cryptographic keys + * and account signatures through the ERC-7913 signature verification system. Each account + * can store its own ERC-7913 formatted signer (a concatenation of a verifier address and a + * key: `verifier || key`). + * + * This enables accounts to use signature schemes without requiring each key to have its own + * Ethereum address.A smart account with this module installed can keep an emergency key as a + * backup. + */ +contract ERC7579Signature is ERC7579Validator { + mapping(address account => bytes signer) private _signers; + + /// @dev Emitted when the signer is set. + event ERC7579SignatureSignerSet(address indexed account, bytes signer); + + /// @dev Thrown when the signer length is less than 20 bytes. + error ERC7579SignatureInvalidSignerLength(); + + /// @dev Return the ERC-7913 signer (i.e. `verifier || key`). + function signer(address account) public view virtual returns (bytes memory) { + return _signers[account]; + } + + /** + * @dev See {IERC7579Module-onInstall}. + * + * NOTE: An account can only call onInstall once. If called directly by the account, + * the signer will be set to the provided data. Future installations will behave as a no-op. + */ + function onInstall(bytes calldata data) public virtual { + if (signer(msg.sender).length == 0) { + setSigner(data); + } + } + + /** + * @dev See {IERC7579Module-onUninstall}. + * + * WARNING: The signer's key will be removed if the account calls this function, potentially + * making the account unusable. As an account operator, make sure to uninstall to a predefined path + * in your account that properly handles side effects of uninstallation. See {AccountERC7579-uninstallModule}. + */ + function onUninstall(bytes calldata) public virtual { + _setSigner(msg.sender, ""); + } + + /// @dev Sets the ERC-7913 signer (i.e. `verifier || key`) for the calling account. + function setSigner(bytes memory signer_) public virtual { + require(signer_.length >= 20, ERC7579SignatureInvalidSignerLength()); + _setSigner(msg.sender, signer_); + } + + /// @dev Internal version of {setSigner} that takes an `account` as argument without validating `signer_`. + function _setSigner(address account, bytes memory signer_) internal virtual { + _signers[account] = signer_; + emit ERC7579SignatureSignerSet(account, signer_); + } + + /** + * @dev See {ERC7579Validator-_rawERC7579Validation}. + * + * Validates a `signature` using ERC-7913 verification. + * + * This base implementation ignores the `sender` parameter and validates using + * the account's stored signer. Derived contracts can override this to implement + * custom validation logic based on the sender. + */ + function _rawERC7579Validation( + address account, + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + return SignatureChecker.isValidSignatureNow(signer(account), hash, signature); + } +} diff --git a/contracts/account/modules/ERC7579Validator.sol b/contracts/account/modules/ERC7579Validator.sol new file mode 100644 index 00000000000..c456ef84897 --- /dev/null +++ b/contracts/account/modules/ERC7579Validator.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7579Module, IERC7579Validator, MODULE_TYPE_VALIDATOR} from "../../interfaces/draft-IERC7579.sol"; +import {PackedUserOperation} from "../../interfaces/draft-IERC4337.sol"; +import {ERC4337Utils} from "../../account/utils/draft-ERC4337Utils.sol"; +import {IERC1271} from "../../interfaces/IERC1271.sol"; + +/** + * @dev Abstract validator module for ERC-7579 accounts. + * + * This contract provides the base implementation for signature validation in ERC-7579 accounts. + * Developers must implement the onInstall, onUninstall, and {_rawERC7579Validation} + * functions in derived contracts to define the specific signature validation logic. + * + * Example usage: + * + * ```solidity + * contract MyValidatorModule is ERC7579Validator { + * function onInstall(bytes calldata data) public { + * // Install logic here + * } + * + * function onUninstall(bytes calldata data) public { + * // Uninstall logic here + * } + * + * function _rawERC7579Validation( + * address account, + * bytes32 hash, + * bytes calldata signature + * ) internal view override returns (bool) { + * // Signature validation logic here + * } + * } + * ``` + * + * Developers can restrict other operations by using the internal {_rawERC7579Validation}. + * Example usage: + * + * ```solidity + * function execute( + * address account, + * Mode mode, + * bytes calldata executionCalldata, + * bytes32 salt, + * bytes calldata signature + * ) public virtual { + * require(_rawERC7579Validation(account, hash, signature)); + * // ... rest of execute logic + * } + * ``` + */ +abstract contract ERC7579Validator is IERC7579Module, IERC7579Validator { + /// @inheritdoc IERC7579Module + function isModuleType(uint256 moduleTypeId) public pure virtual returns (bool) { + return moduleTypeId == MODULE_TYPE_VALIDATOR; + } + + /// @inheritdoc IERC7579Validator + function validateUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash + ) public view virtual returns (uint256) { + return + _rawERC7579Validation(msg.sender, userOpHash, userOp.signature) + ? ERC4337Utils.SIG_VALIDATION_SUCCESS + : ERC4337Utils.SIG_VALIDATION_FAILED; + } + + /** + * @dev See {IERC7579Validator-isValidSignatureWithSender}. + * + * Ignores the `sender` parameter and validates using {_rawERC7579Validation}. + * Consider overriding this function to implement custom validation logic + * based on the original sender. + */ + function isValidSignatureWithSender( + address /* sender */, + bytes32 hash, + bytes calldata signature + ) public view virtual returns (bytes4) { + return + _rawERC7579Validation(msg.sender, hash, signature) + ? IERC1271.isValidSignature.selector + : bytes4(0xffffffff); + } + + /** + * @dev Validation algorithm. + * + * WARNING: Validation is a critical security function. Implementations must carefully + * handle cryptographic verification to prevent unauthorized access. + */ + function _rawERC7579Validation( + address account, + bytes32 hash, + bytes calldata signature + ) internal view virtual returns (bool); +} diff --git a/test/account/modules/ERC7579Module.behavior.js b/test/account/modules/ERC7579Module.behavior.js new file mode 100644 index 00000000000..f309662d502 --- /dev/null +++ b/test/account/modules/ERC7579Module.behavior.js @@ -0,0 +1,70 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILURE } = require('../../helpers/erc4337'); + +function shouldBehaveLikeERC7579Module() { + describe('behaves like ERC7579Module', function () { + it('identifies its module type correctly', async function () { + await expect(this.mock.isModuleType(this.moduleType)).to.eventually.be.true; + await expect(this.mock.isModuleType(999)).to.eventually.be.false; // Using random unassigned module type + }); + + it('handles installation, uninstallation, and re-installation', async function () { + await expect(this.mockFromAccount.onInstall(this.installData || '0x')).to.not.be.reverted; + await expect(this.mockFromAccount.onUninstall(this.uninstallData || '0x')).to.not.be.reverted; + await expect(this.mockFromAccount.onInstall(this.installData || '0x')).to.not.be.reverted; + }); + }); +} + +function shouldBehaveLikeERC7579Validator() { + describe('behaves like ERC7579Validator', function () { + const MAGIC_VALUE = '0x1626ba7e'; + const INVALID_VALUE = '0xffffffff'; + + beforeEach(async function () { + await this.mockFromAccount.onInstall(this.installData); + }); + + describe('validateUserOp', function () { + it('returns SIG_VALIDATION_SUCCESS when signature is valid', async function () { + const userOp = await this.mockAccount.createUserOp(this.userOp).then(op => this.signUserOp(op)); + await expect(this.mockFromAccount.validateUserOp(userOp.packed, userOp.hash())).to.eventually.equal( + SIG_VALIDATION_SUCCESS, + ); + }); + + it('returns SIG_VALIDATION_FAILURE when signature is invalid', async function () { + const userOp = await this.mockAccount.createUserOp(this.userOp); + userOp.signature = this.invalidSignature || '0x00'; + await expect(this.mockFromAccount.validateUserOp(userOp.packed, userOp.hash())).to.eventually.equal( + SIG_VALIDATION_FAILURE, + ); + }); + }); + + describe('isValidSignatureWithSender', function () { + it('returns magic value for valid signature', async function () { + const message = 'Hello, world!'; + const hash = ethers.hashMessage(message); + const signature = await this.signer.signMessage(message); + await expect(this.mockFromAccount.isValidSignatureWithSender(this.other, hash, signature)).to.eventually.equal( + MAGIC_VALUE, + ); + }); + + it('returns failure value for invalid signature', async function () { + const hash = ethers.hashMessage('Hello, world!'); + const signature = this.invalidSignature || '0x00'; + await expect(this.mock.isValidSignatureWithSender(this.other, hash, signature)).to.eventually.equal( + INVALID_VALUE, + ); + }); + }); + }); +} + +module.exports = { + shouldBehaveLikeERC7579Module, + shouldBehaveLikeERC7579Validator, +}; diff --git a/test/account/modules/ERC7579Signature.test.js b/test/account/modules/ERC7579Signature.test.js new file mode 100644 index 00000000000..cf09a48bf48 --- /dev/null +++ b/test/account/modules/ERC7579Signature.test.js @@ -0,0 +1,151 @@ +const { ethers, predeploy } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { expect } = require('chai'); + +const { impersonate } = require('../../helpers/account'); +const { getDomain, PackedUserOperation } = require('../../helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { MODULE_TYPE_VALIDATOR } = require('../../helpers/erc7579'); +const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey } = require('../../helpers/signers'); + +const { shouldBehaveLikeERC7579Module, shouldBehaveLikeERC7579Validator } = require('./ERC7579Module.behavior'); + +// Prepare signers in advance (RSA are long to initialize) +const signerECDSA = ethers.Wallet.createRandom(); +const signerP256 = new NonNativeSigner(P256SigningKey.random()); +const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); + +async function fixture() { + const [other] = await ethers.getSigners(); + + // Deploy ERC-7579 signature validator + const mock = await ethers.deployContract('$ERC7579Signature'); + + // 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(predeploy.entrypoint.v08); + + // ERC-7579 account + const mockAccount = await helper.newAccount('$AccountERC7579'); + const mockFromAccount = await impersonate(mockAccount.address).then(asAccount => mock.connect(asAccount)); + + return { + moduleType: MODULE_TYPE_VALIDATOR, + mock, + verifierP256, + verifierRSA, + mockFromAccount, + entrypointDomain, + mockAccount, + other, + }; +} + +function prepareSigner(prototype) { + this.signUserOp = userOp => + prototype.signTypedData + .call(this.signer, this.entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); +} + +describe('ERC7579Signature', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('reverts with ERC7579SignatureInvalidSignerLength when signer length is less than 20 bytes', async function () { + const shortSigner = '0x0123456789'; // Less than 20 bytes + await expect(this.mockFromAccount.onInstall(shortSigner)).to.be.revertedWithCustomError( + this.mock, + 'ERC7579SignatureInvalidSignerLength', + ); + }); + + it('behaves as a noop when the validator is already installed for an account', async function () { + // First installation should succeed + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + await expect(this.mockFromAccount.onInstall(signerData)).to.not.be.reverted; + + // Second installation should behave as a no-op + await this.mockFromAccount.onInstall(ethers.solidityPacked(['address'], [ethers.Wallet.createRandom().address])); // Not revert + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal(signerData); // No change in signers + }); + + it('emits event on ERC7579SignatureSignerSet on both installation and uninstallation', async function () { + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + + // First install + await expect(this.mockFromAccount.onInstall(signerData)) + .to.emit(this.mock, 'ERC7579SignatureSignerSet') + .withArgs(this.mockAccount.address, signerData); + + // Then uninstall + await expect(this.mockFromAccount.onUninstall('0x')) + .to.emit(this.mock, 'ERC7579SignatureSignerSet') + .withArgs(this.mockAccount.address, '0x'); + }); + + it('returns the correct signer bytes when set', async function () { + // Starts empty + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal('0x'); + + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + await this.mockFromAccount.onInstall(signerData); + + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal(signerData); + }); + + it('sets signer correctly with setSigner and emits event', async function () { + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + await expect(this.mockFromAccount.setSigner(signerData)) + .to.emit(this.mockFromAccount, 'ERC7579SignatureSignerSet') + .withArgs(this.mockAccount.address, signerData); + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal(signerData); + }); + + it('reverts when calling setSigner with invalid signer length', async function () { + await expect(this.mock.setSigner('0x0123456789')).to.be.revertedWithCustomError( + this.mock, + 'ERC7579SignatureInvalidSignerLength', + ); + }); + + // ECDSA tested in ./ERC7579Validator.test.js + + describe('P256 key', function () { + beforeEach(async function () { + this.signer = signerP256; + prepareSigner.call(this, new NonNativeSigner(this.signer.signingKey)); + this.installData = ethers.concat([ + this.verifierP256.target, + this.signer.signingKey.publicKey.qx, + this.signer.signingKey.publicKey.qy, + ]); + }); + + shouldBehaveLikeERC7579Module(); + shouldBehaveLikeERC7579Validator(); + }); + + describe('RSA key', function () { + beforeEach(async function () { + this.signer = signerRSA; + prepareSigner.call(this, new NonNativeSigner(this.signer.signingKey)); + this.installData = ethers.concat([ + this.verifierRSA.target, + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes'], + [this.signer.signingKey.publicKey.e, this.signer.signingKey.publicKey.n], + ), + ]); + }); + + shouldBehaveLikeERC7579Module(); + shouldBehaveLikeERC7579Validator(); + }); +}); diff --git a/test/account/modules/ERC7579Validator.test.js b/test/account/modules/ERC7579Validator.test.js new file mode 100644 index 00000000000..83a6539da3d --- /dev/null +++ b/test/account/modules/ERC7579Validator.test.js @@ -0,0 +1,57 @@ +const { ethers, predeploy } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { impersonate } = require('../../helpers/account'); +const { getDomain, PackedUserOperation } = require('../../helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { MODULE_TYPE_VALIDATOR } = require('../../helpers/erc7579'); + +const { shouldBehaveLikeERC7579Module, shouldBehaveLikeERC7579Validator } = require('./ERC7579Module.behavior'); + +async function fixture() { + const [other] = await ethers.getSigners(); + + // Deploy ERC-7579 validator module + const mock = await ethers.deployContract('$ERC7579Signature'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + const entrypointDomain = await getDomain(predeploy.entrypoint.v08); + + // Prepare signer + const signer = ethers.Wallet.createRandom(); + const signUserOp = userOp => + signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + + // Prepare module installation data + const installData = ethers.solidityPacked(['address'], [signer.address]); + + // ERC-7579 account + const mockAccount = await helper.newAccount('$AccountERC7579'); + const mockFromAccount = await impersonate(mockAccount.address).then(asAccount => mock.connect(asAccount)); + + return { + moduleType: MODULE_TYPE_VALIDATOR, + mock, + mockFromAccount, + mockAccount, + other, + signer, + signUserOp, + installData, + }; +} + +describe('ERC7579Validator', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('ECDSA key', function () { + shouldBehaveLikeERC7579Module(); + shouldBehaveLikeERC7579Validator(); + }); +}); From 3531217a65189d1301498566066489d69a85b3bf Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 27 Aug 2025 10:14:47 -1000 Subject: [PATCH 2/2] Add changesets --- .changeset/solid-squids-cough.md | 5 +++++ .changeset/yummy-ideas-stay.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/solid-squids-cough.md create mode 100644 .changeset/yummy-ideas-stay.md diff --git a/.changeset/solid-squids-cough.md b/.changeset/solid-squids-cough.md new file mode 100644 index 00000000000..ff6e9b1d8f0 --- /dev/null +++ b/.changeset/solid-squids-cough.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7579Signature`: Add implementation of `ERC7579Validator` that enables ERC-7579 accounts to integrate with address-less cryptographic keys and account signatures through ERC-7913 signature verification. diff --git a/.changeset/yummy-ideas-stay.md b/.changeset/yummy-ideas-stay.md new file mode 100644 index 00000000000..1972dcd0c46 --- /dev/null +++ b/.changeset/yummy-ideas-stay.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7579Validator`: Add abstract validator module for ERC-7579 accounts that provides base implementation for signature validation.