Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/solid-squids-cough.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/yummy-ideas-stay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify package key matches the workspace configuration

Ensure openzeppelin-solidity is the correct package name in this repo’s Changesets config; otherwise use the actual workspace package (often @openzeppelin/contracts).

Run:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Packages matching expected names:"
fd -a package.json | xargs -I{} sh -c 'jq -r ".name + \" @ \" + (.version // \"unknown\") + \" :: {}\"" "{}"' | rg '@openzeppelin/contracts|openzeppelin-solidity' -n || true

echo
echo "Changesets referencing package keys:"
rg -n "openzeppelin-solidity|@openzeppelin/contracts" .changeset || true

Length of output: 2180


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Root package.json metadata:"
jq '{name, version, private, workspaces}' package.json

echo
echo "Changesets config:"
jq '.' .changeset/config.json

Length of output: 590


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Checking for pnpm-workspace.yaml..."
if [ -f pnpm-workspace.yaml ]; then
  echo "Found pnpm-workspace.yaml"
else
  echo "pnpm-workspace.yaml not found"
fi

echo
echo "Checking for lerna.json..."
if [ -f lerna.json ]; then
  echo "Found lerna.json"
else
  echo "lerna.json not found"
fi

echo
echo "Searching for 'workspaces' key in package.json..."
grep -R '"workspaces"' -n package.json || echo "No 'workspaces' key in package.json"

echo
echo "Searching for 'packages' key in .changeset/config.json..."
grep -R '"packages"' -n .changeset/config.json || echo "No 'packages' key in .changeset/config.json"

Length of output: 890


Update Changesets to target the published package (@openzeppelin/contracts)

Your current setup is bumping the private root package (name: "openzeppelin-solidity") instead of the actual npm package in contracts/package.json (@openzeppelin/contracts). To fix this, you need to tell Changesets about the subpackage and update all existing changesets to use its key.

• In .changeset/config.json, add the packages field to point at the contracts folder:

 {
   "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
-  "changelog": [
+  "packages": ["contracts"],
+  "changelog": [
     "@changesets/changelog-github",
     {
       "repo": "OpenZeppelin/openzeppelin-contracts"
     }
   ],
   "commit": false,
   "access": "public",
   "baseBranch": "master"
 }

• Update each existing changeset in .changeset/*.md so that the first line uses the published package name. For example, in yummy-ideas-stay.md:

- 'openzeppelin-solidity': minor
+ '@openzeppelin/contracts': minor

• Repeat for all files under .changeset/ (e.g. witty-hats-flow.md, whole-cats-find.md, etc.).

After these updates, running Changesets will correctly bump and publish @openzeppelin/contracts (version 5.4.0) instead of the private root.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'openzeppelin-solidity': minor
'@openzeppelin/contracts': minor
🤖 Prompt for AI Agents
In .changeset/yummy-ideas-stay.md around line 2 and across other .changeset/*.md
files, the changeset is targeting the private root package
('openzeppelin-solidity') instead of the published package; change the first
line of this and every changeset file to use the published package key (for
example "@openzeppelin/contracts: minor") so Changesets will bump the correct
package, and add a packages field to .changeset/config.json pointing to the
contracts folder (e.g. "packages": ["contracts"]) so Changesets knows to target
the subpackage.

---

`ERC7579Validator`: Add abstract validator module for ERC-7579 accounts that provides base implementation for signature validation.
8 changes: 8 additions & 0 deletions contracts/account/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,6 +25,12 @@ This directory includes contracts to build accounts for ERC-4337. These include:

{{ERC7821}}

=== Validators

{{ERC7579Validator}}

{{ERC7579Signature}}

== Utilities

{{ERC4337Utils}}
Expand Down
86 changes: 86 additions & 0 deletions contracts/account/modules/ERC7579Signature.sol
Original file line number Diff line number Diff line change
@@ -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());
Comment on lines +59 to +60
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The minimum signer length check may not be sufficient for all ERC-7913 use cases.

ERC-7913 signers may require more specific validation based on verifier and key format. Please consider adding stricter checks or documenting the expected signer structure to avoid misconfiguration.

Suggested implementation:

    /**
     * @dev Sets the ERC-7913 signer for the calling account.
     * The signer must be encoded as: `verifier (20 bytes) || key (variable length)`.
     * Requirements:
     * - `signer_` must be at least 20 bytes (verifier address).
     * - `verifier` must be a valid non-zero address.
     * - `key` must be present (length > 0).
     */
    function setSigner(bytes memory signer_) public virtual {
        require(signer_.length > 20, ERC7579SignatureInvalidSignerLength()); // 20 bytes verifier + at least 1 byte key
        address verifier;
        assembly {
            verifier := mload(add(signer_, 20))
        }
        require(verifier != address(0), ERC7579SignatureInvalidVerifier());
        _setSigner(msg.sender, signer_);
    }

You will need to define the custom error ERC7579SignatureInvalidVerifier() if it does not already exist in your codebase, e.g.:

error ERC7579SignatureInvalidVerifier();

Also, ensure that the expected structure of the signer (verifier || key) is documented wherever relevant in your codebase and documentation.

_setSigner(msg.sender, signer_);
}
Comment on lines +58 to +62
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Custom error with require will not compile; use revert.

