Skip to content

Commit 86298a0

Browse files
Amxxernestognw
andauthored
Implement ERC-7739 (#17)
* implement ERC7739 and lint javascript * up * match ERC specs * lint * make slither happy * Apply suggestions from code review Co-authored-by: Ernesto García <[email protected]> * slither-disable-next-line * import helpers from @openzeppelin/contract submodule * Run forge install to get the vanilla repository utils * Review recommendations --------- Co-authored-by: Ernesto García <[email protected]>
1 parent b8b9edc commit 86298a0

File tree

14 files changed

+1246
-413
lines changed

14 files changed

+1246
-413
lines changed

.github/workflows/checks.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ jobs:
3535
- uses: actions/checkout@v4
3636
- name: Set up environment
3737
uses: ./.github/actions/setup
38+
- name: Bring vanilla repository test utils as a submodule
39+
run: forge install
3840
- name: Run tests and generate gas report
3941
run: npm run test
4042

@@ -44,6 +46,8 @@ jobs:
4446
- uses: actions/checkout@v4
4547
- name: Set up environment
4648
uses: ./.github/actions/setup
49+
- name: Bring vanilla repository test utils as a submodule
50+
run: forge install
4751
- name: Run coverage
4852
run: npm run coverage
4953

contracts/mocks/ERC7739SignerMock.sol

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
6+
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
7+
import {ERC7739Signer} from "../utils/cryptography/draft-ERC7739Signer.sol";
8+
9+
contract ERC7739SignerMock is ERC7739Signer {
10+
address private immutable _eoa;
11+
12+
constructor(address eoa) EIP712("ERC7739SignerMock", "1") {
13+
_eoa = eoa;
14+
}
15+
16+
function _validateSignature(bytes32 hash, bytes calldata signature) internal view virtual override returns (bool) {
17+
(address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, signature);
18+
return _eoa == recovered && err == ECDSA.RecoverError.NoError;
19+
}
20+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol";
6+
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
7+
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
8+
import {ShortStrings} from "@openzeppelin/contracts/utils/ShortStrings.sol";
9+
import {ERC7739Utils} from "./draft-ERC7739Utils.sol";
10+
11+
/**
12+
* @dev Validates signatures wrapping the message hash in a nested EIP712 type. See {ERC7739Utils}.
13+
*
14+
* Linking the signature to the EIP-712 domain separator is a security measure to prevent signature replay across different
15+
* EIP-712 domains (e.g. a single offchain owner of multiple contracts).
16+
*
17+
* This contract requires implementing the {_validateSignature} function, which passes the wrapped message hash,
18+
* which may be either an typed data or a personal sign nested type.
19+
*
20+
* NOTE: {EIP712} uses {ShortStrings} to optimize gas costs for short strings (up to 31 characters).
21+
* Consider that strings longer than that will use storage, which may limit the ability of the signer to
22+
* be used within the ERC-4337 validation phase (due to ERC-7562 storage access rules).
23+
*/
24+
abstract contract ERC7739Signer is EIP712, IERC1271 {
25+
using ERC7739Utils for *;
26+
using MessageHashUtils for bytes32;
27+
28+
/**
29+
* @dev Attempts validating the signature in a nested EIP-712 type.
30+
*
31+
* A nested EIP-712 type might be presented in 2 different ways:
32+
*
33+
* - As a nested EIP-712 typed data
34+
* - As a _personal_ signature (an EIP-712 mimic of the `eth_personalSign` for a smart contract)
35+
*/
36+
function isValidSignature(bytes32 hash, bytes calldata signature) public view virtual returns (bytes4 result) {
37+
// For the hash `0x7739773977397739773977397739773977397739773977397739773977397739` and an empty signature,
38+
// we return the magic value too as it's assumed impossible to find a preimage for it that can be used maliciously.
39+
// Useful for simulation purposes and to validate whether the contract supports ERC-7739.
40+
return
41+
_isValidSignature(hash, signature)
42+
? IERC1271.isValidSignature.selector
43+
: (hash == 0x7739773977397739773977397739773977397739773977397739773977397739 && signature.length == 0)
44+
? bytes4(0x77390001)
45+
: bytes4(0xffffffff);
46+
}
47+
48+
/**
49+
* @dev Internal version of {isValidSignature} that returns a boolean.
50+
*/
51+
function _isValidSignature(bytes32 hash, bytes calldata signature) internal view virtual returns (bool) {
52+
return
53+
_isValidNestedTypedDataSignature(hash, signature) || _isValidNestedPersonalSignSignature(hash, signature);
54+
}
55+
56+
/**
57+
* @dev Nested personal signature verification.
58+
*/
59+
function _isValidNestedPersonalSignSignature(
60+
bytes32 hash,
61+
bytes calldata signature
62+
) internal view virtual returns (bool) {
63+
return _validateSignature(_domainSeparatorV4().toTypedDataHash(hash.personalSignStructHash()), signature);
64+
}
65+
66+
/**
67+
* @dev Nested EIP-712 typed data verification.
68+
*/
69+
function _isValidNestedTypedDataSignature(
70+
bytes32 hash,
71+
bytes calldata encodedSignature
72+
) internal view virtual returns (bool) {
73+
// decode signature
74+
(
75+
bytes calldata signature,
76+
bytes32 appSeparator,
77+
bytes32 contentsHash,
78+
string calldata contentsDescr
79+
) = encodedSignature.decodeTypedDataSig();
80+
81+
(
82+
,
83+
string memory name,
84+
string memory version,
85+
uint256 chainId,
86+
address verifyingContract,
87+
bytes32 salt,
88+
89+
) = eip712Domain();
90+
91+
// Check that contentHash and separator are correct
92+
// Rebuild nested hash
93+
return
94+
hash == appSeparator.toTypedDataHash(contentsHash) &&
95+
bytes(contentsDescr).length != 0 &&
96+
_validateSignature(
97+
appSeparator.toTypedDataHash(
98+
ERC7739Utils.typedDataSignStructHash(
99+
contentsDescr,
100+
contentsHash,
101+
abi.encode(keccak256(bytes(name)), keccak256(bytes(version)), chainId, verifyingContract, salt)
102+
)
103+
),
104+
signature
105+
);
106+
}
107+
108+
/**
109+
* @dev Signature validation algorithm.
110+
*
111+
* WARNING: Implementing a signature validation algorithm is a security-sensitive operation as it involves
112+
* cryptographic verification. It is important to review and test thoroughly before deployment. Consider
113+
* using one of the signature verification libraries ({ECDSA}, {P256} or {RSA}).
114+
*/
115+
function _validateSignature(bytes32 hash, bytes calldata signature) internal view virtual returns (bool);
116+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
/**
6+
* @dev Utilities to process https://ercs.ethereum.org/ERCS/erc-7739[ERC-7739] typed data signatures
7+
* that are specific to an EIP-712 domain.
8+
*
9+
* This library provides methods to wrap, unwrap and operate over typed data signatures with a defensive
10+
* rehashing mechanism that includes the application's {EIP712-_domainSeparatorV4} and preserves
11+
* readability of the signed content using an EIP-712 nested approach.
12+
*
13+
* A smart contract domain can validate a signature for a typed data structure in two ways:
14+
*
15+
* - As an application validating a typed data signature. See {toNestedTypedDataHash}.
16+
* - As a smart contract validating a raw message signature. See {toNestedPersonalSignHash}.
17+
*
18+
* NOTE: A provider for a smart contract wallet would need to return this signature as the
19+
* result of a call to `personal_sign` or `eth_signTypedData`, and this may be unsupported by
20+
* API clients that expect a return value of 129 bytes, or specifically the `r,s,v` parameters
21+
* of an {ECDSA} signature, as is for example specified for {EIP712}.
22+
*/
23+
library ERC7739Utils {
24+
/**
25+
* @dev An EIP-712 type to represent "personal" signatures
26+
* (i.e. mimic of `personal_sign` for smart contracts).
27+
*/
28+
bytes32 private constant PERSONAL_SIGN_TYPEHASH = keccak256("PersonalSign(bytes prefixed)");
29+
30+
/**
31+
* @dev Error when the contents type is invalid. See {tryValidateContentsType}.
32+
*/
33+
error InvalidContentsType();
34+
35+
/**
36+
* @dev Nest a signature for a given EIP-712 type into a nested signature for the domain of the app.
37+
*
38+
* Counterpart of {decodeTypedDataSig} to extract the original signature and the nested components.
39+
*/
40+
function encodeTypedDataSig(
41+
bytes memory signature,
42+
bytes32 appSeparator,
43+
bytes32 contentsHash,
44+
string memory contentsDescr
45+
) internal pure returns (bytes memory) {
46+
return
47+
abi.encodePacked(signature, appSeparator, contentsHash, contentsDescr, uint16(bytes(contentsDescr).length));
48+
}
49+
50+
/**
51+
* @dev Parses a nested signature into its components.
52+
*
53+
* Constructed as follows:
54+
*
55+
* `signature ‖ DOMAIN_SEPARATOR ‖ contentsHash ‖ contentsDescr ‖ uint16(contentsDescr.length)`
56+
*
57+
* - `signature` is the original signature for the nested struct hash that includes the "contents" hash
58+
* - `DOMAIN_SEPARATOR` is the EIP-712 {EIP712-_domainSeparatorV4} of the smart contract verifying the signature
59+
* - `contentsHash` is the hash of the underlying data structure or message
60+
* - `contentsDescr` is a descriptor of the "contents" part of the the EIP-712 type of the nested signature
61+
*/
62+
function decodeTypedDataSig(
63+
bytes calldata encodedSignature
64+
)
65+
internal
66+
pure
67+
returns (bytes calldata signature, bytes32 appSeparator, bytes32 contentsHash, string calldata contentsDescr)
68+
{
69+
unchecked {
70+
uint256 sigLength = encodedSignature.length;
71+
72+
if (sigLength < 4) return (_emptyCalldataBytes(), 0, 0, _emptyCalldataString());
73+
74+
uint256 contentsDescrEnd = sigLength - 2; // Last 2 bytes
75+
uint256 contentsDescrLength = uint16(bytes2(encodedSignature[contentsDescrEnd:]));
76+
77+
if (contentsDescrLength + 64 > contentsDescrEnd)
78+
return (_emptyCalldataBytes(), 0, 0, _emptyCalldataString());
79+
80+
uint256 contentsHashEnd = contentsDescrEnd - contentsDescrLength;
81+
uint256 separatorEnd = contentsHashEnd - 32;
82+
uint256 signatureEnd = separatorEnd - 32;
83+
84+
signature = encodedSignature[:signatureEnd];
85+
appSeparator = bytes32(encodedSignature[signatureEnd:separatorEnd]);
86+
contentsHash = bytes32(encodedSignature[separatorEnd:contentsHashEnd]);
87+
contentsDescr = string(encodedSignature[contentsHashEnd:contentsDescrEnd]);
88+
}
89+
}
90+
91+
/**
92+
* @dev Nests an `ERC-191` digest into a `PersonalSign` EIP-712 struct, and return the corresponding struct hash.
93+
* This struct hash must be combined with a domain separator, using {MessageHashUtils-toTypedDataHash} before
94+
* being verified/recovered.
95+
*
96+
* This is used to simulates the `personal_sign` RPC method in the context of smart contracts.
97+
*/
98+
function personalSignStructHash(bytes32 contents) internal pure returns (bytes32) {
99+
return keccak256(abi.encode(PERSONAL_SIGN_TYPEHASH, contents));
100+
}
101+
102+
/**
103+
* @dev Nests an `EIP-712` hash (`contents`) into a `TypedDataSign` EIP-712 struct, and return the corresponding
104+
* struct hash. This struct hash must be combined with a domain separator, using {MessageHashUtils-toTypedDataHash}
105+
* before being verified/recovered.
106+
*/
107+
function typedDataSignStructHash(
108+
string calldata contentsTypeName,
109+
string calldata contentsType,
110+
bytes32 contentsHash,
111+
bytes memory domainBytes
112+
) internal pure returns (bytes32 result) {
113+
return
114+
bytes(contentsTypeName).length == 0
115+
? bytes32(0)
116+
: keccak256(
117+
abi.encodePacked(typedDataSignTypehash(contentsTypeName, contentsType), contentsHash, domainBytes)
118+
);
119+
}
120+
121+
/**
122+
* @dev Variant of {typedDataSignStructHash-string-string-bytes32-string-bytes} that takes a content descriptor
123+
* and decodes the `contentsTypeName` and `contentsType` out of it.
124+
*/
125+
function typedDataSignStructHash(
126+
string calldata contentsDescr,
127+
bytes32 contentsHash,
128+
bytes memory domainBytes
129+
) internal pure returns (bytes32 result) {
130+
(string calldata contentsTypeName, string calldata contentsType) = decodeContentsDescr(contentsDescr);
131+
132+
return typedDataSignStructHash(contentsTypeName, contentsType, contentsHash, domainBytes);
133+
}
134+
135+
/**
136+
* @dev Compute the EIP-712 typehash of the `TypedDataSign` structure for a given type (and typename).
137+
*/
138+
function typedDataSignTypehash(
139+
string calldata contentsTypeName,
140+
string calldata contentsType
141+
) internal pure returns (bytes32) {
142+
return
143+
keccak256(
144+
abi.encodePacked(
145+
"TypedDataSign(",
146+
contentsTypeName,
147+
" contents,string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)",
148+
contentsType
149+
)
150+
);
151+
}
152+
153+
/**
154+
* @dev Parse the type name out of the ERC-7739 contents type description. Supports both the implicit and explicit
155+
* modes.
156+
*
157+
* Following ERC-7739 specifications, a `contentsTypeName` is considered invalid if it's empty or it contains
158+
* any of the following bytes , )\x00
159+
*
160+
* If the `contentsType` is invalid, this returns an empty string. Otherwise, the return string has non-zero
161+
* length.
162+
*/
163+
function decodeContentsDescr(
164+
string calldata contentsDescr
165+
) internal pure returns (string calldata contentsTypeName, string calldata contentsType) {
166+
bytes calldata buffer = bytes(contentsDescr);
167+
if (buffer.length == 0) {
168+
// pass through (fail)
169+
} else if (buffer[buffer.length - 1] == bytes1(")")) {
170+
// Implicit mode: read contentsTypeName for the beginning, and keep the complete descr
171+
for (uint256 i = 0; i < buffer.length; ++i) {
172+
bytes1 current = buffer[i];
173+
if (current == bytes1("(")) {
174+
// if name is empty - passthrough (fail)
175+
if (i == 0) break;
176+
// we found the end of the contentsTypeName
177+
return (string(buffer[:i]), contentsDescr);
178+
} else if (_isForbiddenChar(current)) {
179+
// we found an invalid character (forbidden) - passthrough (fail)
180+
break;
181+
}
182+
}
183+
} else {
184+
// Explicit mode: read contentsTypeName for the end, and remove it from the descr
185+
for (uint256 i = buffer.length; i > 0; --i) {
186+
bytes1 current = buffer[i - 1];
187+
if (current == bytes1(")")) {
188+
// we found the end of the contentsTypeName
189+
return (string(buffer[i:]), string(buffer[:i]));
190+
} else if (_isForbiddenChar(current)) {
191+
// we found an invalid character (forbidden) - passthrough (fail)
192+
break;
193+
}
194+
}
195+
}
196+
return (_emptyCalldataString(), _emptyCalldataString());
197+
}
198+
199+
// slither-disable-next-line write-after-write
200+
function _emptyCalldataBytes() private pure returns (bytes calldata result) {
201+
assembly ("memory-safe") {
202+
result.offset := 0
203+
result.length := 0
204+
}
205+
}
206+
207+
// slither-disable-next-line write-after-write
208+
function _emptyCalldataString() private pure returns (string calldata result) {
209+
assembly ("memory-safe") {
210+
result.offset := 0
211+
result.length := 0
212+
}
213+
}
214+
215+
function _isForbiddenChar(bytes1 char) private pure returns (bool) {
216+
return char == 0x00 || char == bytes1(" ") || char == bytes1(",") || char == bytes1("(") || char == bytes1(")");
217+
}
218+
}

hardhat.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const { argv } = require('yargs/yargs')()
1414
require('@nomicfoundation/hardhat-chai-matchers');
1515
require('@nomicfoundation/hardhat-ethers');
1616
require('hardhat-exposed');
17-
require('solidity-coverage')
17+
require('solidity-coverage');
1818
require('./hardhat/remappings');
1919

2020
module.exports = {

hardhat/remappings.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ task(TASK_COMPILE_GET_REMAPPINGS).setAction((taskArgs, env, runSuper) =>
1111
.readFileSync('remappings.txt', 'utf-8')
1212
.split('\n')
1313
.filter(Boolean)
14-
.filter(line => !line.startsWith("#"))
14+
.filter(line => !line.startsWith('#'))
1515
.map(line => line.trim().split('=')),
1616
),
1717
),

0 commit comments

Comments
 (0)