Skip to content

Implement 7739 #140

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 52 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
6ad87b2
Copy 7739 utils
zhongeric Apr 11, 2025
af19d9d
Merge branch 'main' into nested-712
zhongeric Apr 11, 2025
f8e2234
Initial commit
zhongeric Apr 11, 2025
93c00cd
Finish implementation
zhongeric Apr 13, 2025
1e4402c
Comments and add library tests
zhongeric Apr 13, 2025
6fc3163
Working 1271 test
zhongeric Apr 13, 2025
46c6d39
Fix all 1271 tests
zhongeric Apr 13, 2025
7d32e1a
Add signature legnth check
zhongeric Apr 13, 2025
1b6c6df
natspec
zhongeric Apr 13, 2025
cd6a70e
comments
zhongeric Apr 14, 2025
420a2f9
Add isValidSignature gas tests
zhongeric Apr 14, 2025
ac2be66
Implement ffi
zhongeric Apr 14, 2025
57d9245
save work
zhongeric Apr 14, 2025
d7a7455
Add debug
zhongeric Apr 14, 2025
e63d105
save state
zhongeric Apr 14, 2025
3e04315
FFI passes
zhongeric Apr 14, 2025
2050fcb
pass all tests
zhongeric Apr 14, 2025
0be11c3
Merge branch 'main' into nested-712
zhongeric Apr 14, 2025
14b5e41
fix test
zhongeric Apr 14, 2025
23f622c
nit: remove unsued js imports
zhongeric Apr 14, 2025
97029f0
Add personal sign ffi support but maybe broken
zhongeric Apr 14, 2025
c4a0f65
Add personal sign tests
zhongeric Apr 14, 2025
4e8f640
remove salt from personal sign domain to fix ffi
zhongeric Apr 14, 2025
b8a2db8
Add implicit failing test
zhongeric Apr 15, 2025
f0b4bf0
rm console
zhongeric Apr 15, 2025
6730adb
Update ERC7739 base contract
zhongeric Apr 15, 2025
17f02c3
fix comment
zhongeric Apr 15, 2025
756a119
Implement branching for safe 1271 callers and fallback for personal sign
zhongeric Apr 15, 2025
08b9da4
Fix personal sign fallback, only do NestedPersonalSign
zhongeric Apr 16, 2025
4e5ba50
comments
zhongeric Apr 16, 2025
11be832
Add setCallerSafe functions
zhongeric Apr 16, 2025
a00ac81
Add sentinel value
zhongeric Apr 16, 2025
973cbdf
comment
zhongeric Apr 16, 2025
5b5fcfa
Comments
zhongeric Apr 17, 2025
a9da1f3
optimize 0x7739 magic value
zhongeric Apr 17, 2025
587ce1c
nit comments
zhongeric Apr 17, 2025
3857251
comments
zhongeric Apr 17, 2025
3e62772
gas
zhongeric Apr 17, 2025
d23b4ed
Add comment for personal sign hash func
zhongeric Apr 21, 2025
b143ce2
Merge branch 'dev' into nested-712
zhongeric Apr 21, 2025
61353fe
only support personal sign for ECDSA keys
zhongeric Apr 21, 2025
a8896af
check contentsType length too
zhongeric Apr 22, 2025
c147c16
Merge branch 'dev' into nested-712
zhongeric Apr 22, 2025
3b3d61c
encode length
zhongeric Apr 22, 2025
2a6bcf2
do offchain call for personal sign (#157)
snreynolds Apr 22, 2025
f38d5c4
Merge branch 'dev' into nested-712
zhongeric Apr 22, 2025
7de8aed
add test revert
zhongeric Apr 22, 2025
e32e94a
Merge branch 'dev' into nested-712
zhongeric Apr 22, 2025
ce1ccac
fix: return bool for afterIsValidSignature hook (#159)
zhongeric Apr 22, 2025
807d2fe
feedback
zhongeric Apr 22, 2025
e004269
Merge branch 'nested-712' of github.com:Uniswap/minimal-delegation in…
zhongeric Apr 22, 2025
3ccab25
fix foundry toml and add natspec
zhongeric Apr 22, 2025
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
12 changes: 6 additions & 6 deletions snapshots/MinimalDelegationExecuteTest.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"execute_BATCHED_CALL_SUPPORTS_OPDATA_singleCall": "91363",
"execute_BATCHED_CALL_SUPPORTS_OPDATA_twoCalls": "127803",
"execute_BATCHED_CALL_opData_P256_singleCall": "116677",
"execute_BATCHED_CALL_opData_singleCall": "91363",
"execute_BATCHED_CALL_opData_singleCall_native": "92314",
"execute_BATCHED_CALL_opData_twoCalls": "127803",
"execute_BATCHED_CALL_SUPPORTS_OPDATA_singleCall": "91247",
"execute_BATCHED_CALL_SUPPORTS_OPDATA_twoCalls": "127687",
"execute_BATCHED_CALL_opData_P256_singleCall": "116561",
"execute_BATCHED_CALL_opData_singleCall": "91247",
"execute_BATCHED_CALL_opData_singleCall_native": "92198",
"execute_BATCHED_CALL_opData_twoCalls": "127687",
"execute_BATCHED_CALL_singleCall": "59296",
"execute_BATCHED_CALL_singleCall_native": "59465",
"execute_BATCHED_CALL_twoCalls": "94928",
Expand Down
2 changes: 1 addition & 1 deletion snapshots/MinimalDelegationTest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"minimalDelegationEntry bytecode size": "21361",
"minimalDelegationEntry bytecode size": "22910",
"register": "184718",
"revoke": "54502",
"validateUserOp_missingAccountFunds": "63831",
Expand Down
32 changes: 28 additions & 4 deletions src/EIP712.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
pragma solidity ^0.8.23;

import {IERC5267} from "@openzeppelin/contracts/interfaces/IERC5267.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {IEIP712} from "./interfaces/IEIP712.sol";

/// @title EIP712
Expand Down Expand Up @@ -47,6 +48,23 @@ contract EIP712 is IEIP712, IERC5267 {
bytes32 salt,
uint256[] memory extensions
)
{
(fields, name, version, chainId, verifyingContract, salt, extensions) = _eip712Domain();
}

/// @notice Internal eip712Domain implementation for use in ERC7739
function _eip712Domain()
internal
view
returns (
bytes1 fields,
string memory name,
string memory version,
uint256 chainId,
address verifyingContract,
bytes32 salt,
uint256[] memory extensions
)
{
fields = hex"0f"; // `0b1111`.
(name, version) = _domainNameAndVersion();
Expand All @@ -59,13 +77,19 @@ contract EIP712 is IEIP712, IERC5267 {
/// @notice Returns the `domainSeparator` used to create EIP-712 compliant hashes.
/// @return The 32 bytes domain separator result.
function domainSeparator() public view returns (bytes32) {
return _domainSeparator();
}

/// @notice Internal domainSeparator implementation for use in ERC7739
function _domainSeparator() internal view returns (bytes32) {
return
keccak256(abi.encode(_DOMAIN_TYPEHASH, _cachedNameHash, _cachedVersionHash, block.chainid, address(this)));
}

/// @notice Public getter for `_hashTypedData()` to produce a replay-safe hash from the given `hash`.
/// @param hash The nested typed data hash as defined by EIP-712. Assumes the hash is the result of applying EIP-712 hashStruct.
/// @return The corresponding replay-safe hash.
/// @notice Public getter for `_hashTypedData()` to produce a EIP-712 hash using this account's domain separator
/// @dev This is meant to be used for internal verification of SignedBatchedCalls and thus does not produce signatures that are replay-safe.
/// See ERC7739 for a version that is replay-safe and can be used for external verification through ERC-1271.
/// @param hash The nested typed data. Assumes the hash is the result of applying EIP-712 hashStruct.
function hashTypedData(bytes32 hash) public view virtual returns (bytes32) {
return _hashTypedData(hash);
}
Expand All @@ -74,7 +98,7 @@ contract EIP712 is IEIP712, IERC5267 {
/// @param hash The nested typed data hash as defined by EIP-712. Assumes the hash is already compliant with EIP-712.
/// @return The resulting EIP-712 hash.
function _hashTypedData(bytes32 hash) internal view returns (bytes32) {
return keccak256(abi.encodePacked("\x19\x01", domainSeparator(), hash));
return MessageHashUtils.toTypedDataHash(_domainSeparator(), hash);
}

/// @notice Returns the domain name and version to use when creating EIP-712 signatures.
Expand Down
94 changes: 94 additions & 0 deletions src/ERC7739.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {ERC7739Utils} from "./libraries/ERC7739Utils.sol";
import {EIP712} from "./EIP712.sol";
import {Key, KeyLib} from "./libraries/KeyLib.sol";
import {TypedDataSignLib} from "./libraries/TypedDataSignLib.sol";
import {PersonalSignLib} from "./libraries/PersonalSignLib.sol";

/// @title ERC7739
/// @notice An abstract contract that implements the ERC-7739 standard
/// @notice This contract assumes that all data verified through ERC-1271 `isValidSignature` implements the defensive nested hashing scheme defined in EIP-7739
/// @dev See https://eips.ethereum.org/EIPS/eip-7739
abstract contract ERC7739 is EIP712 {
using ERC7739Utils for *;
using KeyLib for Key;

/// @notice Hash a PersonalSign struct with the app's domain separator to produce an EIP-712 compatible hash
/// @dev Uses this account's domain separator in the EIP-712 hash for replay protection
/// @param hash The hashed message, done offchain
/// @return The PersonalSign nested EIP-712 hash of the message
function _getPersonalSignTypedDataHash(bytes32 hash) private view returns (bytes32) {
return MessageHashUtils.toTypedDataHash(_domainSeparator(), PersonalSignLib.hash(hash));
}

/// @notice Hash TypedDataSign with the app's domain separator to produce an EIP-712 compatible hash
/// @dev Includes this account's domain in the hash for replay protection
/// @param contentsName The top level type, per EIP-712
/// @param contentsType The full type string of the contents, per EIP-712
/// @param contentsHash The hash of the contents, per EIP-712
function _getNestedTypedDataSignHash(
bytes32 appSeparator,
string memory contentsName,
string memory contentsType,
bytes32 contentsHash
) private view returns (bytes32) {
// _eip712Domain().fields and _eip712Domain().extensions are not used
(, string memory name, string memory version, uint256 chainId, address verifyingContract, bytes32 salt,) =
_eip712Domain();
bytes memory domainBytes =
abi.encode(keccak256(bytes(name)), keccak256(bytes(version)), chainId, verifyingContract, salt);
return MessageHashUtils.toTypedDataHash(
appSeparator, TypedDataSignLib.hash(contentsName, contentsType, contentsHash, domainBytes)
);
}

/// @notice Verifies that the claimed contentsHash hashed with the app's separator matches the isValidSignature provided data
/// @dev This is a necessary check to ensure that the caller provided contentsHash is correct
/// @param appSeparator The app's domain separator
/// @param hash The data provided in `isValidSignature`
/// @param contentsHash The hash of the contents, i.e. hashStruct(contents)
function _callerHashMatchesReconstructedHash(bytes32 appSeparator, bytes32 hash, bytes32 contentsHash)
private
pure
returns (bool)
{
return hash == MessageHashUtils.toTypedDataHash(appSeparator, contentsHash);
}

/// @notice Decodes the data for TypedDataSign and verifies the signature against the key over the hash
/// @dev Performs the required checks per the ERC-7739 spec:
/// - contentsDescr is not empty
/// - contentsName is not empty
/// - The reconstructed hash mathches the hash passed in via isValidSignature
function _isValidTypedDataSig(Key memory key, bytes32 hash, bytes memory wrappedSignature)
internal
view
returns (bool)
{
(bytes memory signature, bytes32 appSeparator, bytes32 contentsHash, string memory contentsDescr) =
abi.decode(wrappedSignature, (bytes, bytes32, bytes32, string));

if (bytes(contentsDescr).length == 0) return false;
Copy link
Member

Choose a reason for hiding this comment

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

why does the spec require sending in the contentsDescr length?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think because it assumes you are decoding in calldata, but can double check

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yea the spec actually doesn't say what its used for at all


(string memory contentsName, string memory contentsType) = ERC7739Utils.decodeContentsDescr(contentsDescr);

if (bytes(contentsName).length == 0) return false;
Copy link
Member

Choose a reason for hiding this comment

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

do we not need to length check the type as well?

Copy link
Member

Choose a reason for hiding this comment

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

Could these length checks be all done inside .decodeContentsDescr() ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, I have that implemented in #146

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ok I changed this to check both, we don't need to check contentsDescr however because that's done in decodeContentsDescr


if (!_callerHashMatchesReconstructedHash(appSeparator, hash, contentsHash)) return false;

bytes32 digest = _getNestedTypedDataSignHash(appSeparator, contentsName, contentsType, contentsHash);
return key.verify(digest, signature);
}

/// @notice Verifies a personal sign signature against the key over the hash
function _isValidNestedPersonalSignature(Key memory key, bytes32 hash, bytes memory signature)
internal
view
returns (bool)
{
return key.verify(_getPersonalSignTypedDataHash(hash), signature);
}
}
25 changes: 18 additions & 7 deletions src/MinimalDelegation.sol
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {NonceManager} from "./NonceManager.sol";
import {IAccount} from "account-abstraction/interfaces/IAccount.sol";
import {ERC4337Account} from "./ERC4337Account.sol";
import {IERC4337Account} from "./interfaces/IERC4337Account.sol";
import {WrappedDataHash} from "./libraries/WrappedDataHash.sol";
import {ERC7914} from "./ERC7914.sol";
import {SignedBatchedCallLib, SignedBatchedCall} from "./libraries/SignedBatchedCallLib.sol";
import {BatchedCallLib, BatchedCall} from "./libraries/BatchedCallLib.sol";
Expand All @@ -31,29 +30,31 @@ import {Settings, SettingsLib} from "./libraries/SettingsLib.sol";
import {Static} from "./libraries/Static.sol";
import {ERC7821} from "./ERC7821.sol";
import {IERC7821} from "./interfaces/IERC7821.sol";
import {ERC7739} from "./ERC7739.sol";
import {ERC7739Utils} from "./libraries/ERC7739Utils.sol";

contract MinimalDelegation is
IMinimalDelegation,
ERC7821,
ERC1271,
EIP712,
ERC4337Account,
Receiver,
KeyManagement,
NonceManager,
ERC7914,
ERC7201
ERC7201,
ERC7739
{
using ModeDecoder for bytes32;
using KeyLib for Key;
using EnumerableSetLib for EnumerableSetLib.Bytes32Set;
using CalldataDecoder for bytes;
using WrappedDataHash for bytes32;
using CallLib for Call[];
using BatchedCallLib for BatchedCall;
using SignedBatchedCallLib for SignedBatchedCall;
using HooksLib for IHook;
using SettingsLib for Settings;
using ERC7739Utils for bytes;

function execute(BatchedCall memory batchedCall) public payable onlyThis {
_dispatch(batchedCall, KeyLib.ROOT_KEY_HASH);
Expand Down Expand Up @@ -174,17 +175,27 @@ contract MinimalDelegation is
}

/// @inheritdoc ERC1271
/// @dev WrappedSignature is used for both NestedTypedDataSign and NestedPersonalSign signatures.
/// TypedDataSign signatures are of the form: abi.encode(bytes32, bytes(`signature ‖ APP_DOMAIN_SEPARATOR ‖ contentsHash ‖ contentsDescr)`))
function isValidSignature(bytes32 data, bytes calldata wrappedSignature)
public
view
override(ERC1271, IERC1271)
returns (bytes4 result)
{
(bytes32 keyHash, bytes memory signature) = abi.decode(wrappedSignature, (bytes32, bytes));
bytes32 digest = _hashTypedData(data.hashWithWrappedType());

Key memory key = getKey(keyHash);
bool isValid = key.verify(digest, signature);

// Must be branched because we do abi decoding in memory which will throw since the ecnoding schemes are different
// Early check for personal signature which must be length 64 or 65 (k1 or r1 curve)
bool isValid;
if (signature.length == 64 || signature.length == 65) {
isValid = _isValidNestedPersonalSignature(key, data, signature);
} else {
isValid = _isValidTypedDataSig(key, data, signature);
}
// Early return if the signature is invalid
if (!isValid) return _1271_INVALID_VALUE;
result = _1271_MAGIC_VALUE;

Expand All @@ -194,7 +205,7 @@ contract MinimalDelegation is
IHook hook = settings.hook();
if (hook.hasPermission(HooksLib.AFTER_IS_VALID_SIGNATURE_FLAG)) {
// Hook can override the result
result = hook.handleAfterIsValidSignature(keyHash, digest);
result = hook.handleAfterIsValidSignature(keyHash, data);
}
}
}
66 changes: 66 additions & 0 deletions src/libraries/ERC7739Utils.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {LibString} from "solady/utils/LibString.sol";

/// @title ERC7739Utils
/// @notice Modified from the original implementation at
/// https://github.com/OpenZeppelin/openzeppelin-community-contracts/blob/53f590e4f4902bee0e06e455332e3321c697ea8b/contracts/utils/cryptography/ERC7739Utils.sol
/// Changelog
/// - Use in memory strings
/// - Use Solady's LibString for memory string operations
library ERC7739Utils {
/// @notice Parse the type name out of the ERC-7739 contents type description. Supports both the implicit and explicit modes
/// @dev Returns empty strings if the contentsDescr is invalid, which must be handled by the calling function
/// @return contentsName The type name of the contents
/// @return contentsType The type description of the contents
function decodeContentsDescr(string memory contentsDescr)
internal
pure
returns (string memory contentsName, string memory contentsType)
{
bytes memory buffer = bytes(contentsDescr);
if (buffer.length == 0) {
// pass through (fail)
} else if (buffer[buffer.length - 1] == bytes1(")")) {
// Implicit mode: read contentsName from the beginning, and keep the complete descr
for (uint256 i = 0; i < buffer.length; ++i) {
bytes1 current = buffer[i];
if (current == bytes1("(")) {
// if name is empty - passthrough (fail)
if (i == 0) break;
// we found the end of the contentsName
contentsName = LibString.slice(contentsDescr, 0, i);
contentsType = contentsDescr;
return (contentsName, contentsType);
} else if (_isForbiddenChar(current)) {
// we found an invalid character (forbidden) - passthrough (fail)
break;
}
}
} else {
// Explicit mode: read contentsName from the end, and remove it from the descr
for (uint256 i = buffer.length; i > 0; --i) {
bytes1 current = buffer[i - 1];
if (current == bytes1(")")) {
// we found the end of the contentsName
contentsName = LibString.slice(contentsDescr, i, buffer.length);
contentsType = LibString.slice(contentsDescr, 0, i);
return (contentsName, contentsType);
} else if (_isForbiddenChar(current)) {
// we found an invalid character (forbidden) - passthrough (fail)
break;
}
}
}
return ("", "");
}

/// @notice Perform onchain sanitization of contentsName as defined by the ERC-7739 spec
/// @dev Following ERC-7739 specifications, a `contentsName` is considered invalid if it's empty or it contains
/// any of the following bytes: ", )\x00"
function _isForbiddenChar(bytes1 char) private pure returns (bool) {
return char == 0x00 || char == bytes1(" ") || char == bytes1(",") || char == bytes1("(") || char == bytes1(")");
}
}
13 changes: 13 additions & 0 deletions src/libraries/PersonalSignLib.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

library PersonalSignLib {
bytes private constant PERSONAL_SIGN_TYPE = "PersonalSign(bytes prefixed)";
bytes32 private constant PERSONAL_SIGN_TYPEHASH = keccak256(PERSONAL_SIGN_TYPE);

/// @notice We don't care how the hash was computed for personal sign, and it does not match the typestring above
/// i.e. keccak256("\x19Ethereum Signed Message:\n" || len(someMessage) || someMessage)
function hash(bytes32 message) internal pure returns (bytes32) {
return keccak256(abi.encode(PERSONAL_SIGN_TYPEHASH, message));
}
}
43 changes: 43 additions & 0 deletions src/libraries/TypedDataSignLib.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

/// @title TypedDataSignLib
/// @notice Library supporting nesting of EIP-712 typed data signatures
/// Follows ERC-7739 spec
library TypedDataSignLib {
/// @dev Generate the dynamic type string for the TypedDataSign struct
function _toTypedDataSignTypeString(string memory contentsName, string memory contentsType)
internal
pure
returns (string memory)
{
return string(
abi.encodePacked(
"TypedDataSign(",
contentsName,
" contents,string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)",
contentsType
)
);
}

/// @dev Create the type hash for a TypedDataSign struct
function _toTypedDataSignTypeHash(string memory contentsName, string memory contentsType)
internal
pure
returns (bytes32)
{
return keccak256(abi.encodePacked(_toTypedDataSignTypeString(contentsName, contentsType)));
}

/// @notice contentsName and contentsType MUST be checked for length before hashing
/// @dev domainBytes is abi.encodePacked(name, version, chainId, verifyingContract, salt)
function hash(
string memory contentsName,
string memory contentsType,
bytes32 contentsHash,
bytes memory domainBytes
) internal pure returns (bytes32) {
return keccak256(abi.encode(_toTypedDataSignTypeHash(contentsName, contentsType), contentsHash, domainBytes));
}
}
Loading
Loading