require(..., CustomError()) is invalid. Switch to an explicit revert.

-    function setSigner(bytes memory signer_) public virtual {
-        require(signer_.length >= 20, ERC7579SignatureInvalidSignerLength());
-        _setSigner(msg.sender, signer_);
-    }
+    function setSigner(bytes memory signer_) public virtual {
+        if (signer_.length < 20) revert ERC7579SignatureInvalidSignerLength();
+        _setSigner(msg.sender, signer_);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// @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 Sets the ERC-7913 signer (i.e. `verifier || key`) for the calling account.
function setSigner(bytes memory signer_) public virtual {
if (signer_.length < 20) revert ERC7579SignatureInvalidSignerLength();
_setSigner(msg.sender, signer_);
}
🤖 Prompt for AI Agents
In contracts/account/modules/ERC7579Signature.sol around lines 58 to 62, the
require call using a custom error will not compile; replace the require(...,
CustomError()) with an explicit check and revert: check signer_.length < 20 in
an if statement and revert ERC7579SignatureInvalidSignerLength() when true, then
call _setSigner(msg.sender, signer_); keep visibility and function signature
unchanged.


/// @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);
}
}
Comment on lines +79 to +86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Signature validation relies on external SignatureChecker; ensure compatibility with all expected signer formats.

Document the expected signer formats and cryptographic schemes, and consider adding extension points to support custom signature validation if needed.

Suggested change
function _rawERC7579Validation(
address account,
bytes32 hash,
bytes calldata signature
) internal view virtual override returns (bool) {
return SignatureChecker.isValidSignatureNow(signer(account), hash, signature);
}
}
/**
* @dev Validates a `signature` for the given `account` and `hash`.
*
* This function uses SignatureChecker to validate the signature against the account's stored signer.
*
* Expected signer formats:
* - EOA (Externally Owned Account) addresses (ECDSA signatures)
* - ERC1271 contract-based signers (isValidSignature)
*
* Supported cryptographic schemes:
* - ECDSA (secp256k1)
* - ERC1271 contract signatures
*
* Derived contracts can override {_customSignatureValidation} to support additional formats or schemes.
*/
function _rawERC7579Validation(
address account,
bytes32 hash,
bytes calldata signature
) internal view virtual override returns (bool) {
// Extension point for custom validation logic
bool customValid = _customSignatureValidation(account, hash, signature);
if (customValid) {
return true;
}
return SignatureChecker.isValidSignatureNow(signer(account), hash, signature);
}
/**
* @dev Extension point for custom signature validation logic.
* Override in derived contracts to support additional signer formats or cryptographic schemes.
* Return true if the signature is valid, false otherwise.
*/
function _customSignatureValidation(
address account,
bytes32 hash,
bytes calldata signature
) internal view virtual returns (bool) {
// Default implementation: no custom validation
return false;
}
}

101 changes: 101 additions & 0 deletions contracts/account/modules/ERC7579Validator.sol
Original file line number Diff line number Diff line change
@@ -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);
}
70 changes: 70 additions & 0 deletions test/account/modules/ERC7579Module.behavior.js
Original file line number Diff line number Diff line change
@@ -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,
);
Comment on lines +56 to +61
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use mockFromAccount here for consistency with msg.sender-based validation.

isValidSignatureWithSender relies on msg.sender (the account). Using this.mock may mask issues.

-        await expect(this.mock.isValidSignatureWithSender(this.other, hash, signature)).to.eventually.equal(
+        await expect(this.mockFromAccount.isValidSignatureWithSender(this.other, hash, signature)).to.eventually.equal(
           INVALID_VALUE,
         );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
);
it('returns failure value for invalid signature', async function () {
const hash = ethers.hashMessage('Hello, world!');
const signature = this.invalidSignature || '0x00';
await expect(this.mockFromAccount.isValidSignatureWithSender(this.other, hash, signature)).to.eventually.equal(
INVALID_VALUE,
);
});
🤖 Prompt for AI Agents
test/account/modules/ERC7579Module.behavior.js around lines 56 to 61: the test
calls isValidSignatureWithSender on this.mock which doesn’t simulate msg.sender
correctly; replace the call to use the mock instance created for the account
(mockFromAccount) so msg.sender-based validation is executed. Specifically,
obtain the mock bound to this.other (or create mockFromAccount(this.other)) and
call isValidSignatureWithSender on that mock instance with the same hash and
signature, keeping the assertion expecting INVALID_VALUE.

});
});
});
}

module.exports = {
shouldBehaveLikeERC7579Module,
shouldBehaveLikeERC7579Validator,
};
Loading