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

Added Sample 4337 Module #131

Closed
Show file tree
Hide file tree
Changes from 2 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
11 changes: 10 additions & 1 deletion contracts/SafeProtocolManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
* @notice Mapping of a mapping what stores information about plugins that are enabled per account.
* address (module address) => address (account address) => EnabledPluginInfo
*/
mapping(address => mapping(address => PluginAccessInfo)) public enabledPlugins;
mapping(address => mapping(address => PluginAccessInfo)) internal enabledPlugins;
nlordell marked this conversation as resolved.
Show resolved Hide resolved
struct PluginAccessInfo {
uint8 permissions;
address nextPluginPointer;
Expand Down Expand Up @@ -464,4 +464,13 @@
revert MissingPluginPermission(msg.sender, requiresPermissions, permission, givenPermissions);
}
}

function transferPrefund(

Check failure on line 468 in contracts/SafeProtocolManager.sol

View workflow job for this annotation

GitHub Actions / lint

Replace ⏎········address·account,⏎········address·payable·entrypoint,⏎········uint256·missingAccountFunds⏎···· with address·account,·address·payable·entrypoint,·uint256·missingAccountFunds
address account,

Check failure on line 469 in contracts/SafeProtocolManager.sol

View workflow job for this annotation

GitHub Actions / lint

Replace ⏎········address·account,⏎········address·payable·entrypoint,⏎········uint256·missingAccountFunds⏎···· with address·account,·address·payable·entrypoint,·uint256·missingAccountFunds
address payable entrypoint,
uint256 missingAccountFunds
) external override {
require(functionHandlers[hex"3a871cdd"][account] == msg.sender);
Copy link
Member

Choose a reason for hiding this comment

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

whats 0x3a871cdd?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It is the selector of validateUserOp. This is veeeeery hacky (I talk about it in my findings I documented in #125 (comment)).

This is needed because doing the prefund transfer from a plugin requires a lookup in the registry which is not allowed for two reasons:

  1. 4337 bundlers do not accept such user operations
  2. The Safe{Core} Protocol spec forbids it 😛

I left a note in the finding to clean this up

IAccount(account).execTransactionFromModule(entrypoint, missingAccountFunds, "", 0);
}
}
2 changes: 1 addition & 1 deletion contracts/base/FunctionHandlerManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ abstract contract FunctionHandlerManager is RegistryManager {
// Storage
/** @dev Mapping that stores information about an account, function selector, and address of the account.
*/
mapping(bytes4 => mapping(address => address)) public functionHandlers;
mapping(bytes4 => mapping(address => address)) internal functionHandlers;

// Events
event FunctionHandlerChanged(address indexed account, bytes4 indexed selector, address indexed functionHandler);
Expand Down
2 changes: 1 addition & 1 deletion contracts/base/HooksManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {OnlyAccountCallable} from "./OnlyAccountCallable.sol";
import {MODULE_TYPE_HOOKS} from "../common/Constants.sol";

abstract contract HooksManager is RegistryManager {
mapping(address => address) public enabledHooks;
mapping(address => address) internal enabledHooks;

struct TempHooksInfo {
address hooksAddress;
Expand Down
2 changes: 2 additions & 0 deletions contracts/interfaces/Manager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ interface ISafeProtocolManager {
* in case of succcessful execution. Empty if the call failed.
*/
function executeRootAccess(address account, SafeRootAccess calldata rootAccess) external returns (bytes memory data);

function transferPrefund(address account, address payable entrypoint, uint256 missingAccountFunds) external;
}

interface ISafeProtocolSignatureValidatorManager {
Expand Down
132 changes: 132 additions & 0 deletions contracts/modules/erc4337/SafeProtocol4337Module.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity ^0.8.18;

import {SafeProtocolAction, SafeTransaction} from "../../DataTypes.sol";
import {PLUGIN_PERMISSION_EXECUTE_CALL} from "../../common/Constants.sol";
import {IAccount} from "../../interfaces/Accounts.sol";
import {ISafeProtocolManager} from "../../interfaces/Manager.sol";
import {IERC165, ISafeProtocolFunctionHandler, ISafeProtocolPlugin} from "../../interfaces/Modules.sol";
import {UserOperation} from "./interfaces/IERC4337.sol";
import {ISafeProtocol4337Handler} from "./interfaces/ISafeProtocol4337Handler.sol";

contract SafeProtocol4337Module is ISafeProtocolFunctionHandler, ISafeProtocolPlugin {
uint256 private constant VALIDATION_SIG_SUCCESS = 0;
uint256 private constant VALIDATION_SIG_FAILURE = 0;

address payable public immutable entrypoint;

constructor(address payable _entrypoint) {
require(_entrypoint != address(0), "invalid entrypoint address");
entrypoint = _entrypoint;
}

/**
* @inheritdoc IERC165
*/
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
return
interfaceId == type(IERC165).interfaceId ||
interfaceId == type(ISafeProtocolFunctionHandler).interfaceId ||
interfaceId == type(ISafeProtocolPlugin).interfaceId;
}

/**
* @inheritdoc ISafeProtocolFunctionHandler
*/
function handle(address account, address sender, uint256 value, bytes calldata data) external override returns (bytes memory result) {
require(sender == entrypoint, "unsupported entrypoint");
require(value == 0, "not payable");
Copy link
Member

Choose a reason for hiding this comment

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

This value variable is unused; does it make sense to omit the check and remove the argument?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is part of the interface of the ISafeProtocolFunctionHandler. I think we could change it to not have a value, but that would be strange to me:

  1. I think fallbackhandlers should be able to emulate non-payable functions (so revert on value != 0
  2. payable fallback handlers should be able to read the amount of Ether that was sent with the transaction to the account.

I would also argue this is a bit out of the scope of this PR.


ISafeProtocolManager manager = ISafeProtocolManager(msg.sender);
bytes4 selector = bytes4(data[:4]);
if (selector == ISafeProtocol4337Handler(account).validateUserOp.selector) {
(UserOperation memory userOp, bytes32 userOpHash, uint256 missingAccountFunds) = abi.decode(
data[4:],
(UserOperation, bytes32, uint256)
);
uint256 validationData = _validateUserOp(manager, account, userOp, userOpHash, missingAccountFunds);
result = abi.encode(validationData);
} else if (selector == ISafeProtocol4337Handler(account).executeUserOp.selector) {
(address to, uint256 opValue, bytes memory opData) = abi.decode(data[4:], (address, uint256, bytes));
_executeUserOp(manager, account, to, opValue, opData);
}
}

/**
* Validate account operation.
* @param manager the protocol manager.
* @param account the account.
* @param userOp the operation that is about to be executed.
* @param missingAccountFunds missing funds on the account's deposit in the entrypoint.
* @return validationData packaged validation data.
*/
function _validateUserOp(
ISafeProtocolManager manager,
address account,
UserOperation memory userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) internal returns (uint256 validationData) {
require(bytes4(userOp.callData) == ISafeProtocol4337Handler(account).executeUserOp.selector, "unsupported execution");

if (missingAccountFunds > 0) {
manager.transferPrefund(account, entrypoint, missingAccountFunds);
}

try IAccount(account).checkSignatures(userOpHash, "", userOp.signature) {
validationData = VALIDATION_SIG_SUCCESS;
} catch {
validationData = VALIDATION_SIG_FAILURE;
}
}

/**
* Executes a account operation.
* @param manager the protocol manager.
* @param account the account.
* @param to target of the operation.
* @param value value of the operation.
* @param data calldata for the operation.
*/
function _executeUserOp(ISafeProtocolManager manager, address account, address to, uint256 value, bytes memory data) internal {
SafeTransaction memory transaction;
{
transaction.actions = new SafeProtocolAction[](1);
transaction.actions[0].to = payable(to);
transaction.actions[0].value = value;
transaction.actions[0].data = data;
}
manager.executeTransaction(account, transaction);
}

/**
* @inheritdoc ISafeProtocolPlugin
*/
function metadataProvider()
external
view
override(ISafeProtocolFunctionHandler, ISafeProtocolPlugin)
returns (uint256 providerType, bytes memory location)
{}

/**
* @inheritdoc ISafeProtocolPlugin
*/
function name() external pure override returns (string memory) {
return "Safe Protocol ERC-4337 Plugin";
}

/**
* @inheritdoc ISafeProtocolPlugin
*/
function version() external pure override returns (string memory) {
return "1";
}

/**
* @inheritdoc ISafeProtocolPlugin
*/
function requiresPermissions() external pure override returns (uint8 permissions) {
permissions = PLUGIN_PERMISSION_EXECUTE_CALL;
}
}
6 changes: 6 additions & 0 deletions contracts/modules/erc4337/interfaces/IERC4337.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity ^0.8.18;

import {IAccount} from "@account-abstraction/contracts/interfaces/IAccount.sol";
import {IEntryPoint} from "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
import {UserOperation} from "@account-abstraction/contracts/interfaces/UserOperation.sol";
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity ^0.8.18;

import {IAccount} from "./IERC4337.sol";

interface ISafeProtocol4337Handler is IAccount {
function executeUserOp(address to, uint256 value, bytes calldata data) external;
}
37 changes: 37 additions & 0 deletions deploy/deploy_plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { DeployFunction } from "hardhat-deploy/types";
import { HardhatRuntimeEnvironment } from "hardhat/types";

import { MODULE_TYPE_FUNCTION_HANDLER, MODULE_TYPE_PLUGIN } from "../src/utils/constants";

const ENTRYPOINT = process.env.SAFE_PROTOCOL_ERC4337_ENTRYPOINT ?? "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789";

const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
const { ethers, deployments, getNamedAccounts } = hre;
const { deployer, owner } = await getNamedAccounts();
const { deploy } = deployments;

const registry = await deploy("SafeProtocolRegistry", {
from: deployer,
args: [owner],
log: true,
deterministicDeployment: true,
});

const module = await deploy("SafeProtocol4337Module", {
from: deployer,
args: [ENTRYPOINT],
log: true,
deterministicDeployment: true,
});
await ethers.getContractAt("SafeProtocolRegistry", registry.address).then(async (registry) => {
const { listedAt } = await registry.check(module.address, ethers.toBeHex(ethers.MaxUint256));
if (listedAt == 0n) {
await registry
.connect(await ethers.getSigner(owner))
.addModule(module.address, MODULE_TYPE_PLUGIN | MODULE_TYPE_FUNCTION_HANDLER);
}
});
};

deploy.tags = ["plugin"];
export default deploy;
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"build:ts": "yarn rimraf dist && tsc -p tsconfig.prod.json"
},
"devDependencies": {
"@account-abstraction/contracts": "^0.6.0",
"@nomicfoundation/hardhat-chai-matchers": "^2.0.0",
"@nomicfoundation/hardhat-ethers": "^3.0.0",
"@nomicfoundation/hardhat-network-helpers": "^1.0.0",
Expand All @@ -59,7 +60,7 @@
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-prettier": "^4.2.1",
"ethers": "^6.4.0",
"ethers": "^6.8.1",
nlordell marked this conversation as resolved.
Show resolved Hide resolved
"hardhat": "^2.15.0",
"hardhat-deploy": "^0.11.34",
"hardhat-gas-reporter": "^1.0.8",
Expand All @@ -74,4 +75,4 @@
"typescript": "~5.0.0",
"yargs": "^17.7.2"
}
}
}
92 changes: 92 additions & 0 deletions scripts/user-operation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Script to execute an ERC-4337 user operation for a test executor.
*/

