diff --git a/.changeset/young-seals-travel.md b/.changeset/young-seals-travel.md new file mode 100644 index 0000000000..484aec348d --- /dev/null +++ b/.changeset/young-seals-travel.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/paymaster": patch +--- + +Added `GenerousPaymaster`, a simple paymaster that sponsors all user operations for local development purposes. diff --git a/packages/paymaster/.gitignore b/packages/paymaster/.gitignore new file mode 100644 index 0000000000..1e4ded714a --- /dev/null +++ b/packages/paymaster/.gitignore @@ -0,0 +1,2 @@ +cache +out diff --git a/packages/paymaster/CHANGELOG.md b/packages/paymaster/CHANGELOG.md new file mode 100644 index 0000000000..e62969c775 --- /dev/null +++ b/packages/paymaster/CHANGELOG.md @@ -0,0 +1 @@ +# @latticexyz/paymaster diff --git a/packages/paymaster/README.md b/packages/paymaster/README.md new file mode 100644 index 0000000000..8aff618ff4 --- /dev/null +++ b/packages/paymaster/README.md @@ -0,0 +1,3 @@ +# Paymaster contracts + +> :warning: **Important note: these contracts have not been audited yet, so any production use is discouraged for now.** diff --git a/packages/paymaster/foundry.toml b/packages/paymaster/foundry.toml new file mode 100644 index 0000000000..f0e017f5a0 --- /dev/null +++ b/packages/paymaster/foundry.toml @@ -0,0 +1,15 @@ +[profile.default] +solc = "0.8.24" +ffi = false +fuzz_runs = 256 +optimizer = true +optimizer_runs = 3000 +verbosity = 2 +allow_paths = ["../../node_modules", "../"] +src = "src" +out = "out" +bytecode_hash = "none" +extra_output_files = [ + "abi", + "evm.bytecode" +] diff --git a/packages/paymaster/package.json b/packages/paymaster/package.json new file mode 100644 index 0000000000..304d04779f --- /dev/null +++ b/packages/paymaster/package.json @@ -0,0 +1,44 @@ +{ + "name": "@latticexyz/paymaster", + "version": "2.2.14", + "description": "Paymaster contracts", + "repository": { + "type": "git", + "url": "https://github.com/latticexyz/mud.git", + "directory": "packages/paymaster" + }, + "license": "MIT", + "type": "module", + "exports": { + "./out/*": "./out/*" + }, + "typesVersions": { + "*": {} + }, + "files": [ + "out/GenerousPaymaster.sol", + "src" + ], + "scripts": { + "build": "pnpm run build:abi && pnpm run build:abi-ts", + "build:abi": "forge build", + "build:abi-ts": "abi-ts", + "clean": "pnpm run clean:abi", + "clean:abi": "forge clean", + "dev": "echo 'nothing to watch'", + "lint": "solhint --config ./.solhint.json 'src/**/*.sol'", + "test": "forge test", + "test:ci": "pnpm run test" + }, + "dependencies": {}, + "devDependencies": { + "@account-abstraction/contracts": "0.7.0", + "@latticexyz/abi-ts": "workspace:*", + "@openzeppelin/contracts": "5.1.0", + "forge-std": "https://github.com/foundry-rs/forge-std.git#1eea5bae12ae557d589f9f0f0edae2faa47cb262", + "solhint": "^3.3.7" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/paymaster/remappings.txt b/packages/paymaster/remappings.txt new file mode 100644 index 0000000000..af2dbf2991 --- /dev/null +++ b/packages/paymaster/remappings.txt @@ -0,0 +1,2 @@ +forge-std/=node_modules/forge-std/src/ +@account-abstraction/=node_modules/@account-abstraction/ \ No newline at end of file diff --git a/packages/paymaster/src/experimental/GenerousPaymaster.sol b/packages/paymaster/src/experimental/GenerousPaymaster.sol new file mode 100644 index 0000000000..2bae2e2f13 --- /dev/null +++ b/packages/paymaster/src/experimental/GenerousPaymaster.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import { IPaymaster } from "@account-abstraction/contracts/interfaces/IPaymaster.sol"; +import { IEntryPoint } from "@account-abstraction/contracts/interfaces/IEntryPoint.sol"; +import { PackedUserOperation } from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol"; +import { BasePaymaster } from "@account-abstraction/contracts/core/BasePaymaster.sol"; + +/** + * @title Generous Paymaster + * @author MUD (https://mud.dev) by Lattice (https://lattice.xyz) + * @dev This contract is a simple paymaster that sponsors all user operations. + * It is intended for local development purposes. + */ +contract GenerousPaymaster is BasePaymaster { + constructor(IEntryPoint _entryPoint) BasePaymaster(_entryPoint) {} + + /** + * Payment validation: check if paymaster agrees to pay. + * Revert to reject this request. + * Note that bundlers will reject this method if it changes the state, unless the paymaster is trusted (whitelisted). + * The paymaster pre-pays using its deposit, and receive back a refund after the postOp method returns. + * @param userOp - The user operation. + * @param userOpHash - Hash of the user's request data. + * @param maxCost - The maximum cost of this transaction (based on maximum gas and gas price from userOp). + * @return context - Value to send to a postOp. Zero length to signify postOp is not required. + * @return validationData - Signature and time-range of this operation, encoded the same as the return + * value of validateUserOperation. + * <20-byte> sigAuthorizer - 0 for valid signature, 1 to mark signature failure, + * other values are invalid for paymaster. + * <6-byte> validUntil - last timestamp this operation is valid. 0 for "indefinite" + * <6-byte> validAfter - first timestamp this operation is valid + * Note that the validation code cannot use block.timestamp (or block.number) directly. + */ + function _validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 maxCost + ) internal override returns (bytes memory context, uint256 validationData) { + // No validation required, since this paymaster sponsors all user operations. + } + + /** + * Post-operation handler. + * @param mode - Enum with the following options: + * opSucceeded - User operation succeeded. + * opReverted - User op reverted. The paymaster still has to pay for gas. + * postOpReverted - never passed in a call to postOp(). + * @param context - The context value returned by validatePaymasterUserOp + * @param actualGasCost - Actual gas used so far (without this postOp call). + * @param actualUserOpFeePerGas - the gas price this UserOp pays. This value is based on the UserOp's maxFeePerGas + * and maxPriorityFee (and basefee) + * It is not the same as tx.gasprice, which is what the bundler pays. + */ + function _postOp( + IPaymaster.PostOpMode mode, + bytes calldata context, + uint256 actualGasCost, + uint256 actualUserOpFeePerGas + ) internal override {} +} diff --git a/packages/paymaster/test/GenerousPaymaster.t.sol b/packages/paymaster/test/GenerousPaymaster.t.sol new file mode 100644 index 0000000000..1f86e5124f --- /dev/null +++ b/packages/paymaster/test/GenerousPaymaster.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import "forge-std/Test.sol"; +import { EntryPoint, IEntryPoint } from "@account-abstraction/contracts/core/EntryPoint.sol"; +import { PackedUserOperation } from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol"; +import { SimpleAccountFactory, SimpleAccount } from "@account-abstraction/contracts/samples/SimpleAccountFactory.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { TestCounter } from "./utils/TestCounter.sol"; +import { GenerousPaymaster } from "../src/experimental/GenerousPaymaster.sol"; + +contract GenerousPaymasterTest is Test { + EntryPoint entryPoint; + SimpleAccountFactory accountFactory; + GenerousPaymaster paymaster; + TestCounter counter; + + address payable beneficiary; + address user; + uint256 userKey; + SimpleAccount account; + + uint256 grantAllowance = 10 ether; + uint256 paymasterDeposit = 10 ether; + + function setUp() public { + entryPoint = new EntryPoint(); + accountFactory = new SimpleAccountFactory(entryPoint); + paymaster = new GenerousPaymaster(entryPoint); + counter = new TestCounter(); + + beneficiary = payable(makeAddr("beneficiary")); + (user, userKey) = makeAddrAndKey("user"); + account = accountFactory.createAccount(user, 0); + + entryPoint.depositTo{ value: paymasterDeposit }(address(paymaster)); + } + + // sanity check for everything works without paymaster + function testCall() external { + vm.deal(address(account), 1e18); + PackedUserOperation memory op = fillUserOp( + account, + userKey, + address(counter), + 0, + abi.encodeWithSelector(TestCounter.count.selector) + ); + op.signature = signUserOp(op, userKey); + submitUserOp(op); + assertEq(counter.counters(address(account)), 1); + } + + function testCallWithPaymaster() external { + PackedUserOperation memory op = fillUserOp( + account, + userKey, + address(counter), + 0, + abi.encodeWithSelector(TestCounter.count.selector) + ); + + op.paymasterAndData = abi.encodePacked(address(paymaster), uint128(100000), uint128(100000)); + op.signature = signUserOp(op, userKey); + + assertEq(beneficiary.balance, 0); + submitUserOp(op); + assertEq(counter.counters(address(account)), 1); + assertLt(entryPoint.balanceOf(address(paymaster)), paymasterDeposit); + } + + function fillUserOp( + SimpleAccount _sender, + uint256 _key, + address _to, + uint256 _value, + bytes memory _data + ) internal view returns (PackedUserOperation memory op) { + op.sender = address(_sender); + op.nonce = entryPoint.getNonce(address(_sender), 0); + op.callData = abi.encodeWithSelector(SimpleAccount.execute.selector, _to, _value, _data); + op.accountGasLimits = bytes32(abi.encodePacked(bytes16(uint128(80000)), bytes16(uint128(50000)))); + op.preVerificationGas = 50000; + op.gasFees = bytes32(abi.encodePacked(bytes16(uint128(100)), bytes16(uint128(1000000000)))); + // NOTE: gas fees are set to 0 on purpose to not require paymaster to have a deposit + // op.gasFees = bytes32(abi.encodePacked(bytes16(uint128(0)), bytes16(uint128(0)))); + op.signature = signUserOp(op, _key); + return op; + } + + function signUserOp(PackedUserOperation memory op, uint256 _key) internal view returns (bytes memory signature) { + bytes32 hash = entryPoint.getUserOpHash(op); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_key, MessageHashUtils.toEthSignedMessageHash(hash)); + signature = abi.encodePacked(r, s, v); + } + + function submitUserOp(PackedUserOperation memory op) internal { + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = op; + entryPoint.handleOps(ops, beneficiary); + } + + function expectUserOpRevert(bytes memory message) internal { + vm.expectRevert( + abi.encodeWithSelector(IEntryPoint.FailedOpWithRevert.selector, uint256(0), "AA33 reverted", message) + ); + } +} diff --git a/packages/paymaster/test/utils/TestCounter.sol b/packages/paymaster/test/utils/TestCounter.sol new file mode 100644 index 0000000000..efcfc784ed --- /dev/null +++ b/packages/paymaster/test/utils/TestCounter.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +//sample "receiver" contract, for testing "exec" from account. +contract TestCounter { + mapping(address => uint256) public counters; + + function count() public { + counters[msg.sender] = counters[msg.sender] + 1; + } + + function countFail() public pure { + revert("count failed"); + } + + function justemit() public { + emit CalledFrom(msg.sender); + } + + event CalledFrom(address sender); + + //helper method to waste gas + // repeat - waste gas on writing storage in a loop + // junk - dynamic buffer to stress the function size. + mapping(uint256 => uint256) public xxx; + uint256 public offset; + + function gasWaster(uint256 repeat, string calldata /*junk*/) external { + for (uint256 i = 1; i <= repeat; i++) { + offset++; + xxx[offset] = i; + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acc0acfa0b..1641c26e8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -718,6 +718,24 @@ importers: specifier: 0.34.6 version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.33.0) + packages/paymaster: + devDependencies: + '@account-abstraction/contracts': + specifier: 0.7.0 + version: 0.7.0 + '@latticexyz/abi-ts': + specifier: workspace:* + version: link:../abi-ts + '@openzeppelin/contracts': + specifier: 5.1.0 + version: 5.1.0 + forge-std: + specifier: https://github.com/foundry-rs/forge-std.git#1eea5bae12ae557d589f9f0f0edae2faa47cb262 + version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/1eea5bae12ae557d589f9f0f0edae2faa47cb262 + solhint: + specifier: ^3.3.7 + version: 3.3.7 + packages/protocol-parser: dependencies: '@latticexyz/common': @@ -1555,6 +1573,9 @@ packages: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} + '@account-abstraction/contracts@0.7.0': + resolution: {integrity: sha512-Bt/66ilu3u8I9+vFZ9fTd+cWs55fdb9J5YKfrhsrFafH1drkzwuCSL/xEot1GGyXXNJLQuXbMRztQPyelNbY1A==} + '@adraffy/ens-normalize@1.11.0': resolution: {integrity: sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg==} @@ -3755,6 +3776,12 @@ packages: resolution: {integrity: sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==} engines: {node: '>=8.0.0'} + '@openzeppelin/contracts@3.4.2-solc-0.7': + resolution: {integrity: sha512-W6QmqgkADuFcTLzHL8vVoNBtkwjvQRpYIAom7KiUNoLKghyx3FgH0GBjt8NRvigV1ZmMOBllvE1By1C+bi8WpA==} + + '@openzeppelin/contracts@5.1.0': + resolution: {integrity: sha512-p1ULhl7BXzjjbha5aqst+QMLY+4/LCWADXOCsmLHRM77AqiPjnd9vvUN9sosUfhL9JGKpZ0TjEGxgvnizmWGSA==} + '@parcel/watcher-android-arm64@2.4.1': resolution: {integrity: sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==} engines: {node: '>= 10.0.0'} @@ -5595,6 +5622,22 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + '@uniswap/lib@4.0.1-alpha': + resolution: {integrity: sha512-f6UIliwBbRsgVLxIaBANF6w09tYqc6Y/qXdsrbEmXHyFA7ILiKrIwRFXe1yOg8M3cksgVsO9N7yuL2DdCGQKBA==} + engines: {node: '>=10'} + + '@uniswap/v2-core@1.0.1': + resolution: {integrity: sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q==} + engines: {node: '>=10'} + + '@uniswap/v3-core@1.0.1': + resolution: {integrity: sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ==} + engines: {node: '>=10'} + + '@uniswap/v3-periphery@1.4.4': + resolution: {integrity: sha512-S4+m+wh8HbWSO3DKk4LwUCPZJTpCugIsHrWR86m/OrUyvSqGDTXKFfc2sMuGXCZrD1ZqO3rhQsKgdWg3Hbb2Kw==} + engines: {node: '>=10'} + '@vanilla-extract/css@1.15.5': resolution: {integrity: sha512-N1nQebRWnXvlcmu9fXKVUs145EVwmWtMD95bpiEKtvehHDpUhmO1l2bauS7FGYKbi3dU1IurJbGpQhBclTr1ng==} @@ -6137,6 +6180,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64-sol@1.0.1: + resolution: {integrity: sha512-ld3cCNMeXt4uJXmLZBHFGMvVpK9KsLVEhPpFRXnvSVAqABKbuNZg/+dsq3NuM+wxFLb/UrVkz7m1ciWmkMfTbg==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -10356,10 +10402,6 @@ packages: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true - semver@6.3.0: - resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} - hasBin: true - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -11852,6 +11894,11 @@ snapshots: '@aashutoshrathi/word-wrap@1.2.6': {} + '@account-abstraction/contracts@0.7.0': + dependencies: + '@openzeppelin/contracts': 5.1.0 + '@uniswap/v3-periphery': 1.4.4 + '@adraffy/ens-normalize@1.11.0': {} '@alloc/quick-lru@5.2.0': {} @@ -14660,6 +14707,10 @@ snapshots: '@opentelemetry/api@1.8.0': {} + '@openzeppelin/contracts@3.4.2-solc-0.7': {} + + '@openzeppelin/contracts@5.1.0': {} + '@parcel/watcher-android-arm64@2.4.1': optional: true @@ -16937,6 +16988,20 @@ snapshots: '@ungap/structured-clone@1.2.0': {} + '@uniswap/lib@4.0.1-alpha': {} + + '@uniswap/v2-core@1.0.1': {} + + '@uniswap/v3-core@1.0.1': {} + + '@uniswap/v3-periphery@1.4.4': + dependencies: + '@openzeppelin/contracts': 3.4.2-solc-0.7 + '@uniswap/lib': 4.0.1-alpha + '@uniswap/v2-core': 1.0.1 + '@uniswap/v3-core': 1.0.1 + base64-sol: 1.0.1 + '@vanilla-extract/css@1.15.5': dependencies: '@emotion/hash': 0.9.2 @@ -17875,6 +17940,8 @@ snapshots: base64-js@1.5.1: {} + base64-sol@1.0.1: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -19195,7 +19262,7 @@ snapshots: eslint@5.16.0: dependencies: - '@babel/code-frame': 7.21.4 + '@babel/code-frame': 7.24.7 ajv: 6.12.6 chalk: 2.4.2 cross-spawn: 6.0.5 @@ -22930,8 +22997,6 @@ snapshots: semver@5.7.2: {} - semver@6.3.0: {} - semver@6.3.1: {} semver@7.5.0: @@ -23128,7 +23193,7 @@ snapshots: ignore: 4.0.6 js-yaml: 3.14.1 lodash: 4.17.21 - semver: 6.3.0 + semver: 6.3.1 optionalDependencies: prettier: 1.19.1 transitivePeerDependencies: