Skip to content
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
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 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
82 changes: 82 additions & 0 deletions contracts/hooks/EIP4337Hook.sol
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}(
Copy link
Member

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 use LibOptim.call or use the returned data directly.

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;
}
}
24 changes: 24 additions & 0 deletions contracts/hooks/interfaces/IAccount.sol
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);
}
28 changes: 28 additions & 0 deletions contracts/hooks/interfaces/IEIP4337Hook.sol
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;

}
226 changes: 226 additions & 0 deletions foundry_test/hooks/EIP4337Hook.t.sol
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
}
}
Loading