import { ethers, deployments } from "hardhat";

import { PLUGIN_PERMISSION_EXECUTE_CALL } from "../src/utils/constants";
import { UserOperationStruct } from "../typechain-types/@account-abstraction/contracts/interfaces/IAccount";

const BUNDLER = process.env.SAFE_PROTOCOL_ERC4337_BUNDLER_URL ?? "http://localhost:3000/rpc";
const ENTRYPOINT = process.env.SAFE_PROTOCOL_ERC4337_ENTRYPOINT ?? "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789";

async function main() {
const bundler = await bundlerRpc(BUNDLER);

const manager = await deployments
.get("SafeProtocolManager")
.then(({ address }) => ethers.getContractAt("SafeProtocolManager", address));
const module = await deployments
.get("SafeProtocol4337Module")
.then(({ address }) => ethers.getContractAt("SafeProtocol4337Module", address));
const handler = await ethers.getContractAt("ISafeProtocol4337Handler", await module.getAddress());
const entrypoint = await ethers.getContractAt("EntryPoint", ENTRYPOINT);

const account = await ethers.deployContract("TestExecutor", [await manager.getAddress()]);
Copy link
Member

Choose a reason for hiding this comment

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

Would it make sense to use an actual safe account here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Probably, but it made the signing more complicated 😅. You could argue that the PoC was about ensuring that some account implementation paired with the Safe{Core} Protocol reference implementation could work with ERC-4337, so I don’t think its strictly necessary.

console.log(`using account ${await account.getAddress()}`);

await fundAddress(await account.getAddress());
await account.setModule(await manager.getAddress());
await account.exec(
await account.getAddress(),
0,
manager.interface.encodeFunctionData("enablePlugin", [await module.getAddress(), PLUGIN_PERMISSION_EXECUTE_CALL]),
);
await account.exec(
await account.getAddress(),
0,
manager.interface.encodeFunctionData("setFunctionHandler", [handler.validateUserOp.fragment.selector, await handler.getAddress()]),
);
await account.exec(
await account.getAddress(),
0,
manager.interface.encodeFunctionData("setFunctionHandler", [handler.executeUserOp.fragment.selector, await handler.getAddress()]),
);

const { maxFeePerGas, maxPriorityFeePerGas } = await ethers.provider.getFeeData();
const op = {
sender: await account.getAddress(),
nonce: ethers.toBeHex(await entrypoint.getNonce(await account.getAddress(), 0)),
initCode: "0x",
callData: handler.interface.encodeFunctionData("executeUserOp", [ethers.ZeroAddress, 0, "0x"]),
callGasLimit: ethers.toBeHex(100000),
verificationGasLimit: ethers.toBeHex(200000),
preVerificationGas: ethers.toBeHex(100000),
maxFeePerGas: ethers.toBeHex(maxFeePerGas ?? 0),
maxPriorityFeePerGas: ethers.toBeHex(maxPriorityFeePerGas ?? 0),
paymasterAndData: "0x",
signature: "0x",
};
console.log("sending operation", op);

const result = await bundler.sendUserOperation(op, await entrypoint.getAddress());
console.log("sent user operation", result);
}

