Skip to content

Commit

Permalink
Remove gasleft in setupModules, add erc4337 compatibility test
Browse files Browse the repository at this point in the history
  • Loading branch information
mmv08 committed May 24, 2023
1 parent e870f51 commit b08d4a5
Show file tree
Hide file tree
Showing 10 changed files with 1,850 additions and 1,473 deletions.
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ INFURA_KEY=""
# Used for custom network
NODE_URL=""
ETHERSCAN_API_KEY=""
# (Optional) Used to run ERC-4337 compatibility test. MNEMONIC is also required.
ERC4337_TEST_BUNDLER_URL=
ERC4337_TEST_NODE_URL=
ERC4337_TEST_SINGLETON_ADDRESS=
ERC4337_TEST_SAFE_FACTORY_ADDRESS=
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,31 @@ Usage
yarn
```

### Run all tests:
### Testing

To run the tests:

```bash
yarn build
yarn test
```

Optionally, if you want to run the ERC-4337 compatibility test, it uses a live bundler and node, so it contains some pre-requisites:

1. Define the environment variables:

```
ERC4337_TEST_BUNDLER_URL=
ERC4337_TEST_NODE_URL=
ERC4337_TEST_SINGLETON_ADDRESS=
ERC4337_TEST_SAFE_FACTORY_ADDRESS=
MNEMONIC=
```

2. Pre-fund the executor account derived from the mnemonic with some Native Token to cover the deployment of an ERC4337 module and the pre-fund of the Safe for the test operation.

3. If necessary, edit the hardcoded base fee in `contracts/test/4337/UserOperation.sol`

### Deployments

A collection of the different Safe contract deployments and their addresses can be found in the [Safe deployments](https://github.com/safe-global/safe-deployments) repository.
Expand Down
2 changes: 1 addition & 1 deletion contracts/base/ModuleManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ abstract contract ModuleManager is SelfAuthorized, Executor {
if (to != address(0)) {
require(isContract(to), "GS002");
// Setup has to complete successfully or transaction fails.
require(execute(to, 0, data, Enum.Operation.DelegateCall, gasleft()), "GS000");
require(execute(to, 0, data, Enum.Operation.DelegateCall, type(uint256).max), "GS000");
}
}

Expand Down
69 changes: 69 additions & 0 deletions contracts/test/4337/Test4337ModuleAndHandler.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;
pragma abicoder v2;

import "../../libraries/SafeStorage.sol";
import "./UserOperation.sol";

interface ISafe {
function execTransactionFromModule(address to, uint256 value, bytes memory data, uint8 operation) external returns (bool success);
}

/// @dev A Dummy 4337 Module/Handler for testing purposes
/// @dev DO NOT USE IN PRODUCTION
contract Test4337ModuleAndHandler is SafeStorage {
using UserOperationLib for UserOperation;

address public immutable myAddress;
address public immutable entryPoint;

address internal constant SENTINEL_MODULES = address(0x1);

constructor(address entryPointAddress) {
entryPoint = entryPointAddress;
myAddress = address(this);
}

function validateUserOp(UserOperation calldata userOp, bytes32, uint256 missingAccountFunds) external returns (uint256 validationData) {
address payable safeAddress = payable(userOp.sender);
ISafe senderSafe = ISafe(safeAddress);

if (missingAccountFunds != 0) {
ISafe(senderSafe).execTransactionFromModule(entryPoint, missingAccountFunds, "", 0);
}

return 0;
}

function execTransaction(address to, uint256 value, bytes calldata data) external payable {
// we need to strip out msg.sender address appended by HandlerContext contract from the calldata
bytes memory callData;
// solhint-disable-next-line no-inline-assembly
assembly {
// Load free memory location
let pointer := mload(0x40)
// We allocate memory for the return data by setting the free memory location to
// current free memory location + data size + 32 bytes for data size value - 32 bytes for stripped msg.sender
mstore(0x40, add(pointer, calldatasize()))
// Store the size
mstore(pointer, sub(calldatasize(), 20))
// Store the data
calldatacopy(add(pointer, 0x20), 0, sub(calldatasize(), 20))
// Point the callData to the correct memory location
callData := pointer
}

address payable safeAddress = payable(msg.sender);
ISafe safe = ISafe(safeAddress);
require(safe.execTransactionFromModule(to, value, data, 0), "tx failed");
}

function enableMyself() public {
require(myAddress != address(this), "You need to DELEGATECALL, sir");

// Module cannot be added twice.
require(modules[myAddress] == address(0), "GS102");
modules[myAddress] = modules[SENTINEL_MODULES];
modules[SENTINEL_MODULES] = myAddress;
}
}
107 changes: 107 additions & 0 deletions contracts/test/4337/UserOperation.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

/* solhint-disable no-inline-assembly */

/**
* User Operation struct
* @param sender the sender account of this request.
* @param nonce unique value the sender uses to verify it is not a replay.
* @param initCode if set, the account contract will be created by this constructor/
* @param callData the method call to execute on this account.
* @param callGasLimit the gas limit passed to the callData method call.
* @param verificationGasLimit gas used for validateUserOp and validatePaymasterUserOp.
* @param preVerificationGas gas not calculated by the handleOps method, but added to the gas paid. Covers batch overhead.
* @param maxFeePerGas same as EIP-1559 gas parameter.
* @param maxPriorityFeePerGas same as EIP-1559 gas parameter.
* @param paymasterAndData if set, this field holds the paymaster address and paymaster-specific data. the paymaster will pay for the transaction instead of the sender.
* @param signature sender-verified signature over the entire request, the EntryPoint address and the chain ID.
*/
struct UserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
uint256 callGasLimit;
uint256 verificationGasLimit;
uint256 preVerificationGas;
uint256 maxFeePerGas;
uint256 maxPriorityFeePerGas;
bytes paymasterAndData;
bytes signature;
}

/**
* Utility functions helpful when working with UserOperation structs.
*/
library UserOperationLib {
function getSender(UserOperation calldata userOp) internal pure returns (address) {
address data;
//read sender from userOp, which is first userOp member (saves 800 gas...)
assembly {
data := calldataload(userOp)
}
return address(uint160(data));
}

// Solidity 0.7.6 doesn't support the BASEFEE opcode, so we hardcode it here.
function getBaseFee() public pure returns (uint256) {
return 20 gwei;
}

// relayer/block builder might submit the TX with higher priorityFee, but the user should not
// pay above what he signed for.
function gasPrice(UserOperation calldata userOp) internal pure returns (uint256) {
uint256 maxFeePerGas = userOp.maxFeePerGas;
uint256 maxPriorityFeePerGas = userOp.maxPriorityFeePerGas;
if (maxFeePerGas == maxPriorityFeePerGas) {
//legacy mode (for networks that don't support basefee opcode)
return maxFeePerGas;
}
return min(maxFeePerGas, maxPriorityFeePerGas + getBaseFee());
}

function calldataKeccak(bytes calldata data) public pure returns (bytes32 ret) {
assembly {
let mem := mload(0x40)
let len := data.length
calldatacopy(mem, data.offset, len)
ret := keccak256(mem, len)
}
}

function pack(UserOperation calldata userOp) internal pure returns (bytes memory ret) {
address sender = getSender(userOp);
uint256 nonce = userOp.nonce;
bytes32 hashInitCode = calldataKeccak(userOp.initCode);
bytes32 hashCallData = calldataKeccak(userOp.callData);
uint256 callGasLimit = userOp.callGasLimit;
uint256 verificationGasLimit = userOp.verificationGasLimit;
uint256 preVerificationGas = userOp.preVerificationGas;
uint256 maxFeePerGas = userOp.maxFeePerGas;
uint256 maxPriorityFeePerGas = userOp.maxPriorityFeePerGas;
bytes32 hashPaymasterAndData = calldataKeccak(userOp.paymasterAndData);

return
abi.encode(
sender,
nonce,
hashInitCode,
hashCallData,
callGasLimit,
verificationGasLimit,
preVerificationGas,
maxFeePerGas,
maxPriorityFeePerGas,
hashPaymasterAndData
);
}

function hash(UserOperation calldata userOp) internal pure returns (bytes32) {
return keccak256(pack(userOp));
}

function min(uint256 a, uint256 b) internal pure returns (uint256) {
return a < b ? a : b;
}
}
1 change: 1 addition & 0 deletions hardhat.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import "@nomiclabs/hardhat-ethers";
import type { HardhatUserConfig, HttpNetworkUserConfig } from "hardhat/types";
import "@nomiclabs/hardhat-etherscan";
import "@nomiclabs/hardhat-waffle";
Expand Down
133 changes: 133 additions & 0 deletions test/integration/Safe.ERC4337.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import hre from "hardhat";
import { expect } from "chai";
import { AddressZero } from "@ethersproject/constants";
import { hexConcat } from "ethers/lib/utils";
import { getFactoryContract, getSafeSingletonContract } from "../utils/setup";
import { calculateProxyAddress } from "../../src/utils/proxies";

const ERC4337_TEST_ENV_VARIABLES_DEFINED =
typeof process.env.ERC4337_TEST_BUNDLER_URL !== "undefined" &&
typeof process.env.ERC4337_TEST_NODE_URL !== "undefined" &&
typeof process.env.ERC4337_TEST_SAFE_FACTORY_ADDRESS !== "undefined" &&
typeof process.env.ERC4337_TEST_SINGLETON_ADDRESS !== "undefined" &&
typeof process.env.MNEMONIC !== "undefined";

const itif = ERC4337_TEST_ENV_VARIABLES_DEFINED ? it : it.skip;
const SAFE_FACTORY_ADDRESS = process.env.ERC4337_TEST_SAFE_FACTORY_ADDRESS;
const SINGLETON_ADDRESS = process.env.ERC4337_TEST_SINGLETON_ADDRESS;
const BUNDLER_URL = process.env.ERC4337_TEST_BUNDLER_URL;
const NODE_URL = process.env.ERC4337_TEST_NODE_URL;
const MNEMONIC = process.env.MNEMONIC;

type UserOperation = {
sender: string;
nonce: string;
initCode: string;
callData: string;
callGasLimit: string;
verificationGasLimit: string;
preVerificationGas: string;
maxFeePerGas: string;
maxPriorityFeePerGas: string;
paymasterAndData: string;
signature: string;
};

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

describe("Safe.ERC4337", () => {
const setupTests = async () => {
const factory = await getFactoryContract();
const singleton = await getSafeSingletonContract();
const bundlerProvider = new hre.ethers.providers.JsonRpcProvider(BUNDLER_URL);
const provider = new hre.ethers.providers.JsonRpcProvider(NODE_URL);
const userWallet = hre.ethers.Wallet.fromMnemonic(MNEMONIC as string).connect(provider);

const entryPoints = await bundlerProvider.send("eth_supportedEntryPoints", []);
if (entryPoints.length === 0) {
throw new Error("No entry points found");
}

return {
factory: factory.attach(SAFE_FACTORY_ADDRESS).connect(userWallet),
singleton: singleton.attach(SINGLETON_ADDRESS).connect(provider),
bundlerProvider,
provider,
userWallet,
entryPoints,
};
};

/**
* This test verifies the ERC4337 based on gas estimation for a user operation
* The user operation deploys a Safe with the ERC4337 module and a handler
* and executes a transaction, thus verifying two things:
* 1. Deployment of the Safe with the ERC4337 module and handler is possible
* 2. Executing a transaction is possible
*/
itif("should pass the ERC4337 validation", async () => {
const { singleton, factory, provider, bundlerProvider, userWallet, entryPoints } = await setupTests();
const ENTRYPOINT_ADDRESS = entryPoints[0];

const erc4337ModuleAndHandlerFactory = (await hre.ethers.getContractFactory("Test4337ModuleAndHandler")).connect(userWallet);
const erc4337ModuleAndHandler = await erc4337ModuleAndHandlerFactory.deploy(ENTRYPOINT_ADDRESS);
// The bundler uses a different node, so we need to allow it sometime to sync
await sleep(10000);

const feeData = await provider.getFeeData();
const maxFeePerGas = feeData.maxFeePerGas.toHexString();

const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas.toHexString();

const moduleInitializer = erc4337ModuleAndHandler.interface.encodeFunctionData("enableMyself", []);
const encodedInitializer = singleton.interface.encodeFunctionData("setup", [
[userWallet.address],
1,
erc4337ModuleAndHandler.address,
moduleInitializer,
erc4337ModuleAndHandler.address,
AddressZero,
0,
AddressZero,
]);
const deployedAddress = await calculateProxyAddress(factory, singleton.address, encodedInitializer, 73);

// The initCode contains 20 bytes of the factory address and the rest is the calldata to be forwarded
const initCode = hexConcat([
factory.address,
factory.interface.encodeFunctionData("createProxyWithNonce", [singleton.address, encodedInitializer, 73]),
]);
const userOpCallData = erc4337ModuleAndHandler.interface.encodeFunctionData("execTransaction", [userWallet.address, 0, 0]);

// Native tokens for the pre-fund 💸
await userWallet.sendTransaction({ to: deployedAddress, value: hre.ethers.utils.parseEther("0.001") });
// The bundler uses a different node, so we need to allow it sometime to sync
await sleep(10000);

const userOperation: UserOperation = {
sender: deployedAddress,
nonce: "0x0",
initCode,
callData: userOpCallData,
callGasLimit: "0x7A120",
verificationGasLimit: "0x7A120",
preVerificationGas: "0x186A0",
maxFeePerGas,
maxPriorityFeePerGas,
paymasterAndData: "0x",
signature: "0x",
};

const DEBUG_MESSAGE = `
Using entry point: ${ENTRYPOINT_ADDRESS}
Deployed Safe address: ${deployedAddress}
Module/Handler address: ${erc4337ModuleAndHandler.address}
User operation:
${JSON.stringify(userOperation, null, 2)}
`;
console.log(DEBUG_MESSAGE);

const estimatedGas = await bundlerProvider.send("eth_estimateUserOperationGas", [userOperation, ENTRYPOINT_ADDRESS]);
expect(estimatedGas).to.not.be.undefined;
});
});
12 changes: 12 additions & 0 deletions test/utils/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ export const getSafeSingleton = async () => {
return Safe.attach(SafeDeployment.address);
};

export const getSafeSingletonContract = async () => {
const safeSingleton = await hre.ethers.getContractFactory(safeContractUnderTest());

return safeSingleton;
};

export const getFactoryContract = async () => {
const factory = await hre.ethers.getContractFactory("SafeProxyFactory");

return factory;
};

export const getFactory = async () => {
const FactoryDeployment = await deployments.get("SafeProxyFactory");
const Factory = await hre.ethers.getContractFactory("SafeProxyFactory");
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@
"resolveJsonModule": true
},
"exclude": ["dist", "node_modules"],
"include": ["./src/index.ts", "./types"]
"include": ["./src/index.ts", "./types"],
"files": ["./hardhat.config.ts"]
}
Loading

0 comments on commit b08d4a5

Please sign in to comment.