Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
mmv08 committed Sep 29, 2023
1 parent 4f2c59c commit 853581b
Show file tree
Hide file tree
Showing 5 changed files with 214 additions and 40 deletions.
75 changes: 59 additions & 16 deletions contracts/contracts/ERC4337Plugin.sol
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ interface ISafe {
function enablePlugin(address plugin, uint8 permissions) external;

function setFunctionHandler(bytes4 selector, address functionHandler) external;

function checkSignatures(bytes32 dataHash, bytes memory, bytes memory signatures) external;
}

struct UserOperation {
Expand Down Expand Up @@ -70,32 +72,49 @@ contract ERC4337Plugin is ISafeProtocolFunctionHandler, BasePluginWithEventMetad
}

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

ISafe senderSafe = ISafe(payable(userOp.sender));
bytes32 userOpHash = getUserOpHash(userOp);
senderSafe.checkSignatures(userOpHash, "", userOp.signature);

if (missingAccountFunds != 0) {
senderSafe.execTransactionFromModule(ENTRY_POINT, missingAccountFunds, "", 0);
SafeProtocolAction[] memory actions = new SafeProtocolAction[](1);
actions[0] = SafeProtocolAction({to: ENTRY_POINT, value: missingAccountFunds, data: ""});
SAFE_PROTOCOL_MANAGER.executeTransaction(
userOp.sender,
SafeTransaction({actions: actions, nonce: 0, metadataHash: userOpHash})
);
}

return 0;
}

function execTransaction(address payable to, uint256 value, bytes calldata data) external {
function execTransaction(address safe, address payable to, uint256 value, bytes calldata data) external {
require(msg.sender == address(PLUGIN_ADDRESS));
address payable safeAddress = payable(msg.sender);
ISafe safe = ISafe(safeAddress);

require(safe.execTransactionFromModule(to, value, data, 0), "tx failed");
SafeProtocolAction[] memory actions = new SafeProtocolAction[](1);
actions[0] = SafeProtocolAction({to: to, value: value, data: data});

SAFE_PROTOCOL_MANAGER.executeTransaction(safe, SafeTransaction({actions: actions, nonce: 0, metadataHash: bytes32(0)}));
}

function handle(address safe, address sender, uint256 value, bytes calldata data) external returns (bytes memory result) {
function handle(address, address sender, uint256, bytes calldata data) external returns (bytes memory result) {
bytes4 selector = bytes4(data[0:4]);

require(sender == ENTRY_POINT, "Only entry point");
bool success;
if (selector == this.validateUserOp.selector) {
(, result) = PLUGIN_ADDRESS.call(data);
(success, result) = PLUGIN_ADDRESS.call(data);
} else if (selector == this.execTransaction.selector) {
(, result) = PLUGIN_ADDRESS.call(data);
(success, result) = PLUGIN_ADDRESS.call(data);
}

// solhint-disable-next-line no-inline-assembly
assembly {
// use assembly to avoid converting result bytes to string
if eq(success, 0) {
revert(add(result, 32), mload(result))
}
}
}

Expand All @@ -110,10 +129,6 @@ contract ERC4337Plugin is ISafeProtocolFunctionHandler, BasePluginWithEventMetad
safe.setFunctionHandler(this.execTransaction.selector, PLUGIN_ADDRESS);
}

function requireFromEntryPoint(address sender) internal view {
require(sender == ENTRY_POINT, "Only entry point");
}

function metadataProvider()
public
view
Expand All @@ -124,6 +139,34 @@ contract ERC4337Plugin is ISafeProtocolFunctionHandler, BasePluginWithEventMetad
location = abi.encode(address(this));
}

function packUserOperation(UserOperation calldata userOp) internal pure returns (bytes memory ret) {
//lighter signature scheme. must match UserOp.ts#packUserOp
bytes calldata sig = userOp.signature;
// copy directly the userOp from calldata up to (but not including) the signature.
// this encoding depends on the ABI encoding of calldata, but is much lighter to copy
// than referencing each field separately.
assembly {
let ofs := userOp
let len := sub(sub(sig.offset, ofs), 32)
ret := mload(0x40)
mstore(0x40, add(ret, add(len, 32)))
mstore(ret, len)
calldatacopy(add(ret, 32), ofs, len)
}
}