async function bundlerRpc(url: string) {
const provider = new ethers.JsonRpcProvider(url, await ethers.provider.getNetwork());
return {
sendUserOperation: async (op: UserOperationStruct, entrypoint: string) => {
return await provider._send({
jsonrpc: "2.0",
method: "eth_sendUserOperation",
params: [op, entrypoint],
id: 4337,
});
},
};
}

async function fundAddress(to: string) {
const [signer] = await ethers.getSigners();
const balance = await ethers.provider.getBalance(to);
const value = ethers.parseEther("1.0") - balance;
if (value > 0n) {
await signer.sendTransaction({ to, value }).then((tx) => tx.wait());
}
}

main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
2 changes: 1 addition & 1 deletion test/SafeProtocolManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe("SafeProtocolManager", async () => {
expect(await safeProtocolManager.supportsInterface.staticCall("0x945b8148")).to.be.true;
expect(await safeProtocolManager.supportsInterface.staticCall("0xe6d7a83a")).to.be.true;
expect(await safeProtocolManager.supportsInterface.staticCall("0x01ffc9a7")).to.be.true;
expect(await safeProtocolManager.supportsInterface.staticCall("0x3f6c68ec")).to.be.true;
expect(await safeProtocolManager.supportsInterface.staticCall("0x3cd5a81c")).to.be.true;
});

it("Should return false when non-supported interfaceId is passed as parameter", async () => {
Expand Down
Loading
Loading