-
Notifications
You must be signed in to change notification settings - Fork 48
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
WIP: EIP-4337 Support through hooks #178
Open
ScreamingHawk
wants to merge
5
commits into
master
Choose a base branch
from
eip-4337
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e003d71
EIP-4337 hook first pass
ScreamingHawk 0fef464
Nicer errors
ScreamingHawk ce7c2de
Correct error handling
ScreamingHawk 8cdc096
Ignore nonce validation. Assumed correct by entrypoint
ScreamingHawk ce8904b
Use call data directly
ScreamingHawk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
// SPDX-License-Identifier: Apache-2.0 | ||
pragma solidity 0.8.18; | ||
|
||
import {IEIP4337Hook, IAccount} from './interfaces/IEIP4337Hook.sol'; | ||
import {IERC1271Wallet} from '../interfaces/IERC1271Wallet.sol'; | ||
import {IModuleCalls} from '../modules/commons/interfaces/IModuleCalls.sol'; | ||
import {ModuleNonce} from '../modules/commons/ModuleNonce.sol'; | ||
import {LibOptim} from '../utils/LibOptim.sol'; | ||
|
||
contract EIP4337Hook is IEIP4337Hook, ModuleNonce { | ||
address public immutable entrypoint; | ||
uint256 private constant SIG_VALIDATION_FAILED = 1; | ||
bytes4 private constant ERC1271_SELECTOR = 0x1626ba7e; | ||
|
||
/** | ||
* Create the EIP-4337 hook for the given entrypoint. | ||
*/ | ||
constructor(address _entrypoint) { | ||
entrypoint = _entrypoint; | ||
} | ||
|
||
modifier onlyEntrypoint() { | ||
if (msg.sender != entrypoint) { | ||
revert InvalidCaller(); | ||
} | ||
_; | ||
} | ||
|
||
/** | ||
* Allow the EIP-4337 entrypoint to execute a transaction on the wallet. | ||
* @dev This function does not validate as the entrypoint is trusted to have called validateUserOp. | ||
* @notice This functions is only callable by the Entrypoint. | ||
*/ | ||
function eip4337SelfExecute(IModuleCalls.Transaction[] calldata txs) external payable onlyEntrypoint { | ||
// Self execute | ||
(bool success, ) = payable(address(this)).call{value: msg.value}( | ||
abi.encodeWithSelector(IModuleCalls.selfExecute.selector, txs) | ||
); | ||
if (!success) { | ||
// Bubble up revert reason | ||
bytes memory reason = LibOptim.returnData(); | ||
assembly { | ||
revert(add(reason, 0x20), mload(reason)) | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Validate and pay for user op. | ||
* @dev This must be called by the entrypoint. | ||
* @dev GAS opcode is banned during this call, thus max uint256 is used. | ||
*/ | ||
function validateUserOp( | ||
IAccount.UserOperation calldata userOp, | ||
bytes32 userOpHash, | ||
uint256 missingAccountFunds | ||
) external onlyEntrypoint returns (uint256 validationData) { | ||
// Check nonce. | ||
// Note Sequence space encoding is diff to EIP-4337 encoding. | ||
_validateNonce(userOp.nonce); | ||
ScreamingHawk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// Check signature | ||
bytes32 ethHash = keccak256(abi.encodePacked('\x19Ethereum Signed Message:\n32', userOpHash)); | ||
// solhint-disable-next-line avoid-low-level-calls | ||
(bool sigSuccess, bytes memory data) = address(this).call{gas: type(uint256).max}( | ||
abi.encodeWithSelector(ERC1271_SELECTOR, ethHash, userOp.signature) | ||
); | ||
if (!sigSuccess || bytes4(data) != ERC1271_SELECTOR) { | ||
// Failed to validate signature | ||
return SIG_VALIDATION_FAILED; | ||
} | ||
|
||
// Pay entrypoint | ||
if (missingAccountFunds != 0) { | ||
(bool success, ) = payable(msg.sender).call{value: missingAccountFunds, gas: type(uint256).max}(''); | ||
(success); | ||
} | ||
|
||
// Success | ||
return 0; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
// SPDX-License-Identifier: Apache-2.0 | ||
pragma solidity 0.8.18; | ||
|
||
interface IAccount { | ||
struct UserOperation { | ||
address sender; | ||
uint256 nonce; | ||
bytes initCode; | ||
bytes callData; | ||
uint256 callGasLimit; | ||
uint256 verificationGasLimit; | ||
uint256 preVerificationGas; | ||
uint256 maxFeePerGas; | ||
uint256 maxPriorityFeePerGas; | ||
bytes paymasterAndData; | ||
bytes signature; | ||
} | ||
|
||
function validateUserOp( | ||
UserOperation calldata userOp, | ||
bytes32 userOpHash, | ||
uint256 missingAccountFunds | ||
) external returns (uint256 validationData); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
// SPDX-License-Identifier: Apache-2.0 | ||
pragma solidity 0.8.18; | ||
|
||
import "../../modules/commons/interfaces/IModuleCalls.sol"; | ||
import "./IAccount.sol"; | ||
|
||
interface IEIP4337HookErrors { | ||
|
||
// Thrown when not called by the entrypoint. | ||
error InvalidCaller(); | ||
|
||
} | ||
|
||
/** | ||
* An extension to EIP-4337 that includes a self execute function. | ||
*/ | ||
interface IEIP4337Hook is IAccount, IEIP4337HookErrors { | ||
|
||
/** | ||
* @notice Allow wallet owner to execute an action. | ||
* @param _txs Transactions to process | ||
* @notice This functions is only callable by the Entrypoint. | ||
*/ | ||
function eip4337SelfExecute( | ||
IModuleCalls.Transaction[] calldata _txs | ||
) external payable; | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,226 @@ | ||
// SPDX-License-Identifier: Apache-2.0 | ||
pragma solidity 0.8.18; | ||
|
||
import 'contracts/hooks/EIP4337Hook.sol'; | ||
import 'contracts/hooks/interfaces/IEIP4337Hook.sol'; | ||
import 'contracts/modules/commons/ModuleAuth.sol'; | ||
import 'contracts/modules/commons/ModuleCalls.sol'; | ||
import 'contracts/modules/commons/ModuleHooks.sol'; | ||
import 'contracts/modules/MainModule.sol'; | ||
import 'contracts/modules/MainModuleUpgradable.sol'; | ||
import 'contracts/Factory.sol'; | ||
|
||
import 'foundry_test/base/AdvTest.sol'; | ||
|
||
contract MockModules is ModuleAuth, ModuleCalls, ModuleHooks { | ||
function validateNonce(uint256 _rawNonce) external { | ||
_validateNonce(_rawNonce); | ||
} | ||
|
||
function writeNonce(uint256 _space, uint256 _nonce) external { | ||
_writeNonce(_space, _nonce); | ||
} | ||
|
||
// Module Auth imp | ||
mapping(bytes32 => mapping(bytes => bytes32)) public sigToSubdigest; | ||
mapping(bytes32 => mapping(bytes => bool)) public sigToIsValid; | ||
|
||
function _signatureValidation( | ||
bytes32 _digest, | ||
bytes calldata _signature | ||
) internal view override(IModuleAuth, ModuleAuth) returns (bool isValid, bytes32 subdigest) { | ||
subdigest = sigToSubdigest[_digest][_signature]; | ||
isValid = sigToIsValid[_digest][_signature]; | ||
} | ||
|
||
function mockSignature(bytes32 _digest, bytes calldata _signature, bytes32 _subdigest, bool _isValid) external { | ||
sigToSubdigest[_digest][_signature] = _subdigest; | ||
sigToIsValid[_digest][_signature] = _isValid; | ||
} | ||
|
||
// solhint-disable no-empty-blocks | ||
function _isValidImage(bytes32) internal view override returns (bool) {} | ||
|
||
function _updateImageHash(bytes32) internal override {} | ||
|
||
// solhint-enable no-empty-blocks | ||
|
||
function supportsInterface( | ||
bytes4 _interfaceID | ||
) public pure virtual override(ModuleAuth, ModuleCalls, ModuleHooks) returns (bool) { | ||
return | ||
ModuleAuth.supportsInterface(_interfaceID) || | ||
ModuleCalls.supportsInterface(_interfaceID) || | ||
ModuleHooks.supportsInterface(_interfaceID); | ||
} | ||
} | ||
|
||
contract EIP4337HookTest is AdvTest, IEIP4337HookErrors { | ||
MockModules private walletMod; | ||
EIP4337Hook private wallet; | ||
|
||
address private constant ENTRYPOINT = address(uint160(uint256(keccak256('entrypoint')))); | ||
|
||
function setUp() external { | ||
Factory factory = new Factory(); | ||
ModuleHooks template = new MockModules(); | ||
walletMod = MockModules(payable(factory.deploy(address(template), bytes32(0)))); | ||
EIP4337Hook hook = new EIP4337Hook(ENTRYPOINT); | ||
|
||
// Add hooks | ||
vm.startPrank(address(walletMod)); | ||
walletMod.addHook(IAccount.validateUserOp.selector, address(hook)); | ||
walletMod.addHook(IEIP4337Hook.eip4337SelfExecute.selector, address(hook)); | ||
vm.stopPrank(); | ||
|
||
wallet = EIP4337Hook(address(walletMod)); | ||
vm.label(address(wallet), 'wallet'); | ||
} | ||
|
||
struct ToVal { | ||
address target; | ||
uint256 value; | ||
} | ||
|
||
// | ||
// Execute | ||
// | ||
|
||
function test_4337execute_invalidCaller(ToVal memory sendTx, address sender) external { | ||
vm.assume(sender != ENTRYPOINT); | ||
IModuleCalls.Transaction[] memory txs = new IModuleCalls.Transaction[](1); | ||
txs[0] = IModuleCalls.Transaction({ | ||
delegateCall: false, | ||
revertOnError: false, | ||
gasLimit: 0, | ||
target: sendTx.target, | ||
value: sendTx.value, | ||
data: '' | ||
}); | ||
|
||
vm.expectRevert(InvalidCaller.selector); | ||
vm.prank(sender); | ||
wallet.eip4337SelfExecute(txs); | ||
} | ||
|
||
function test_4337execute_sendEth(ToVal memory sendTx) external { | ||
_assumeSafeAddress(sendTx.target); | ||
|
||
// Give wallet exact funds | ||
vm.deal(address(wallet), sendTx.value); | ||
|
||
IModuleCalls.Transaction[] memory txs = new IModuleCalls.Transaction[](1); | ||
txs[0] = IModuleCalls.Transaction({ | ||
delegateCall: false, | ||
revertOnError: true, | ||
gasLimit: 0, | ||
target: sendTx.target, | ||
value: sendTx.value, | ||
data: '' | ||
}); | ||
|
||
vm.prank(ENTRYPOINT); | ||
wallet.eip4337SelfExecute(txs); | ||
|
||
assertEq(address(wallet).balance, 0); | ||
assertEq(sendTx.target.balance, sendTx.value); | ||
} | ||
|
||
function test_4337execute_insufficientEth(ToVal memory sendTx) external { | ||
_assumeSafeAddress(sendTx.target); | ||
vm.assume(sendTx.value > 0); | ||
|
||
IModuleCalls.Transaction[] memory txs = new IModuleCalls.Transaction[](1); | ||
txs[0] = IModuleCalls.Transaction({ | ||
delegateCall: false, | ||
revertOnError: true, | ||
gasLimit: 0, | ||
target: sendTx.target, | ||
value: sendTx.value, | ||
data: '' | ||
}); | ||
|
||
vm.expectRevert(); | ||
vm.prank(ENTRYPOINT); | ||
wallet.eip4337SelfExecute(txs); | ||
} | ||
|
||
// | ||
// Validate | ||
// | ||
|
||
function test_4337validateUserOp_invalidCaller( | ||
IAccount.UserOperation calldata userOp, | ||
bytes32 userOpHash, | ||
uint256 missingAccountFunds | ||
) external { | ||
vm.expectRevert(InvalidCaller.selector); | ||
wallet.validateUserOp(userOp, userOpHash, missingAccountFunds); | ||
} | ||
|
||
function test_4337validateUserOp( | ||
IAccount.UserOperation calldata userOp, | ||
bytes32 userOpHash, | ||
uint256 missingAccountFunds | ||
) external { | ||
vm.assume(userOp.nonce == 0); | ||
|
||
// Give wallet enough funds | ||
vm.deal(address(wallet), missingAccountFunds); | ||
|
||
// Accept the hash | ||
bytes32 encodedHash = keccak256(abi.encodePacked('\x19Ethereum Signed Message:\n32', userOpHash)); | ||
walletMod.mockSignature(encodedHash, userOp.signature, bytes32(0), true); | ||
|
||
// Validate | ||
vm.prank(ENTRYPOINT); | ||
uint256 validationData = wallet.validateUserOp(userOp, userOpHash, missingAccountFunds); | ||
|
||
assertEq(validationData, 0); | ||
} | ||
|
||
function test_4337validateUserOp_failedSignature( | ||
IAccount.UserOperation calldata userOp, | ||
bytes32 userOpHash, | ||
uint256 missingAccountFunds | ||
) external { | ||
vm.assume(userOp.nonce == 0); | ||
|
||
// Give wallet enough funds | ||
vm.deal(address(wallet), missingAccountFunds); | ||
|
||
// Validate | ||
vm.prank(ENTRYPOINT); | ||
uint256 validationData = wallet.validateUserOp(userOp, userOpHash, missingAccountFunds); | ||
|
||
assertEq(validationData, 1); | ||
} | ||
|
||
function test_4337validateUserOp_invalidFunds( | ||
IAccount.UserOperation calldata userOp, | ||
bytes32 userOpHash, | ||
uint256 missingAccountFunds | ||
) external { | ||
vm.assume(userOp.nonce == 0); | ||
|
||
// Accept the hash | ||
bytes32 encodedHash = keccak256(abi.encodePacked('\x19Ethereum Signed Message:\n32', userOpHash)); | ||
walletMod.mockSignature(encodedHash, userOp.signature, bytes32(0), true); | ||
|
||
// Validate | ||
vm.prank(ENTRYPOINT); | ||
uint256 validationData = wallet.validateUserOp(userOp, userOpHash, missingAccountFunds); | ||
|
||
// Passes. Account doesn't validate | ||
assertEq(validationData, 0); | ||
} | ||
|
||
// | ||
// Helpers | ||
// | ||
|
||
function _assumeSafeAddress(address addr) private view { | ||
vm.assume(uint160(addr) > 20); // Non precompiled | ||
vm.assume(addr.code.length == 0); // Non contract | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI native
.call
copies return data into memory even if you don't define it. Either useLibOptim.call
or use the returned data directly.