function hashUserOpStruct(UserOperation calldata userOp) internal pure returns (bytes32) {
return keccak256(packUserOperation(userOp));
}

/**
* generate a request Id - unique identifier for this request.
* the request ID is a hash over the content of the userOp (except the signature), the entrypoint and the chainid.
*/
function getUserOpHash(UserOperation calldata userOp) public view returns (bytes32) {
return keccak256(abi.encode(hashUserOpStruct(userOp), address(this), block.chainid));
}

function supportsInterface(bytes4 interfaceId) external pure override(BasePlugin, IERC165) returns (bool) {
return
interfaceId == type(ISafeProtocolPlugin).interfaceId ||
Expand Down
2 changes: 1 addition & 1 deletion contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-prettier": "^5.0.0",
"ethers": "^6.7.1",
"hardhat": "^2.17.2",
"hardhat": "^2.17.4",
"hardhat-deploy": "^0.11.37",
"hardhat-gas-reporter": "^1.0.8",
"hardhat-typechain": "^0.3.5",
Expand Down
146 changes: 127 additions & 19 deletions contracts/test/ERC4337Plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import hre, { deployments, ethers } from "hardhat";
import { expect } from "chai";
import { AbiCoder, keccak256 } from "ethers";
import { loadPluginMetadata } from "../src/utils/metadata";
import { getProtocolManagerAddress } from "../src/utils/protocol";
import { deploySafe, getSafeProxyFactoryContractFactory, getSafeSingletonContractFactory } from "./utils/safe";
import { ModuleType } from "../src/utils/constants";
import { UserOperation } from "./utils/types";
import { BigNumberish } from "ethers";

const ERC4337_TEST_ENV_VARIABLES_DEFINED =
typeof process.env.ERC4337_TEST_BUNDLER_URL !== "undefined" &&
Expand All @@ -25,51 +28,111 @@ const MNEMONIC = process.env.ERC4337_TEST_MNEMONIC;
// return bytes;
// };

const randomAddress = "0x1234567890123456789012345678901234567890";
function encode(typevalues: Array<{ type: string; val: any }>, forSignature: boolean): string {

Check failure on line 31 in contracts/test/ERC4337Plugin.spec.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
const types = typevalues.map((typevalue) => (typevalue.type === "bytes" && forSignature ? "bytes32" : typevalue.type));
const values = typevalues.map((typevalue) => (typevalue.type === "bytes" && forSignature ? keccak256(typevalue.val) : typevalue.val));
return AbiCoder.defaultAbiCoder().encode(types, values);
}

export function packUserOp(op: UserOperation, forSignature = true): string {
if (forSignature) {
// lighter signature scheme (must match UserOperation#pack): do encode a zero-length signature, but strip afterwards the appended zero-length value
const userOpType = {
components: [
{ type: "address", name: "sender" },
{ type: "uint256", name: "nonce" },
{ type: "bytes", name: "initCode" },
{ type: "bytes", name: "callData" },
{ type: "uint256", name: "callGasLimit" },
{ type: "uint256", name: "verificationGasLimit" },
{ type: "uint256", name: "preVerificationGas" },
{ type: "uint256", name: "maxFeePerGas" },
{ type: "uint256", name: "maxPriorityFeePerGas" },
{ type: "bytes", name: "paymasterAndData" },
{ type: "bytes", name: "signature" },
],
name: "userOp",
type: "tuple",
};
let encoded = AbiCoder.defaultAbiCoder().encode([userOpType as any], [{ ...op, signature: "0x" }]);

Check failure on line 57 in contracts/test/ERC4337Plugin.spec.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
// remove leading word (total length) and trailing word (zero-length signature)
encoded = "0x" + encoded.slice(66, encoded.length - 64);
return encoded;
}
const typevalues = [
{ type: "address", val: op.sender },
{ type: "uint256", val: op.nonce },
{ type: "bytes", val: op.initCode },
{ type: "bytes", val: op.callData },
{ type: "uint256", val: op.callGasLimit },
{ type: "uint256", val: op.verificationGasLimit },
{ type: "uint256", val: op.preVerificationGas },
{ type: "uint256", val: op.maxFeePerGas },
{ type: "uint256", val: op.maxPriorityFeePerGas },
{ type: "bytes", val: op.paymasterAndData },
];
if (!forSignature) {
// for the purpose of calculating gas cost, also hash signature
typevalues.push({ type: "bytes", val: op.signature });
}
return encode(typevalues, forSignature);
}

export function getUserOpHash(op: UserOperation, validator: string, chainId: BigNumberish): string {
const userOpStructHash = keccak256(packUserOp(op, true));
const enc = AbiCoder.defaultAbiCoder().encode(["bytes32", "address", "uint256"], [userOpStructHash, validator, chainId]);
return keccak256(enc);
}

describe("ERC4337 Plugin", () => {
const setup = deployments.createFixture(async ({ deployments }) => {
const signers = await hre.ethers.getSigners();
const hardhatNetworkSetup = deployments.createFixture(async ({ deployments }) => {
await deployments.fixture();
const signers = await hre.ethers.getSigners();
const entryPoint = signers[signers.length - 1];
const manager = await ethers.getContractAt("MockContract", await getProtocolManagerAddress(hre));

const testRegistryFactory = await ethers.getContractFactory("TestSafeProtocolRegistryUnrestricted");
const testRegistryDeployment = await testRegistryFactory.deploy(signers[0].address);
const safeProtocolManager = await (
await ethers.getContractFactory("SafeProtocolManager")
).deploy(signers[0].address, testRegistryDeployment.getAddress());
const erc4337Plugin = await (
await ethers.getContractFactory("ERC4337Plugin")
).deploy(safeProtocolManager.getAddress(), randomAddress);
const testRegistryDeployment = await ethers
.getContractFactory("TestSafeProtocolRegistryUnrestricted")
.then((factory) => factory.deploy(signers[0].address));
const safeProtocolManager = await ethers
.getContractFactory("SafeProtocolManager")
.then((factory) => factory.deploy(signers[0].address, testRegistryDeployment.getAddress()));
const erc4337Plugin = await ethers
.getContractFactory("ERC4337Plugin")
.then((factory) => factory.deploy(safeProtocolManager.getAddress(), entryPoint.getAddress()));

await testRegistryDeployment
.addModule(erc4337Plugin.getAddress(), ModuleType.Plugin + ModuleType.FunctionHandler)
.then((tx) => tx.wait(1));

const account = await (await ethers.getContractFactory("ExecutableMockContract")).deploy();
const proxyFactory = await (await (await getSafeProxyFactoryContractFactory()).deploy()).waitForDeployment();
const safeSingleton = await (await (await getSafeSingletonContractFactory()).deploy()).waitForDeployment();
const account = await ethers.getContractFactory("ExecutableMockContract").then((f) => f.deploy());
const proxyFactory = await getSafeProxyFactoryContractFactory().then((f) => f.deploy());
const safeSingleton = await getSafeSingletonContractFactory().then((f) => f.deploy());

const callData = erc4337Plugin.interface.encodeFunctionData("enableSafeCoreProtocolWith4337Plugin");
const safe = await deploySafe(proxyFactory, safeSingleton, [signers[0].address], 1, await erc4337Plugin.getAddress(), callData);
const safeWithPluginInterface = await ethers.getContractAt("ERC4337Plugin", safe);

return {
account,
erc4337Plugin,
manager,
signers,
safe,
entryPoint,
safeWithPluginInterface,
};
});

it("should be initialized correctly", async () => {
const { erc4337Plugin } = await setup();
const { erc4337Plugin } = await hardhatNetworkSetup();
expect(await erc4337Plugin.name()).to.be.eq("ERC4337 Plugin");
expect(await erc4337Plugin.version()).to.be.eq("1.0.0");
expect(await erc4337Plugin.permissions()).to.be.eq(1);
});

it.only("can retrieve metadata for the module", async () => {
const { erc4337Plugin } = await setup();
it("can retrieve metadata for the module", async () => {
const { erc4337Plugin } = await hardhatNetworkSetup();
expect(await loadPluginMetadata(hre, erc4337Plugin)).to.be.deep.eq({
name: "ERC4337 Plugin",
version: "1.0.0",
Expand All @@ -79,10 +142,55 @@ describe("ERC4337 Plugin", () => {
});
});

it("can deploy a safe with manager and ERC4337 module", async () => {
const { erc4337Plugin, manager, signers, safe } = await setup();
it.only("can validate a signed user operation and send the prefund", async () => {
const { erc4337Plugin, signers, safe, safeWithPluginInterface, entryPoint } = await hardhatNetworkSetup();

const userOperation: UserOperation = {
initCode: "0x",
sender: await safe.getAddress(),
nonce: 0,
callData: "0x",
callGasLimit: 0,
verificationGasLimit: 0,
preVerificationGas: 0,
maxFeePerGas: 0,
maxPriorityFeePerGas: 0,
paymasterAndData: "0x",
signature: "0x",
};
const userOpHash = await getUserOpHash(
userOperation,
await erc4337Plugin.getAddress(),
await hre.ethers.provider.getNetwork().then((n) => n.chainId),
);

const signature = await signers[0].signMessage(ethers.getBytes(userOpHash));
userOperation.signature = `0x${(BigInt(signature) + 4n).toString(16)}`;

const zeroBytes32 = `0x${"0".repeat(64)}`;
const entryPointBalanceBefore = await hre.ethers.provider.getBalance(entryPoint.address);
// prefund safe
await signers[0].sendTransaction({ to: safe, value: 1000000000000000000n });

const tx = await safeWithPluginInterface.connect(entryPoint).validateUserOp(userOperation, zeroBytes32, 1000000000000000000n);
const receipt = await tx.wait(1);

if (!receipt?.gasPrice || !receipt?.gasUsed) throw new Error("Gas price or gas used not found in receipt");

expect(await hre.ethers.provider.getBalance(entryPoint.address)).to.be.eq(
entryPointBalanceBefore + 1000000000000000000n - receipt.gasPrice * receipt.gasUsed,
);
expect(await hre.ethers.provider.getBalance(safe)).to.be.eq(0);
});

it("rejects a signed user operation if the signature is invalid", async () => {});

it("can execute a transaction coming from the entrypoint", async () => {});

it("rejects validation requests coming from an address that is not the entrypoint", async () => {});

it("rejects execution requests coming from an address that is not the entrypoint", async () => {});

/**
* 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
Expand Down
23 changes: 23 additions & 0 deletions contracts/test/utils/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// define the same export types as used by export typechain/ethers
import { AddressLike, BigNumberish, BytesLike } from "ethers";

export type address = AddressLike;
export type uint256 = BigNumberish;
export type uint = BigNumberish;
export type uint48 = BigNumberish;
export type bytes = BytesLike;
export type bytes32 = BytesLike;

export interface UserOperation {
sender: address;
nonce: uint256;
initCode: bytes;
callData: bytes;
callGasLimit: uint256;
verificationGasLimit: uint256;
preVerificationGas: uint256;
maxFeePerGas: uint256;
maxPriorityFeePerGas: uint256;
paymasterAndData: bytes;
signature: bytes;
}
8 changes: 4 additions & 4 deletions contracts/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3214,10 +3214,10 @@ hardhat-typechain@^0.3.5:
resolved "https://registry.yarnpkg.com/hardhat-typechain/-/hardhat-typechain-0.3.5.tgz#8e50616a9da348b33bd001168c8fda9c66b7b4af"
integrity sha512-w9lm8sxqTJACY+V7vijiH+NkPExnmtiQEjsV9JKD1KgMdVk2q8y+RhvU/c4B7+7b1+HylRUCxpOIvFuB3rE4+w==

hardhat@^2.17.2:
version "2.17.2"
resolved "https://registry.yarnpkg.com/hardhat/-/hardhat-2.17.2.tgz#250a8c8e76029e9bfbfb9b9abee68d5b350b5d4a"
integrity sha512-oUv40jBeHw0dKpbyQ+iH9cmNMziweLoTW3MnkNxJ2Gc0KGLrQR/1n4vV4xY60zn2LdmRgnwPqy3CgtY0mfwIIA==
hardhat@^2.17.4:
version "2.17.4"
resolved "https://registry.yarnpkg.com/hardhat/-/hardhat-2.17.4.tgz#aebaac3aea54ee02d6afa089eeb4c095e83a817c"
integrity sha512-YTyHjVc9s14CY/O7Dbtzcr/92fcz6AzhrMaj6lYsZpYPIPLzOrFCZHHPxfGQB6FiE6IPNE0uJaAbr7zGF79goA==
dependencies:
"@ethersproject/abi" "^5.1.2"
"@metamask/eth-sig-util" "^4.0.0"
Expand Down

0 comments on commit 853581b

Please sign in to comment.