diff --git a/account-integrations/safe/src/SafeCompressionFactory.sol b/account-integrations/safe/src/SafeCompressionFactory.sol new file mode 100644 index 00000000..24d491bf --- /dev/null +++ b/account-integrations/safe/src/SafeCompressionFactory.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity >=0.7.0 <0.9.0; +pragma abicoder v2; + +import {Safe} from "safe-contracts/contracts/Safe.sol"; +import {SafeProxyFactory} from "safe-contracts/contracts/proxies/SafeProxyFactory.sol"; +import {SafeProxy} from "safe-contracts/contracts/proxies/SafeProxy.sol"; + +import {EntryPoint} from "account-abstraction/contracts/core/EntryPoint.sol"; + +import {SafeCompressionPlugin} from "./SafeCompressionPlugin.sol"; +import {IDecompressor} from "./compression/decompressors/IDecompressor.sol"; + +contract SafeCompressionFactory { + function create( + Safe safeSingleton, + EntryPoint entryPoint, + IDecompressor defaultDecompressor, + address owner, + uint256 saltNonce + ) external returns (SafeCompressionPlugin) { + bytes32 salt = keccak256(abi.encodePacked(owner, saltNonce)); + + Safe safe = Safe(payable(new SafeProxy{salt: salt}( + address(safeSingleton) + ))); + + address[] memory owners = new address[](1); + owners[0] = owner; + + SafeCompressionPlugin plugin = new SafeCompressionPlugin{salt: salt}( + address(entryPoint), + defaultDecompressor + ); + + safe.setup( + owners, + 1, + address(plugin), + abi.encodeCall(SafeCompressionPlugin.enableMyself, (owner)), + address(plugin), + address(0), + 0, + payable(address(0)) + ); + + return SafeCompressionPlugin(address(safe)); + } +} diff --git a/account-integrations/safe/src/SafeCompressionPlugin.sol b/account-integrations/safe/src/SafeCompressionPlugin.sol new file mode 100644 index 00000000..509afa51 --- /dev/null +++ b/account-integrations/safe/src/SafeCompressionPlugin.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity >=0.7.0 <0.9.0; +pragma abicoder v2; + +import {ECDSA} from "openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; + +import {HandlerContext} from "safe-contracts/contracts/handler/HandlerContext.sol"; + +import {BaseAccount} from "account-abstraction/contracts/core/BaseAccount.sol"; +import {UserOperation} from "account-abstraction/contracts/interfaces/IEntryPoint.sol"; + +import {WaxLib as W} from "./compression/WaxLib.sol"; +import {IDecompressor} from "./compression/decompressors/IDecompressor.sol"; + +interface ISafe { + function enableModule(address module) external; + + function execTransactionFromModule( + address to, + uint256 value, + bytes memory data, + uint8 operation + ) external returns (bool success); +} + +struct ECDSAOwnerStorage { + address owner; +} + +contract SafeCompressionPlugin is HandlerContext { + using ECDSA for bytes32; + + uint256 constant internal SIG_VALIDATION_FAILED = 1; + + mapping(address => ECDSAOwnerStorage) public ecdsaOwnerStorage; + address public immutable myAddress; + address private immutable entryPoint; + IDecompressor public decompressor; + + address internal constant _SENTINEL_MODULES = address(0x1); + + error NONCE_NOT_SEQUENTIAL(); + event OWNER_UPDATED(address indexed safe, address indexed oldOwner, address indexed newOwner); + + constructor(address entryPointParam, IDecompressor decompressorParam) { + myAddress = address(this); + entryPoint = entryPointParam; + decompressor = decompressorParam; + } + + function validateUserOp( + UserOperation calldata userOp, + bytes32 userOpHash, + uint256 missingAccountFunds + ) external returns (uint256 validationData) { + _validateNonce(userOp.nonce); + validationData = _validateSignature(userOp, userOpHash); + _payPrefund(missingAccountFunds); + } + + function decompressAndPerform( + bytes calldata stream + ) public fromThisOrEntryPoint { + (W.Action[] memory actions,) = decompressor.decompress(stream); + + ISafe safe = ISafe(msg.sender); + + for (uint256 i = 0; i < actions.length; i++) { + W.Action memory a = actions[i]; + + require( + safe.execTransactionFromModule(a.to, a.value, a.data, 0), + "tx failed" + ); + } + } + + function setDecompressor( + IDecompressor decompressorParam + ) public fromThisOrEntryPoint { + decompressor = decompressorParam; + } + + function enableMyself(address ownerKey) public { + ISafe(address(this)).enableModule(myAddress); + + // Enable the safe address with the defined key + bytes memory _data = abi.encodePacked(ownerKey); + SafeCompressionPlugin(myAddress).enable(_data); + } + + function enable(bytes calldata _data) external payable { + address newOwner = address(bytes20(_data[0:20])); + address oldOwner = ecdsaOwnerStorage[msg.sender].owner; + ecdsaOwnerStorage[msg.sender].owner = newOwner; + emit OWNER_UPDATED(msg.sender, oldOwner, newOwner); + } + + function _validateSignature( + UserOperation calldata userOp, + bytes32 userOpHash + ) internal view returns (uint256 validationData) { + address keyOwner = ecdsaOwnerStorage[msg.sender].owner; + bytes32 hash = userOpHash.toEthSignedMessageHash(); + if (keyOwner != hash.recover(userOp.signature)) + return SIG_VALIDATION_FAILED; + return 0; + } + + /** + * Ensures userOp nonce is sequential. Nonce uniqueness is already managed by the EntryPoint. + * This function prevents using a “key” different from the first “zero” key. + * @param nonce to validate + */ + function _validateNonce(uint256 nonce) internal pure { + if (nonce >= type(uint64).max) { + revert NONCE_NOT_SEQUENTIAL(); + } + } + + /** + * This function is overridden as this plugin does not hold funds, so the transaction + * has to be executed from the sender Safe + * @param missingAccountFunds The minimum value this method should send to the entrypoint + */ + function _payPrefund(uint256 missingAccountFunds) internal { + address payable safeAddress = payable(msg.sender); + ISafe senderSafe = ISafe(safeAddress); + + if (missingAccountFunds != 0) { + senderSafe.execTransactionFromModule( + entryPoint, + missingAccountFunds, + "", + 0 + ); + } + } + + modifier fromThisOrEntryPoint() { + require( + _msgSender() == entryPoint || + _msgSender() == address(this) + ); + _; + } +} diff --git a/account-integrations/safe/src/compression b/account-integrations/safe/src/compression new file mode 120000 index 00000000..6044aeae --- /dev/null +++ b/account-integrations/safe/src/compression @@ -0,0 +1 @@ +../../compression/src \ No newline at end of file diff --git a/account-integrations/safe/test/hardhat/integration/SafeCompressionPlugin.test.ts b/account-integrations/safe/test/hardhat/integration/SafeCompressionPlugin.test.ts new file mode 100644 index 00000000..3547c703 --- /dev/null +++ b/account-integrations/safe/test/hardhat/integration/SafeCompressionPlugin.test.ts @@ -0,0 +1,198 @@ +import { expect } from "chai"; +import { getBytes, resolveProperties, ethers } from "ethers"; +import { UserOperationStruct } from "@account-abstraction/contracts"; +import { getUserOpHash } from "@account-abstraction/utils"; +import { + AddressRegistry__factory, + FallbackDecompressor__factory, + SafeCompressionFactory__factory, + SafeCompressionPlugin__factory, + SafeProxyFactory__factory, + Safe__factory, +} from "../../../typechain-types"; +import sendUserOpAndWait from "../utils/sendUserOpAndWait"; +import receiptOf from "../utils/receiptOf"; +import SafeSingletonFactory from "../utils/SafeSingletonFactory"; +import makeDevFaster from "../utils/makeDevFaster"; + +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.MNEMONIC !== "undefined"; + +const itif = ERC4337_TEST_ENV_VARIABLES_DEFINED ? it : it.skip; +const BUNDLER_URL = process.env.ERC4337_TEST_BUNDLER_URL; +const NODE_URL = process.env.ERC4337_TEST_NODE_URL; +const MNEMONIC = process.env.MNEMONIC; + +describe("SafeCompressionPlugin", () => { + const setupTests = async () => { + const bundlerProvider = new ethers.JsonRpcProvider(BUNDLER_URL); + const provider = new ethers.JsonRpcProvider(NODE_URL); + await makeDevFaster(provider); + + const userWallet = ethers.Wallet.fromPhrase(MNEMONIC!).connect(provider); + + const entryPoints = (await bundlerProvider.send( + "eth_supportedEntryPoints", + [], + )) as string[]; + + if (entryPoints.length === 0) { + throw new Error("No entry points found"); + } + + const ssf = await SafeSingletonFactory.init(userWallet); + + return { + factory: await ssf.connectOrDeploy(SafeProxyFactory__factory, []), + singleton: await ssf.connectOrDeploy(Safe__factory, []), + bundlerProvider, + provider, + userWallet, + entryPoints, + }; + }; + + /** + * This test verifies a ERC4337 transaction succeeds when sent via a plugin + * The user operation deploys a Safe with the ERC4337 plugin and a handler + * and executes a transaction, thus verifying two things: + * 1. Deployment of the Safe with the ERC4337 plugin and handler is possible + * 2. Executing a transaction is possible + */ + itif("should pass the ERC4337 validation", async () => { + const { singleton, provider, bundlerProvider, userWallet, entryPoints } = + await setupTests(); + + const ENTRYPOINT_ADDRESS = entryPoints[0]; + + const ssf = await SafeSingletonFactory.init(userWallet); + + const safeCompressionFactory = await ssf.connectOrDeploy( + SafeCompressionFactory__factory, + [], + ); + + const feeData = await provider.getFeeData(); + if (!feeData.maxFeePerGas || !feeData.maxPriorityFeePerGas) { + throw new Error( + "maxFeePerGas or maxPriorityFeePerGas is null or undefined", + ); + } + + const maxFeePerGas = `0x${feeData.maxFeePerGas.toString()}`; + const maxPriorityFeePerGas = `0x${feeData.maxPriorityFeePerGas.toString()}`; + + const owner = ethers.Wallet.createRandom(provider); + + await receiptOf( + userWallet.sendTransaction({ + to: owner.address, + value: ethers.parseEther("100"), + }), + ); + + const addressRegistry = await ssf.connectOrDeploy( + AddressRegistry__factory, + [], + ); + + const fallbackDecompressor = await ssf.connectOrDeploy( + FallbackDecompressor__factory, + [await addressRegistry.getAddress()], + ); + + const createArgs = [ + singleton, + ENTRYPOINT_ADDRESS, + await fallbackDecompressor.getAddress(), + owner.address, + 0, + ] satisfies Parameters; + + const accountAddress = await safeCompressionFactory.create.staticCall( + ...createArgs, + ); + + await receiptOf(safeCompressionFactory.create(...createArgs)); + + const compressionAccount = SafeCompressionPlugin__factory.connect( + accountAddress, + userWallet, + ); + + const recipient = new ethers.Wallet( + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + ); + + const transferAmount = ethers.parseEther("1"); + + const compressedActions = await fallbackDecompressor.compress( + [ + { + to: recipient.address, + value: transferAmount, + data: "0x", + }, + ], + [], + ); + + const userOpCallData = compressionAccount.interface.encodeFunctionData( + "decompressAndPerform", + [compressedActions], + ); + + // Native tokens for the pre-fund 💸 + await receiptOf( + userWallet.sendTransaction({ + to: accountAddress, + value: ethers.parseEther("100"), + nonce: await userWallet.getNonce(), + }), + ); + + const unsignedUserOperation: UserOperationStruct = { + sender: accountAddress, + nonce: "0x0", + + // Note: initCode is not used because we need to create both the safe + // proxy and the plugin, and 4337 currently only allows one contract + // creation in this step. Since we need an extra step anyway, it's simpler + // to do the whole create outside of 4337. + initCode: "0x", + + callData: userOpCallData, + callGasLimit: "0x7A120", + verificationGasLimit: "0x7A120", + preVerificationGas: "0x186A0", + maxFeePerGas, + maxPriorityFeePerGas, + paymasterAndData: "0x", + signature: "", + }; + + const resolvedUserOp = await resolveProperties(unsignedUserOperation); + const userOpHash = getUserOpHash( + resolvedUserOp, + ENTRYPOINT_ADDRESS, + Number((await provider.getNetwork()).chainId), + ); + const userOpSignature = await owner.signMessage(getBytes(userOpHash)); + + const userOperation = { + ...unsignedUserOperation, + signature: userOpSignature, + }; + + const recipientBalanceBefore = await provider.getBalance(recipient.address); + + await sendUserOpAndWait(userOperation, ENTRYPOINT_ADDRESS, bundlerProvider); + + const recipientBalanceAfter = await provider.getBalance(recipient.address); + + const expectedRecipientBalance = recipientBalanceBefore + transferAmount; + expect(recipientBalanceAfter).to.equal(expectedRecipientBalance); + }); +}); diff --git a/account-integrations/safe/test/hardhat/utils/receiptOf.ts b/account-integrations/safe/test/hardhat/utils/receiptOf.ts index f0432aab..99dff081 100644 --- a/account-integrations/safe/test/hardhat/utils/receiptOf.ts +++ b/account-integrations/safe/test/hardhat/utils/receiptOf.ts @@ -1,4 +1,5 @@ import { ethers } from "ethers"; +import assert from "./assert"; export default async function receiptOf( txResponseOrPromise: @@ -6,5 +7,9 @@ export default async function receiptOf( | Promise, ) { const txResponse = await txResponseOrPromise; - return await txResponse.wait(); + const receipt = await txResponse.wait(); + assert(receipt !== null); + assert(receipt.status === 1); + + return receipt; }