From 2213ba4ac86c5973a8c3334ca8dfeb5a930042b0 Mon Sep 17 00:00:00 2001 From: Ino Murko Date: Wed, 3 May 2023 11:58:44 +0200 Subject: [PATCH 1/9] Inomurko/bump bundler (#698) * bump bundler, limit dependency bumps * uncomment bundler related stuff * build bundler in docker * build fixes * fix tests * fix bundler building * uncomment _disableInitializers * fix running DTL * v1.0.0 * fixing starting bundler and tests, default config * local unsafe, linting fix in intg tests * custom errors fixes * new api for simple account contract * use simple account factory proxy * use simple account factory proxy in SimpleAccountAPI * use simple account factory proxy in SimpleAccountAPI * use wrappers to get around custom errors * update entrypoint wrapper (#745) * sponsoring fee fixed * stricter validation for staking * remove debug namespace, fix return for unaavailable rpc methods * addressing Souradeeps comments --------- Co-authored-by: Souradeep Das (cherry picked from commit 6368c7293f6986f33e9556fdb2341234d86890db) --- .circleci/config.yml | 28 +- integration-tests/hardhat.config.ts | 1 - integration-tests/package.json | 2 + .../test/eth-l2/boba_aa_fee_alt_token.spec.ts | 454 +++++++++-------- .../test/eth-l2/boba_aa_fee_boba.spec.ts | 460 ++++++++--------- .../eth-l2/boba_aa_sponsoring_fee.spec.ts | 333 +++++++------ .../test/eth-l2/boba_aa_wallet.spec.ts | 414 ++++++++-------- ops/docker-compose-side.yml | 69 ++- ops/docker-compose.yml | 12 +- ops/docker/Dockerfile.data-transport-layer | 2 +- ops/docker/Dockerfile.packages | 68 +-- ops/scripts/dtl.sh | 2 +- ops/scripts/geth.sh | 2 +- package.json | 1 - .../.github/workflows/build.yml | 88 ---- .../contracts/bundler/EntryPointWrapper.sol | 208 ++++++++ .../deploy/4-deploy-bundler-helper-wrapper.ts | 43 ++ .../deploy/5-dump-addresses.ts | 35 ++ .../account-abstraction/test/helpers.test.ts | 3 + .../test/verifying_paymaster.test.ts | 2 + .../test/verifying_paymaster_boba.test.ts | 1 + packages/boba/bobalink/test/bobalink.spec.ts | 2 +- packages/boba/bundler/bundler.sh | 2 +- .../boba/bundler/contracts/BundlerHelper.sol | 55 +- packages/boba/bundler/contracts/Import.sol | 2 +- .../boba/bundler/contracts/tests/TestCoin.sol | 42 ++ .../contracts/tests/TestOpcodesAccount.sol | 43 ++ .../contracts/tests/TestRecursionAccount.sol | 26 + .../contracts/tests/TestRuleAccount.sol | 72 +++ .../contracts/tests/TestRulesAccount.sol | 98 ++++ .../contracts/tests/TestStorageAccount.sol | 73 +++ .../bundler/contracts/tests/TracerTest.sol | 3 - packages/boba/bundler/package.json | 9 +- .../bundler/src/BundlerCollectorTracer.ts | 183 +++++-- packages/boba/bundler/src/BundlerConfig.ts | 33 +- packages/boba/bundler/src/BundlerServer.ts | 124 ++++- packages/boba/bundler/src/Config.ts | 56 +++ .../boba/bundler/src/DebugMethodHandler.ts | 63 +++ packages/boba/bundler/src/GethTracer.ts | 98 ++-- packages/boba/bundler/src/RpcTypes.ts | 61 +++ .../boba/bundler/src/UserOpMethodHandler.ts | 444 +++++++++-------- packages/boba/bundler/src/exec.ts | 9 +- .../boba/bundler/src/modules/BundleManager.ts | 254 ++++++++++ .../boba/bundler/src/modules/EventsManager.ts | 110 ++++ .../bundler/src/modules/ExecutionManager.ts | 93 ++++ .../bundler/src/modules/MempoolManager.ts | 157 ++++++ .../bundler/src/modules/ReputationManager.ts | 208 ++++++++ packages/boba/bundler/src/modules/Types.ts | 47 ++ .../bundler/src/modules/ValidationManager.ts | 307 ++++++++++++ .../boba/bundler/src/modules/initServer.ts | 36 ++ .../boba/bundler/src/modules/moduleUtils.ts | 93 ++++ .../boba/bundler/src/parseScannerResult.ts | 468 ++++++++++++++++++ packages/boba/bundler/src/runBundler.ts | 199 ++++---- packages/boba/bundler/src/runner/runop.ts | 108 ++-- packages/boba/bundler/src/utils.ts | 99 ++-- .../boba/bundler/test/BundlerManager.test.ts | 74 +++ .../boba/bundler/test/BundlerServer.test.ts | 13 +- .../bundler/test/DebugMethodHandler.test.ts | 109 ++++ packages/boba/bundler/test/Flow.test.ts | 124 ----- .../bundler/test/UserOpMethodHandler.test.ts | 299 ++++++++--- .../boba/bundler/test/ValidateManager.test.ts | 317 ++++++++++++ .../boba/bundler/test/moduleUtils.test.ts | 27 + packages/boba/bundler/test/opcodes.test.ts | 3 + packages/boba/bundler/test/runBundler.test.ts | 4 +- packages/boba/bundler/test/testUtils.ts | 43 ++ packages/boba/bundler/test/tracer.test.ts | 24 +- packages/boba/bundler/test/utils.test.ts | 14 +- packages/boba/bundler_sdk/hardhat.config.ts | 2 +- packages/boba/bundler_sdk/package.json | 19 +- .../{BaseWalletAPI.ts => BaseAccountAPI.ts} | 123 +++-- .../bundler_sdk/src/DeterministicDeployer.ts | 186 ++++--- .../bundler_sdk/src/ERC4337EthersProvider.ts | 91 +++- .../bundler_sdk/src/ERC4337EthersSigner.ts | 52 +- .../boba/bundler_sdk/src/HttpRpcClient.ts | 37 +- packages/boba/bundler_sdk/src/Provider.ts | 46 +- .../boba/bundler_sdk/src/SimpleAccountAPI.ts | 53 +- .../bundler_sdk/src/calcPreVerificationGas.ts | 23 +- packages/boba/bundler_sdk/src/index.ts | 3 + .../test/0-deterministicDeployer.test.ts | 5 +- .../test/1-SimpleAccountAPI.test.ts | 31 +- .../test/3-ERC4337EthersSigner.test.ts | 9 +- packages/boba/bundler_sdk/tsconfig.json | 2 + .../boba/bundler_sdk/tsconfig.packages.json | 20 - packages/boba/bundler_utils/package.json | 4 +- .../boba/bundler_utils/src/ERC4337Utils.ts | 111 ++--- packages/boba/bundler_utils/src/Version.ts | 2 +- .../boba/bundler_utils/src/postExecCheck.ts | 53 +- packages/boba/gateway/package.json | 2 +- .../teleportation/test/teleportation.spec.ts | 2 +- tsconfig.json | 2 +- 90 files changed, 5651 insertions(+), 2183 deletions(-) delete mode 100644 packages/boba/account-abstraction/.github/workflows/build.yml create mode 100644 packages/boba/account-abstraction/contracts/bundler/EntryPointWrapper.sol create mode 100644 packages/boba/account-abstraction/deploy/4-deploy-bundler-helper-wrapper.ts create mode 100644 packages/boba/account-abstraction/deploy/5-dump-addresses.ts create mode 100644 packages/boba/bundler/contracts/tests/TestCoin.sol create mode 100644 packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol create mode 100644 packages/boba/bundler/contracts/tests/TestRecursionAccount.sol create mode 100644 packages/boba/bundler/contracts/tests/TestRuleAccount.sol create mode 100644 packages/boba/bundler/contracts/tests/TestRulesAccount.sol create mode 100644 packages/boba/bundler/contracts/tests/TestStorageAccount.sol create mode 100644 packages/boba/bundler/src/Config.ts create mode 100644 packages/boba/bundler/src/DebugMethodHandler.ts create mode 100644 packages/boba/bundler/src/RpcTypes.ts create mode 100644 packages/boba/bundler/src/modules/BundleManager.ts create mode 100644 packages/boba/bundler/src/modules/EventsManager.ts create mode 100644 packages/boba/bundler/src/modules/ExecutionManager.ts create mode 100644 packages/boba/bundler/src/modules/MempoolManager.ts create mode 100644 packages/boba/bundler/src/modules/ReputationManager.ts create mode 100644 packages/boba/bundler/src/modules/Types.ts create mode 100644 packages/boba/bundler/src/modules/ValidationManager.ts create mode 100644 packages/boba/bundler/src/modules/initServer.ts create mode 100644 packages/boba/bundler/src/modules/moduleUtils.ts create mode 100644 packages/boba/bundler/src/parseScannerResult.ts create mode 100644 packages/boba/bundler/test/BundlerManager.test.ts create mode 100644 packages/boba/bundler/test/DebugMethodHandler.test.ts delete mode 100644 packages/boba/bundler/test/Flow.test.ts create mode 100644 packages/boba/bundler/test/ValidateManager.test.ts create mode 100644 packages/boba/bundler/test/moduleUtils.test.ts create mode 100644 packages/boba/bundler/test/opcodes.test.ts create mode 100644 packages/boba/bundler/test/testUtils.ts rename packages/boba/bundler_sdk/src/{BaseWalletAPI.ts => BaseAccountAPI.ts} (74%) delete mode 100644 packages/boba/bundler_sdk/tsconfig.packages.json diff --git a/.circleci/config.yml b/.circleci/config.yml index f56db363a5..8f67885fc1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -79,7 +79,7 @@ commands: - run: name: Bring up services command: | - docker-compose -f <> -f docker-compose-side.yml up -d bobalink aa_deployer + docker-compose -f <> -f docker-compose-side.yml up -d bobalink aa_deployer bundler working_directory: ops - run: name: Start background logging @@ -167,31 +167,6 @@ jobs: command: make <> working_directory: <> - test_flow_bundler_and_depcheck: - docker: # run the steps with Docker - - image: cimg/node:16.6.2 - steps: # a collection of executable commands - - checkout - - attach_workspace: - at: . - - run: # run hardhat-node as standalone process fork - command: yarn && yarn build - working_directory: ./ - - run: # run hardhat-node as standalone process fork - name: hardhat-node-process - command: yarn hardhat-node - background: true - working_directory: ./packages/boba/bundler - - run: # run tests - name: test - command: yarn test-flows | tee /tmp/test-flows-results.log - working_directory: ./packages/boba/bundler - - run: - name: depcheck - command: yarn depcheck - working_directory: ./packages/boba/bundler - - store_test_results: # special step to upload test results for display in Test Summary - path: /tmp/test-flow-results.log integration-tests: executor: intergration-tests-executor parallelism: 4 @@ -254,5 +229,4 @@ workflows: name: proxyd-tests binary_name: proxyd working_directory: go/proxyd - # - test_flow_bundler_and_depcheck diff --git a/integration-tests/hardhat.config.ts b/integration-tests/hardhat.config.ts index a45fab8810..76acafc0a1 100644 --- a/integration-tests/hardhat.config.ts +++ b/integration-tests/hardhat.config.ts @@ -1,5 +1,4 @@ import { HardhatUserConfig } from 'hardhat/types' - // Hardhat plugins import '@nomiclabs/hardhat-ethers' import '@nomiclabs/hardhat-waffle' diff --git a/integration-tests/package.json b/integration-tests/package.json index 122170a5c0..e750f07c89 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -21,9 +21,11 @@ "@openzeppelin/test-helpers": "^0.5.15", "@boba/api": "0.0.1", "@boba/contracts": "0.0.1", + "@boba/accountabstraction": "1.0.0", "@eth-optimism/contracts": "^0.5.11", "@eth-optimism/core-utils": "0.8.1", "@eth-optimism/sdk": "1.0.1", + "@boba/bundler_sdk": "0.2.3", "@ethersproject/abstract-provider": "^5.5.0", "@ethersproject/providers": "^5.5.3", "@ethersproject/transactions": "^5.5.0", diff --git a/integration-tests/test/eth-l2/boba_aa_fee_alt_token.spec.ts b/integration-tests/test/eth-l2/boba_aa_fee_alt_token.spec.ts index b9f6191e08..b6fb2ad580 100644 --- a/integration-tests/test/eth-l2/boba_aa_fee_alt_token.spec.ts +++ b/integration-tests/test/eth-l2/boba_aa_fee_alt_token.spec.ts @@ -1,220 +1,234 @@ -// import chai from 'chai' -// import chaiAsPromised from 'chai-as-promised' -// chai.use(chaiAsPromised) -// const expect = chai.expect - -// import { Contract, ContractFactory, utils, constants, BigNumber } from 'ethers' - -// import { getFilteredLogIndex } from './shared/utils' - -// import { OptimismEnv } from './shared/env' -// import { hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils' -// // use local sdk -// import { SimpleAccountAPI } from '@boba/bundler_sdk' -// import SimpleAccountJson from '@boba/accountabstraction/artifacts/contracts/samples/SimpleAccount.sol/SimpleAccount.json' -// import L2StandardERC20Json from '@eth-optimism/contracts/artifacts/contracts/standards/L2StandardERC20.sol/L2StandardERC20.json' -// import EntryPointJson from '@boba/accountabstraction/artifacts/contracts/core/EntryPoint.sol/EntryPoint.json' -// import SampleRecipientJson from '../../artifacts/contracts/SampleRecipient.sol/SampleRecipient.json' -// import { HttpRpcClient } from '@boba/bundler_sdk/dist/src/HttpRpcClient' - -// import ManualDepositPaymasterJson from '@boba/accountabstraction/artifacts/contracts/samples/ManualDepositPaymaster.sol/ManualDepositPaymaster.json' - -// describe('AA Alt Fee Token Test\n', async () => { -// let env: OptimismEnv -// let SimpleAccount__factory: ContractFactory -// let recipient: Contract - -// let bundlerProvider: HttpRpcClient -// let entryPointAddress: string - -// let L2ERC20Token__factory: ContractFactory -// let L2ERC20Token: Contract - -// let ManualDepositPaymaster__factory: ContractFactory -// let ManualDepositPaymaster: Contract - -// let SampleRecipient__factory: ContractFactory - -// let EntryPoint: Contract - -// const priceRatio = 100 -// const priceRatioDecimals = 2 -// const minRatio = 1 -// const maxRatio = 500 - -// before(async () => { -// env = await OptimismEnv.new() -// entryPointAddress = env.addressesAABOBA.L2_BOBA_EntryPoint - -// SampleRecipient__factory = new ContractFactory( -// SampleRecipientJson.abi, -// SampleRecipientJson.bytecode, -// env.l2Wallet -// ) - -// recipient = await SampleRecipient__factory.deploy() - -// L2ERC20Token__factory = new ContractFactory( -// L2StandardERC20Json.abi, -// L2StandardERC20Json.bytecode, -// env.l2Wallet -// ) - -// // set bridge as wallet_2 to easily mint -// L2ERC20Token = await L2ERC20Token__factory.deploy(env.l2Wallet_2.address, env.l2Wallet_2.address, 'PEARL', 'PEARL', 18) -// // mint tokens to wallet -// await L2ERC20Token.connect(env.l2Wallet_2).mint(env.l2Wallet.address, utils.parseEther('500')) - -// bundlerProvider = new HttpRpcClient( -// env.bundlerUrl, -// entryPointAddress, -// await env.l2Wallet.provider.getNetwork().then((net) => net.chainId) -// ) - -// ManualDepositPaymaster__factory = new ContractFactory( -// ManualDepositPaymasterJson.abi, -// ManualDepositPaymasterJson.bytecode, -// env.l2Wallet -// ) - -// ManualDepositPaymaster = await ManualDepositPaymaster__factory.deploy( -// entryPointAddress, -// ) - -// // add alt erc20 token -// await ManualDepositPaymaster.addToken( -// L2ERC20Token.address, -// // token decimals -// await L2ERC20Token.decimals(), -// priceRatio, -// priceRatioDecimals, -// minRatio, -// maxRatio -// ) - -// EntryPoint = new Contract( -// entryPointAddress, -// EntryPointJson.abi, -// env.l2Wallet -// ) -// }) -// describe('A user without native token pays for a tx using an alt token through a paymaster', async () => { -// let accountAPI: SimpleAccountAPI -// let account -// let preApproveTokenBalance -// let preApproveDepositAmount -// let preApproveEtherBalance -// let postApproveTokenBalance -// let postApproveDepositAmount -// let postApproveEtherBalance -// let signedOp - -// before('the paymaster operator sets up the paymaster by staking and adding deposits', async () => { -// await ManualDepositPaymaster.addStake(1, { value: utils.parseEther('2') }) -// await EntryPoint.depositTo(ManualDepositPaymaster.address, { -// value: utils.parseEther('1') -// }) -// }) - -// before('the user approves the paymaster to spend their $BOBA token', async () => { -// // deploy a 4337 Wallet and send operation to this wallet -// SimpleAccount__factory = new ContractFactory( -// SimpleAccountJson.abi, -// SimpleAccountJson.bytecode, -// env.l2Wallet -// ) -// account = await SimpleAccount__factory.deploy( -// entryPointAddress, -// env.l2Wallet.address -// ) -// await account.deployed() - -// await L2ERC20Token.transfer(account.address, utils.parseEther('1')) - -// await L2ERC20Token.approve(ManualDepositPaymaster.address, constants.MaxUint256) -// await ManualDepositPaymaster.addDepositFor(L2ERC20Token.address, account.address, utils.parseEther('2')) - -// await env.l2Wallet.sendTransaction({ -// value: utils.parseEther('2'), -// to: account.address, -// }) - -// accountAPI = new SimpleAccountAPI({ -// provider: env.l2Provider, -// entryPointAddress, -// owner: env.l2Wallet, -// walletAddress: account.address, -// }) - -// const approveOp = await accountAPI.createSignedUserOp({ -// target: L2ERC20Token.address, -// data: L2ERC20Token.interface.encodeFunctionData('approve', [ManualDepositPaymaster.address, constants.MaxUint256]), -// }) - -// preApproveTokenBalance = await L2ERC20Token.balanceOf(account.address) -// preApproveDepositAmount = (await ManualDepositPaymaster.depositInfo(L2ERC20Token.address, account.address)).amount -// preApproveEtherBalance = await env.l2Provider.getBalance(account.address) - -// const requestId = await bundlerProvider.sendUserOpToBundler(approveOp) -// const txid = await accountAPI.getUserOpReceipt(requestId) -// console.log('reqId', requestId, 'txid=', txid) - -// postApproveTokenBalance = await L2ERC20Token.balanceOf(account.address) -// postApproveDepositAmount = (await ManualDepositPaymaster.depositInfo(L2ERC20Token.address, account.address)).amount -// postApproveEtherBalance = await env.l2Provider.getBalance(account.address) -// }) - -// it('should be able to submit a userOp including the paymaster to the bundler and trigger tx', async () => { -// const op = await accountAPI.createUnsignedUserOp({ -// target: recipient.address, -// data: recipient.interface.encodeFunctionData('something', ['hello']), -// }) - - -// op.paymasterAndData = hexConcat([ManualDepositPaymaster.address, hexZeroPad(L2ERC20Token.address, 20)]) -// op.preVerificationGas = await accountAPI.getPreVerificationGas(op) - -// signedOp = await accountAPI.signUserOp(op) - -// const requestId = await bundlerProvider.sendUserOpToBundler(signedOp) -// const txid = await accountAPI.getUserOpReceipt(requestId) -// console.log('reqId', requestId, 'txid=', txid) -// const receipt = await env.l2Provider.getTransactionReceipt(txid) -// const returnedlogIndex = await getFilteredLogIndex( -// receipt, -// SampleRecipientJson.abi, -// recipient.address, -// 'Sender' -// ) -// const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) -// // tx.origin is the bundler -// expect(log.args.txOrigin).to.eq(env.l2Wallet.address) -// // msg.sender is the 4337 wallet -// expect(log.args.msgSender).to.eq(account.address) -// // message is received and emitted -// expect(log.args.message).to.eq('hello') -// const postCallTokenBalance = await L2ERC20Token.balanceOf(account.address) -// const postCallDepositAmount = (await ManualDepositPaymaster.depositInfo(L2ERC20Token.address, account.address)).amount -// const postCallEtherBalance = await env.l2Provider.getBalance(account.address) - -// const returnedEPlogIndex = await getFilteredLogIndex( -// receipt, -// EntryPointJson.abi, -// entryPointAddress, -// 'UserOperationEvent' -// ) -// const logEP = EntryPoint.interface.parseLog(receipt.logs[returnedEPlogIndex]) - -// // no token is used when approving, ether balance is used to pay approval fees -// expect(preApproveTokenBalance).to.eq(postApproveTokenBalance) -// expect(preApproveEtherBalance).to.gt(postApproveEtherBalance) -// // users deposit amount on paymaster remains constant and is unused throughout -// expect(preApproveDepositAmount).to.eq(postApproveDepositAmount) -// expect(postApproveDepositAmount).to.eq(postCallDepositAmount) -// // no ether is used when calling the recipient with the help of the paymaster, users boba token is used to pay -// expect(postApproveEtherBalance).to.eq(postCallEtherBalance) -// expect(postApproveTokenBalance).to.gt(postCallTokenBalance) -// expect(BigNumber.from(postCallTokenBalance).add(logEP.args.actualGasCost)).to.closeTo(BigNumber.from(postApproveTokenBalance), utils.parseEther('0.0001')) -// }) -// }) -// }) \ No newline at end of file +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +chai.use(chaiAsPromised) +const expect = chai.expect + +import { Contract, ContractFactory, utils, constants, BigNumber } from 'ethers' + +import { getFilteredLogIndex } from './shared/utils' + +import { OptimismEnv } from './shared/env' +import { hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils' +// use local sdk +import { SimpleAccountAPI } from '@boba/bundler_sdk' +import SenderCreatorJson from '@boba/accountabstraction/artifacts/contracts/core/SenderCreator.sol/SenderCreator.json' +import SimpleAccountFactoryJson from '@boba/accountabstraction/artifacts/contracts/samples/SimpleAccountFactory.sol/SimpleAccountFactory.json' +import L2StandardERC20Json from '@eth-optimism/contracts/artifacts/contracts/standards/L2StandardERC20.sol/L2StandardERC20.json' +import EntryPointJson from '@boba/accountabstraction/artifacts/contracts/core/EntryPoint.sol/EntryPoint.json' +import SampleRecipientJson from '../../artifacts/contracts/SampleRecipient.sol/SampleRecipient.json' +import { HttpRpcClient } from '@boba/bundler_sdk/dist/HttpRpcClient' + +import ManualDepositPaymasterJson from '@boba/accountabstraction/artifacts/contracts/samples/ManualDepositPaymaster.sol/ManualDepositPaymaster.json' + +describe('AA Alt Fee Token Test\n', async () => { + let env: OptimismEnv + let SimpleAccountFactory__factory: ContractFactory + let recipient: Contract + + let bundlerProvider: HttpRpcClient + let entryPointAddress: string + + let L2ERC20Token__factory: ContractFactory + let L2ERC20Token: Contract + + let ManualDepositPaymaster__factory: ContractFactory + let ManualDepositPaymaster: Contract + + let SampleRecipient__factory: ContractFactory + + let EntryPoint: Contract + + const priceRatio = 100 + const priceRatioDecimals = 2 + const minRatio = 1 + const maxRatio = 500 + + before(async () => { + env = await OptimismEnv.new() + entryPointAddress = env.addressesAABOBA.L2_BOBA_EntryPoint + + SampleRecipient__factory = new ContractFactory( + SampleRecipientJson.abi, + SampleRecipientJson.bytecode, + env.l2Wallet + ) + + recipient = await SampleRecipient__factory.deploy() + + L2ERC20Token__factory = new ContractFactory( + L2StandardERC20Json.abi, + L2StandardERC20Json.bytecode, + env.l2Wallet + ) + + // set bridge as wallet_2 to easily mint + L2ERC20Token = await L2ERC20Token__factory.deploy(env.l2Wallet_2.address, env.l2Wallet_2.address, 'PEARL', 'PEARL', 18) + // mint tokens to wallet + await L2ERC20Token.connect(env.l2Wallet_2).mint(env.l2Wallet.address, utils.parseEther('500')) + + bundlerProvider = new HttpRpcClient( + env.bundlerUrl, + entryPointAddress, + await env.l2Wallet.provider.getNetwork().then((net) => net.chainId) + ) + + ManualDepositPaymaster__factory = new ContractFactory( + ManualDepositPaymasterJson.abi, + ManualDepositPaymasterJson.bytecode, + env.l2Wallet + ) + + ManualDepositPaymaster = await ManualDepositPaymaster__factory.deploy( + entryPointAddress, + ) + + // add alt erc20 token + await ManualDepositPaymaster.addToken( + L2ERC20Token.address, + // token decimals + await L2ERC20Token.decimals(), + priceRatio, + priceRatioDecimals, + minRatio, + maxRatio + ) + + EntryPoint = new Contract( + entryPointAddress, + EntryPointJson.abi, + env.l2Wallet + ) + }) + describe('A user without native token pays for a tx using an alt token through a paymaster', async () => { + let accountAPI: SimpleAccountAPI + let account + let accountFactory + let preApproveTokenBalance + let preApproveDepositAmount + let preApproveEtherBalance + let postApproveTokenBalance + let postApproveDepositAmount + let postApproveEtherBalance + let signedOp + + before('the paymaster operator sets up the paymaster by staking and adding deposits', async () => { + await ManualDepositPaymaster.addStake(1, { value: utils.parseEther('2') }) + await EntryPoint.depositTo(ManualDepositPaymaster.address, { + value: utils.parseEther('1') + }) + }) + + before('the user approves the paymaster to spend their $BOBA token', async () => { + // deploy a 4337 Wallet and send operation to this wallet + SimpleAccountFactory__factory = new ContractFactory( + SimpleAccountFactoryJson.abi, + SimpleAccountFactoryJson.bytecode, + env.l2Wallet + ) + accountFactory = await SimpleAccountFactory__factory.deploy( + entryPointAddress + ) + await accountFactory.deployed() + console.log('Account Factory deployed to:', accountFactory.address) + await accountFactory.createAccount(env.l2Wallet.address, 0) + account = await accountFactory.getAddress(env.l2Wallet.address, 0) + console.log('Account deployed to:', account) + // const SenderCreator__factory = new ContractFactory( + // SenderCreatorJson.abi, + // SenderCreatorJson.bytecode, + // env.l2Wallet + // ) + // const senderCreator = await SenderCreator__factory.deploy() + // console.log('Sender Creator Factory deployed to:', senderCreator.address) + + await L2ERC20Token.transfer(account, utils.parseEther('1')) + + await L2ERC20Token.approve(ManualDepositPaymaster.address, constants.MaxUint256) + await ManualDepositPaymaster.addDepositFor(L2ERC20Token.address, account, utils.parseEther('2')) + + //the account approves the paymaster to use its tokens (in order for the paymaster to deduct fees from the account) + // this approve operation needs gas (in eth) because this step does not involve a paymaster + await env.l2Wallet.sendTransaction({ + value: utils.parseEther('2'), + to: account, + }) + + accountAPI = new SimpleAccountAPI({ + provider: env.l2Provider, + entryPointAddress, + //senderCreatorAddress: senderCreator.address, + owner: env.l2Wallet, + accountAddress: account + }) + + const approveOp = await accountAPI.createSignedUserOp({ + target: L2ERC20Token.address, + data: L2ERC20Token.interface.encodeFunctionData('approve', [ManualDepositPaymaster.address, constants.MaxUint256]), + }) + + preApproveTokenBalance = await L2ERC20Token.balanceOf(account) + preApproveDepositAmount = (await ManualDepositPaymaster.depositInfo(L2ERC20Token.address, account)).amount + preApproveEtherBalance = await env.l2Provider.getBalance(account) + + const requestId = await bundlerProvider.sendUserOpToBundler(approveOp) + const txid = await accountAPI.getUserOpReceipt(requestId) + console.log('reqId', requestId, 'txid=', txid) + + postApproveTokenBalance = await L2ERC20Token.balanceOf(account) + postApproveDepositAmount = (await ManualDepositPaymaster.depositInfo(L2ERC20Token.address, account)).amount + postApproveEtherBalance = await env.l2Provider.getBalance(account) + }) + + it('should be able to submit a userOp including the paymaster to the bundler and trigger tx', async () => { + const op = await accountAPI.createUnsignedUserOp({ + target: recipient.address, + data: recipient.interface.encodeFunctionData('something', ['hello']), + }) + op.paymasterAndData = hexConcat([ManualDepositPaymaster.address, hexZeroPad(L2ERC20Token.address, 20)]) + op.preVerificationGas = await accountAPI.getPreVerificationGas(op) + + signedOp = await accountAPI.signUserOp(op) + + const requestId = await bundlerProvider.sendUserOpToBundler(signedOp) + const txid = await accountAPI.getUserOpReceipt(requestId) + console.log('reqId', requestId, 'txid=', txid) + const receipt = await env.l2Provider.getTransactionReceipt(txid) + const returnedlogIndex = await getFilteredLogIndex( + receipt, + SampleRecipientJson.abi, + recipient.address, + 'Sender' + ) + const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) + // tx.origin is the bundler + expect(log.args.txOrigin).to.eq(env.l2Wallet.address) + // msg.sender is the 4337 wallet + console.log(log.args.msgSender) + expect(log.args.msgSender).to.eq(account) + // message is received and emitted + expect(log.args.message).to.eq('hello') + const postCallTokenBalance = await L2ERC20Token.balanceOf(account) + const postCallDepositAmount = (await ManualDepositPaymaster.depositInfo(L2ERC20Token.address, account)).amount + const postCallEtherBalance = await env.l2Provider.getBalance(account) + + const returnedEPlogIndex = await getFilteredLogIndex( + receipt, + EntryPointJson.abi, + entryPointAddress, + 'UserOperationEvent' + ) + const logEP = EntryPoint.interface.parseLog(receipt.logs[returnedEPlogIndex]) + + // no token is used when approving, ether balance is used to pay approval fees + expect(preApproveTokenBalance).to.eq(postApproveTokenBalance) + expect(preApproveEtherBalance).to.gt(postApproveEtherBalance) + // users deposit amount on paymaster remains constant and is unused throughout + expect(preApproveDepositAmount).to.eq(postApproveDepositAmount) + expect(postApproveDepositAmount).to.eq(postCallDepositAmount) + // no ether is used when calling the recipient with the help of the paymaster, users boba token is used to pay + expect(postApproveEtherBalance).to.eq(postCallEtherBalance) + expect(postApproveTokenBalance).to.gt(postCallTokenBalance) + expect(BigNumber.from(postCallTokenBalance).add(logEP.args.actualGasCost)).to.closeTo(BigNumber.from(postApproveTokenBalance), utils.parseEther('0.0001')) + }) + }) +}) diff --git a/integration-tests/test/eth-l2/boba_aa_fee_boba.spec.ts b/integration-tests/test/eth-l2/boba_aa_fee_boba.spec.ts index c6e33d2eba..48ca6ecfa0 100644 --- a/integration-tests/test/eth-l2/boba_aa_fee_boba.spec.ts +++ b/integration-tests/test/eth-l2/boba_aa_fee_boba.spec.ts @@ -1,223 +1,237 @@ -// import chai from 'chai' -// import chaiAsPromised from 'chai-as-promised' -// chai.use(chaiAsPromised) -// const expect = chai.expect - -// import { Contract, ContractFactory, utils, constants, BigNumber } from 'ethers' - -// import { getFilteredLogIndex } from './shared/utils' - -// import { OptimismEnv } from './shared/env' -// import { hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils' -// // use local sdk -// import { SimpleAccountAPI } from '@boba/bundler_sdk' -// import MockFeedRegistryJson from '@boba/accountabstraction/artifacts/contracts/test/mocks/MockFeedRegistry.sol/MockFeedRegistry.json' -// import SimpleAccountJson from '@boba/accountabstraction/artifacts/contracts/samples/SimpleAccount.sol/SimpleAccount.json' -// import L2GovernanceERC20Json from '@boba/contracts/artifacts/contracts/standards/L2GovernanceERC20.sol/L2GovernanceERC20.json' -// import EntryPointJson from '@boba/accountabstraction/artifacts/contracts/core/EntryPoint.sol/EntryPoint.json' -// import SampleRecipientJson from '../../artifacts/contracts/SampleRecipient.sol/SampleRecipient.json' -// import { HttpRpcClient } from '@boba/bundler_sdk/dist/src/HttpRpcClient' - -// import BobaDepositPaymasterJson from '@boba/accountabstraction/artifacts/contracts/samples/BobaDepositPaymaster.sol/BobaDepositPaymaster.json' - -// describe('AA Boba as Fee token Test\n', async () => { -// let env: OptimismEnv -// let SimpleAccount__factory: ContractFactory -// let recipient: Contract - -// let bundlerProvider: HttpRpcClient -// let entryPointAddress: string - -// let L2BOBAToken: Contract - -// let BobaDepositPaymaster__factory: ContractFactory -// let BobaDepositPaymaster: Contract - -// let PriceOracle__factory: ContractFactory -// let PriceOracle: Contract - -// let SampleRecipient__factory: ContractFactory - -// let EntryPoint: Contract - -// before(async () => { -// env = await OptimismEnv.new() -// entryPointAddress = env.addressesAABOBA.L2_BOBA_EntryPoint - -// SampleRecipient__factory = new ContractFactory( -// SampleRecipientJson.abi, -// SampleRecipientJson.bytecode, -// env.l2Wallet -// ) - -// recipient = await SampleRecipient__factory.deploy() - -// L2BOBAToken = new Contract( -// env.addressesBOBA.TOKENS.BOBA.L2, -// L2GovernanceERC20Json.abi, -// env.l2Wallet -// ) - -// bundlerProvider = new HttpRpcClient( -// env.bundlerUrl, -// entryPointAddress, -// await env.l2Wallet.provider.getNetwork().then((net) => net.chainId) -// ) - -// BobaDepositPaymaster__factory = new ContractFactory( -// BobaDepositPaymasterJson.abi, -// BobaDepositPaymasterJson.bytecode, -// env.l2Wallet -// ) - -// PriceOracle__factory = new ContractFactory( -// MockFeedRegistryJson.abi, -// MockFeedRegistryJson.bytecode, -// env.l2Wallet -// ) - -// PriceOracle = await PriceOracle__factory.deploy() - -// BobaDepositPaymaster = await BobaDepositPaymaster__factory.deploy( -// entryPointAddress, -// // ethPrice oracle -// PriceOracle.address -// ) - -// // add boba token -// await BobaDepositPaymaster.addToken( -// L2BOBAToken.address, -// // tokenPrice oracle -// PriceOracle.address, -// L2BOBAToken.address, -// 18 -// ) - -// EntryPoint = new Contract( -// entryPointAddress, -// EntryPointJson.abi, -// env.l2Wallet -// ) -// }) -// // this paymaster allows to sponsor txs in exchange for $BOBA tokens paid to it -// // this does not use the dual fee token system -// describe('A user without ETH pays for a tx through a paymaster that accepts $BOBA', async () => { -// let accountAPI: SimpleAccountAPI -// let account -// let preApproveTokenBalance -// let preApproveDepositAmount -// let preApproveEtherBalance -// let postApproveTokenBalance -// let postApproveDepositAmount -// let postApproveEtherBalance -// let signedOp - -// before('the paymaster operator sets up the paymaster by staking and adding deposits', async () => { -// await BobaDepositPaymaster.addStake(1, { value: utils.parseEther('2') }) -// await EntryPoint.depositTo(BobaDepositPaymaster.address, { -// value: utils.parseEther('1') -// }) -// }) - -// before('the user approves the paymaster to spend their $BOBA token', async () => { -// // deploy a 4337 Wallet and send operation to this wallet -// SimpleAccount__factory = new ContractFactory( -// SimpleAccountJson.abi, -// SimpleAccountJson.bytecode, -// env.l2Wallet -// ) -// account = await SimpleAccount__factory.deploy( -// entryPointAddress, -// env.l2Wallet.address -// ) -// await account.deployed() - -// await L2BOBAToken.transfer(account.address, utils.parseEther('1')) - -// await L2BOBAToken.approve(BobaDepositPaymaster.address, constants.MaxUint256) -// await BobaDepositPaymaster.addDepositFor(L2BOBAToken.address, account.address, utils.parseEther('2')) - -// await env.l2Wallet.sendTransaction({ -// value: utils.parseEther('2'), -// to: account.address, -// }) - -// accountAPI = new SimpleAccountAPI({ -// provider: env.l2Provider, -// entryPointAddress, -// owner: env.l2Wallet, -// walletAddress: account.address, -// }) - -// const approveOp = await accountAPI.createSignedUserOp({ -// target: L2BOBAToken.address, -// data: L2BOBAToken.interface.encodeFunctionData('approve', [BobaDepositPaymaster.address, constants.MaxUint256]), -// }) - -// preApproveTokenBalance = await L2BOBAToken.balanceOf(account.address) -// preApproveDepositAmount = (await BobaDepositPaymaster.depositInfo(L2BOBAToken.address, account.address)).amount -// preApproveEtherBalance = await env.l2Provider.getBalance(account.address) - -// const requestId = await bundlerProvider.sendUserOpToBundler(approveOp) -// const txid = await accountAPI.getUserOpReceipt(requestId) -// console.log('reqId', requestId, 'txid=', txid) - -// postApproveTokenBalance = await L2BOBAToken.balanceOf(account.address) -// postApproveDepositAmount = (await BobaDepositPaymaster.depositInfo(L2BOBAToken.address, account.address)).amount -// postApproveEtherBalance = await env.l2Provider.getBalance(account.address) -// }) -// it('should be able to submit a userOp including the paymaster to the bundler and trigger tx', async () => { -// const op = await accountAPI.createUnsignedUserOp({ -// target: recipient.address, -// data: recipient.interface.encodeFunctionData('something', ['hello']), -// }) - - -// // TODO: check why paymasterAndData does not work when added to the walletAPI -// op.paymasterAndData = hexConcat([BobaDepositPaymaster.address, hexZeroPad(L2BOBAToken.address, 20)]) -// op.preVerificationGas = await accountAPI.getPreVerificationGas(op) - -// signedOp = await accountAPI.signUserOp(op) - -// const requestId = await bundlerProvider.sendUserOpToBundler(signedOp) -// const txid = await accountAPI.getUserOpReceipt(requestId) -// console.log('reqId', requestId, 'txid=', txid) -// const receipt = await env.l2Provider.getTransactionReceipt(txid) -// const returnedlogIndex = await getFilteredLogIndex( -// receipt, -// SampleRecipientJson.abi, -// recipient.address, -// 'Sender' -// ) -// const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) -// // tx.origin is the bundler -// expect(log.args.txOrigin).to.eq(env.l2Wallet.address) -// // msg.sender is the 4337 wallet -// expect(log.args.msgSender).to.eq(account.address) -// // message is received and emitted -// expect(log.args.message).to.eq('hello') -// const postCallTokenBalance = await L2BOBAToken.balanceOf(account.address) -// const postCallDepositAmount = (await BobaDepositPaymaster.depositInfo(L2BOBAToken.address, account.address)).amount -// const postCallEtherBalance = await env.l2Provider.getBalance(account.address) - -// const returnedEPlogIndex = await getFilteredLogIndex( -// receipt, -// EntryPointJson.abi, -// entryPointAddress, -// 'UserOperationEvent' -// ) -// const logEP = EntryPoint.interface.parseLog(receipt.logs[returnedEPlogIndex]) - -// // no token is used when approving, ether balance is used to pay approval fees -// expect(preApproveTokenBalance).to.eq(postApproveTokenBalance) -// expect(preApproveEtherBalance).to.gt(postApproveEtherBalance) -// // users deposit amount on paymaster remains constant and is unused throughout -// expect(preApproveDepositAmount).to.eq(postApproveDepositAmount) -// expect(postApproveDepositAmount).to.eq(postCallDepositAmount) -// // no ether is used when calling the recipient with the help of the paymaster, users boba token is used to pay -// expect(postApproveEtherBalance).to.eq(postCallEtherBalance) -// expect(postApproveTokenBalance).to.gt(postCallTokenBalance) -// expect(BigNumber.from(postCallTokenBalance).add(logEP.args.actualGasCost)).to.closeTo(BigNumber.from(postApproveTokenBalance), utils.parseEther('0.0001')) -// }) -// }) -// }) \ No newline at end of file +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +chai.use(chaiAsPromised) +const expect = chai.expect + +import { Contract, ContractFactory, utils, constants, BigNumber } from 'ethers' + +import { getFilteredLogIndex } from './shared/utils' + +import { OptimismEnv } from './shared/env' +import { hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils' +// use local sdk +import { SimpleAccountAPI } from '@boba/bundler_sdk' +import SenderCreatorJson from '@boba/accountabstraction/artifacts/contracts/core/SenderCreator.sol/SenderCreator.json' +import SimpleAccountFactoryJson from '@boba/accountabstraction/artifacts/contracts/samples/SimpleAccountFactory.sol/SimpleAccountFactory.json' +import MockFeedRegistryJson from '@boba/accountabstraction/artifacts/contracts/test/mocks/MockFeedRegistry.sol/MockFeedRegistry.json' +import L2GovernanceERC20Json from '@boba/contracts/artifacts/contracts/standards/L2GovernanceERC20.sol/L2GovernanceERC20.json' +import EntryPointJson from '@boba/accountabstraction/artifacts/contracts/core/EntryPoint.sol/EntryPoint.json' +import SampleRecipientJson from '../../artifacts/contracts/SampleRecipient.sol/SampleRecipient.json' +import { HttpRpcClient } from '@boba/bundler_sdk/dist/HttpRpcClient' + +import BobaDepositPaymasterJson from '@boba/accountabstraction/artifacts/contracts/samples/BobaDepositPaymaster.sol/BobaDepositPaymaster.json' + +describe('AA Boba as Fee token Test\n', async () => { + let env: OptimismEnv + let SimpleAccount__factory: ContractFactory + let recipient: Contract + + let bundlerProvider: HttpRpcClient + let entryPointAddress: string + + let L2BOBAToken: Contract + + let BobaDepositPaymaster__factory: ContractFactory + let BobaDepositPaymaster: Contract + + let PriceOracle__factory: ContractFactory + let PriceOracle: Contract + + let SampleRecipient__factory: ContractFactory + + let EntryPoint: Contract + + before(async () => { + env = await OptimismEnv.new() + entryPointAddress = env.addressesAABOBA.L2_BOBA_EntryPoint + + SampleRecipient__factory = new ContractFactory( + SampleRecipientJson.abi, + SampleRecipientJson.bytecode, + env.l2Wallet + ) + + recipient = await SampleRecipient__factory.deploy() + + L2BOBAToken = new Contract( + env.addressesBOBA.TOKENS.BOBA.L2, + L2GovernanceERC20Json.abi, + env.l2Wallet + ) + + bundlerProvider = new HttpRpcClient( + env.bundlerUrl, + entryPointAddress, + await env.l2Wallet.provider.getNetwork().then((net) => net.chainId) + ) + + BobaDepositPaymaster__factory = new ContractFactory( + BobaDepositPaymasterJson.abi, + BobaDepositPaymasterJson.bytecode, + env.l2Wallet + ) + + PriceOracle__factory = new ContractFactory( + MockFeedRegistryJson.abi, + MockFeedRegistryJson.bytecode, + env.l2Wallet + ) + + PriceOracle = await PriceOracle__factory.deploy() + + BobaDepositPaymaster = await BobaDepositPaymaster__factory.deploy( + entryPointAddress, + // ethPrice oracle + PriceOracle.address + ) + + // add boba token + await BobaDepositPaymaster.addToken( + L2BOBAToken.address, + // tokenPrice oracle + PriceOracle.address, + L2BOBAToken.address, + 18 + ) + + EntryPoint = new Contract( + entryPointAddress, + EntryPointJson.abi, + env.l2Wallet + ) + }) + // this paymaster allows to sponsor txs in exchange for $BOBA tokens paid to it + // this does not use the dual fee token system + describe('A user without ETH pays for a tx through a paymaster that accepts $BOBA', async () => { + let accountAPI: SimpleAccountAPI + let account + let accountFactory + let preApproveTokenBalance + let preApproveDepositAmount + let preApproveEtherBalance + let postApproveTokenBalance + let postApproveDepositAmount + let postApproveEtherBalance + let signedOp + + before('the paymaster operator sets up the paymaster by staking and adding deposits', async () => { + await BobaDepositPaymaster.addStake(1, { value: utils.parseEther('2') }) + await EntryPoint.depositTo(BobaDepositPaymaster.address, { + value: utils.parseEther('1') + }) + }) + + before('the user approves the paymaster to spend their $BOBA token', async () => { + // deploy a 4337 Wallet and send operation to this wallet + SimpleAccount__factory = new ContractFactory( + SimpleAccountFactoryJson.abi, + SimpleAccountFactoryJson.bytecode, + env.l2Wallet + ) + accountFactory = await SimpleAccount__factory.deploy( + entryPointAddress + ) + await accountFactory.deployed() + console.log('Account Factory deployed to:', accountFactory.address) + await accountFactory.createAccount(env.l2Wallet.address, 0) + account = await accountFactory.getAddress(env.l2Wallet.address, 0) + console.log('Account deployed to:', account) + const SenderCreator__factory = new ContractFactory( + SenderCreatorJson.abi, + SenderCreatorJson.bytecode, + env.l2Wallet + ) + const senderCreator = await SenderCreator__factory.deploy() + console.log('Sender Creator Factory deployed to:', senderCreator.address) + + await L2BOBAToken.transfer(account, utils.parseEther('1')) + + await L2BOBAToken.approve(BobaDepositPaymaster.address, constants.MaxUint256) + await BobaDepositPaymaster.addDepositFor(L2BOBAToken.address, account, utils.parseEther('2')) + + //the account approves the paymaster to use its tokens (in order for the paymaster to deduct fees from the account) + // this approve operation needs gas (in eth) because this step does not involve a paymaster + await env.l2Wallet.sendTransaction({ + value: utils.parseEther('2'), + to: account, + }) + + accountAPI = new SimpleAccountAPI({ + provider: env.l2Provider, + entryPointAddress, + senderCreatorAddress: senderCreator.address, + owner: env.l2Wallet, + accountAddress: account + }) + + const approveOp = await accountAPI.createSignedUserOp({ + target: L2BOBAToken.address, + data: L2BOBAToken.interface.encodeFunctionData('approve', [BobaDepositPaymaster.address, constants.MaxUint256]), + }) + + preApproveTokenBalance = await L2BOBAToken.balanceOf(account) + preApproveDepositAmount = (await BobaDepositPaymaster.depositInfo(L2BOBAToken.address, account)).amount + preApproveEtherBalance = await env.l2Provider.getBalance(account) + + const requestId = await bundlerProvider.sendUserOpToBundler(approveOp) + const txid = await accountAPI.getUserOpReceipt(requestId) + console.log('reqId', requestId, 'txid=', txid) + + postApproveTokenBalance = await L2BOBAToken.balanceOf(account) + postApproveDepositAmount = (await BobaDepositPaymaster.depositInfo(L2BOBAToken.address, account)).amount + postApproveEtherBalance = await env.l2Provider.getBalance(account) + }) + it('should be able to submit a userOp including the paymaster to the bundler and trigger tx', async () => { + const op = await accountAPI.createUnsignedUserOp({ + target: recipient.address, + data: recipient.interface.encodeFunctionData('something', ['hello']), + }) + + // TODO: check why paymasterAndData does not work when added to the walletAPI + op.paymasterAndData = hexConcat([BobaDepositPaymaster.address, hexZeroPad(L2BOBAToken.address, 20)]) + op.preVerificationGas = await accountAPI.getPreVerificationGas(op) + + signedOp = await accountAPI.signUserOp(op) + + const requestId = await bundlerProvider.sendUserOpToBundler(signedOp) + const txid = await accountAPI.getUserOpReceipt(requestId) + console.log('reqId', requestId, 'txid=', txid) + const receipt = await env.l2Provider.getTransactionReceipt(txid) + const returnedlogIndex = await getFilteredLogIndex( + receipt, + SampleRecipientJson.abi, + recipient.address, + 'Sender' + ) + const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) + // tx.origin is the bundler + expect(log.args.txOrigin).to.eq(env.l2Wallet.address) + // msg.sender is the 4337 wallet + expect(log.args.msgSender).to.eq(account) + // message is received and emitted + expect(log.args.message).to.eq('hello') + const postCallTokenBalance = await L2BOBAToken.balanceOf(account) + const postCallDepositAmount = (await BobaDepositPaymaster.depositInfo(L2BOBAToken.address, account)).amount + const postCallEtherBalance = await env.l2Provider.getBalance(account) + + const returnedEPlogIndex = await getFilteredLogIndex( + receipt, + EntryPointJson.abi, + entryPointAddress, + 'UserOperationEvent' + ) + const logEP = EntryPoint.interface.parseLog(receipt.logs[returnedEPlogIndex]) + + // no token is used when approving, ether balance is used to pay approval fees + expect(preApproveTokenBalance).to.eq(postApproveTokenBalance) + expect(preApproveEtherBalance).to.gt(postApproveEtherBalance) + // users deposit amount on paymaster remains constant and is unused throughout + expect(preApproveDepositAmount).to.eq(postApproveDepositAmount) + expect(postApproveDepositAmount).to.eq(postCallDepositAmount) + // no ether is used when calling the recipient with the help of the paymaster, users boba token is used to pay + expect(postApproveEtherBalance).to.eq(postCallEtherBalance) + expect(postApproveTokenBalance).to.gt(postCallTokenBalance) + expect(BigNumber.from(postCallTokenBalance).add(logEP.args.actualGasCost)).to.closeTo(BigNumber.from(postApproveTokenBalance), utils.parseEther('0.0001')) + }) + }) +}) diff --git a/integration-tests/test/eth-l2/boba_aa_sponsoring_fee.spec.ts b/integration-tests/test/eth-l2/boba_aa_sponsoring_fee.spec.ts index 116478a49d..6f375fecff 100644 --- a/integration-tests/test/eth-l2/boba_aa_sponsoring_fee.spec.ts +++ b/integration-tests/test/eth-l2/boba_aa_sponsoring_fee.spec.ts @@ -1,158 +1,175 @@ -// import chai from 'chai' -// import chaiAsPromised from 'chai-as-promised' -// chai.use(chaiAsPromised) -// const expect = chai.expect - -// import { Contract, ContractFactory, utils, constants, BigNumber, Wallet } from 'ethers' - -// import { getFilteredLogIndex } from './shared/utils' - -// import { OptimismEnv } from './shared/env' -// import { hexConcat } from 'ethers/lib/utils' -// // use local sdk -// import { SimpleAccountAPI } from '@boba/bundler_sdk' -// import SimpleAccountJson from '@boba/accountabstraction/artifacts/contracts/samples/SimpleAccount.sol/SimpleAccount.json' -// import EntryPointJson from '@boba/accountabstraction/artifacts/contracts/core/EntryPoint.sol/EntryPoint.json' -// import SampleRecipientJson from '../../artifacts/contracts/SampleRecipient.sol/SampleRecipient.json' -// import { HttpRpcClient } from '@boba/bundler_sdk/dist/src/HttpRpcClient' - -// import VerifyingPaymasterJson from '@boba/accountabstraction/artifacts/contracts/samples/VerifyingPaymaster.sol/VerifyingPaymaster.json' - -// describe('Sponsoring Tx\n', async () => { -// let env: OptimismEnv -// let SimpleAccount__factory: ContractFactory -// let recipient: Contract - -// let bundlerProvider: HttpRpcClient -// let entryPointAddress: string - -// let VerifyingPaymaster__factory: ContractFactory -// let VerifyingPaymaster: Contract - -// let SampleRecipient__factory: ContractFactory - -// let EntryPoint: Contract - -// let offchainSigner: Wallet - -// before(async () => { -// env = await OptimismEnv.new() -// entryPointAddress = env.addressesAABOBA.L2_BOBA_EntryPoint - -// SampleRecipient__factory = new ContractFactory( -// SampleRecipientJson.abi, -// SampleRecipientJson.bytecode, -// env.l2Wallet -// ) - -// recipient = await SampleRecipient__factory.deploy() - -// bundlerProvider = new HttpRpcClient( -// env.bundlerUrl, -// entryPointAddress, -// await env.l2Wallet.provider.getNetwork().then((net) => net.chainId) -// ) - -// SimpleAccount__factory = new ContractFactory( -// SimpleAccountJson.abi, -// SimpleAccountJson.bytecode, -// env.l2Wallet -// ) - -// VerifyingPaymaster__factory = new ContractFactory( -// VerifyingPaymasterJson.abi, -// VerifyingPaymasterJson.bytecode, -// env.l2Wallet -// ) - -// offchainSigner = env.l2Wallet_2 -// VerifyingPaymaster = await VerifyingPaymaster__factory.deploy( -// entryPointAddress, -// // ethPrice oracle -// offchainSigner.address -// ) - -// EntryPoint = new Contract( -// entryPointAddress, -// EntryPointJson.abi, -// env.l2Wallet -// ) -// }) -// describe('A user has no fee token, but pays for a transaction through a willing paymaster\n', async () => { -// let accountAPI: SimpleAccountAPI -// let signedOp -// let account - -// before('the paymaster operator sets up the paymaster by staking and adding deposits', async () => { -// await VerifyingPaymaster.addStake(1, { value: utils.parseEther('2') }) -// await EntryPoint.depositTo(VerifyingPaymaster.address, { -// value: utils.parseEther('1') -// }) -// }) -// before('account is created and accountAPI is setup', async () => { -// // deploy a 4337 Wallet and send operation to this wallet -// account = await SimpleAccount__factory.deploy( -// entryPointAddress, -// env.l2Wallet_4.address -// ) -// await account.deployed() - -// accountAPI = new SimpleAccountAPI({ -// provider: env.l2Provider, -// entryPointAddress, -// owner: env.l2Wallet_4, -// walletAddress: account.address, -// }) -// }) -// it('should be able to submit a userOp to the bundler and trigger tx', async () => { -// const op = await accountAPI.createSignedUserOp({ -// target: recipient.address, -// data: recipient.interface.encodeFunctionData('something', ['hello']), -// }) -// // add preverificaiton gas to account for paymaster signature -// op.preVerificationGas = BigNumber.from(await op.preVerificationGas).add(3000) - -// const hash = await VerifyingPaymaster.getHash(op) -// const sig = await offchainSigner.signMessage(utils.arrayify(hash)) - - -// op.paymasterAndData = hexConcat([VerifyingPaymaster.address, sig]) - -// signedOp = await accountAPI.signUserOp(op) - -// const preUserBalance = await env.l2Provider.getBalance(env.l2Wallet_4.address) -// const prePaymasterDeposit = await VerifyingPaymaster.getDeposit() - -// const requestId = await bundlerProvider.sendUserOpToBundler(signedOp) -// const txid = await accountAPI.getUserOpReceipt(requestId) -// console.log('reqId', requestId, 'txid=', txid) -// const receipt = await env.l2Provider.getTransactionReceipt(txid) -// const returnedlogIndex = await getFilteredLogIndex( -// receipt, -// SampleRecipientJson.abi, -// recipient.address, -// 'Sender' -// ) -// const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) -// // tx.origin is the bundler -// expect(log.args.txOrigin).to.eq(env.l2Wallet.address) -// // msg.sender is the 4337 wallet -// expect(log.args.msgSender).to.eq(account.address) -// // message is received and emitted -// expect(log.args.message).to.eq('hello') - -// const returnedEPlogIndex = await getFilteredLogIndex( -// receipt, -// EntryPointJson.abi, -// entryPointAddress, -// 'UserOperationEvent' -// ) -// const logEP = EntryPoint.interface.parseLog(receipt.logs[returnedEPlogIndex]) -// const postUserBalance = await env.l2Provider.getBalance(env.l2Wallet_4.address) -// const postPaymasterDeposit = await VerifyingPaymaster.getDeposit() - -// expect(postUserBalance).to.eq(preUserBalance) -// expect(postPaymasterDeposit).to.eq(prePaymasterDeposit.sub(logEP.args.actualGasCost)) -// }) -// }) -// }) +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +chai.use(chaiAsPromised) +const expect = chai.expect + +import { Contract, ContractFactory, utils, BigNumber, Wallet } from 'ethers' + +import { getFilteredLogIndex } from './shared/utils' + +import { OptimismEnv } from './shared/env' +import { hexConcat, defaultAbiCoder } from 'ethers/lib/utils' +// use local sdk +import { SimpleAccountAPI } from '@boba/bundler_sdk' +import SimpleAccountFactoryJson from '@boba/accountabstraction/artifacts/contracts/samples/SimpleAccountFactory.sol/SimpleAccountFactory.json' +import EntryPointJson from '@boba/accountabstraction/artifacts/contracts/core/EntryPoint.sol/EntryPoint.json' +import SampleRecipientJson from '../../artifacts/contracts/SampleRecipient.sol/SampleRecipient.json' +import SenderCreatorJson from '@boba/accountabstraction/artifacts/contracts/core/SenderCreator.sol/SenderCreator.json' +import { HttpRpcClient } from '@boba/bundler_sdk/dist/HttpRpcClient' + +import VerifyingPaymasterJson from '@boba/accountabstraction/artifacts/contracts/samples/VerifyingPaymaster.sol/VerifyingPaymaster.json' + +describe('Sponsoring Tx\n', async () => { + let env: OptimismEnv + let SimpleAccount__factory: ContractFactory + let recipient: Contract + + let bundlerProvider: HttpRpcClient + let entryPointAddress: string + + let VerifyingPaymaster__factory: ContractFactory + let VerifyingPaymaster: Contract + + let SampleRecipient__factory: ContractFactory + + let EntryPoint: Contract + + let offchainSigner: Wallet + + before(async () => { + env = await OptimismEnv.new() + entryPointAddress = env.addressesAABOBA.L2_BOBA_EntryPoint + + SampleRecipient__factory = new ContractFactory( + SampleRecipientJson.abi, + SampleRecipientJson.bytecode, + env.l2Wallet + ) + + recipient = await SampleRecipient__factory.deploy() + + bundlerProvider = new HttpRpcClient( + env.bundlerUrl, + entryPointAddress, + await env.l2Wallet.provider.getNetwork().then((net) => net.chainId) + ) + + SimpleAccount__factory = new ContractFactory( + SimpleAccountFactoryJson.abi, + SimpleAccountFactoryJson.bytecode, + env.l2Wallet_4 + ) + + VerifyingPaymaster__factory = new ContractFactory( + VerifyingPaymasterJson.abi, + VerifyingPaymasterJson.bytecode, + env.l2Wallet + ) + + offchainSigner = env.l2Wallet_2 + VerifyingPaymaster = await VerifyingPaymaster__factory.deploy( + entryPointAddress, + // ethPrice oracle + offchainSigner.address + ) + + EntryPoint = new Contract( + entryPointAddress, + EntryPointJson.abi, + env.l2Wallet + ) + }) + describe('A user has no fee token, but pays for a transaction through a willing paymaster\n', async () => { + let accountAPI: SimpleAccountAPI + let signedOp + let account + + before('the paymaster operator sets up the paymaster by staking and adding deposits', async () => { + await VerifyingPaymaster.addStake(1, { value: utils.parseEther('2') }) + await EntryPoint.depositTo(VerifyingPaymaster.address, { + value: utils.parseEther('1') + }) + }) + before('account is created and accountAPI is setup', async () => { + // deploy a 4337 Wallet and send operation to this wallet + const accountFactory = await SimpleAccount__factory.deploy( + entryPointAddress + ) + await accountFactory.deployed() + console.log('Account Factory deployed to:', accountFactory.address) + + // deploy a senderCreator contract to get the create2 address on the provide + const SenderCreator__factory = new ContractFactory( + SenderCreatorJson.abi, + SenderCreatorJson.bytecode, + env.l2Wallet_4 + ) + + const senderCreator = await SenderCreator__factory.deploy() + + accountAPI = new SimpleAccountAPI({ + provider: env.l2Provider, + entryPointAddress, + senderCreatorAddress: senderCreator.address, + owner: env.l2Wallet_4, + factoryAddress: accountFactory.address, + }) + }) + it('should be able to submit a userOp to the bundler and trigger tx', async () => { + const validUntil = (await env.l2Provider.getBlock('latest')).timestamp + 600 + const validAfter = (await env.l2Provider.getBlock('latest')).timestamp - 600 + const op = await accountAPI.createSignedUserOp({ + target: recipient.address, + data: recipient.interface.encodeFunctionData('something', ['hello']), + }) + + // add preverificaiton gas to account for paymaster signature + op.paymasterAndData = hexConcat([VerifyingPaymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [validUntil, validAfter]), '0x' + '00'.repeat(65)]) + op.preVerificationGas = BigNumber.from(await op.preVerificationGas).add(3000) + const hash = await VerifyingPaymaster.getHash(op, validUntil, validAfter) + const sig = await offchainSigner.signMessage(utils.arrayify(hash)) + + op.paymasterAndData = hexConcat([VerifyingPaymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [validUntil, validAfter]), sig]) + const res = await VerifyingPaymaster.parsePaymasterAndData(op.paymasterAndData) + + expect(res.signature).to.eq(sig) + expect(res.validAfter).to.eq(validAfter) + expect(res.validUntil).to.eq(validUntil) + signedOp = await accountAPI.signUserOp(op) + + const preUserBalance = await env.l2Provider.getBalance(env.l2Wallet_4.address) + const prePaymasterDeposit = await VerifyingPaymaster.getDeposit() + + const requestId = await bundlerProvider.sendUserOpToBundler(signedOp) + const txid = await accountAPI.getUserOpReceipt(requestId) + console.log('reqId', requestId, 'txid=', txid) + const receipt = await env.l2Provider.getTransactionReceipt(txid) + const returnedlogIndex = await getFilteredLogIndex( + receipt, + SampleRecipientJson.abi, + recipient.address, + 'Sender' + ) + const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) + // tx.origin is the bundler + expect(log.args.txOrigin).to.eq(env.l2Wallet.address) + // msg.sender is the 4337 wallet + expect(log.args.msgSender).to.eq(await signedOp.sender) + // message is received and emitted + expect(log.args.message).to.eq('hello') + + const returnedEPlogIndex = await getFilteredLogIndex( + receipt, + EntryPointJson.abi, + entryPointAddress, + 'UserOperationEvent' + ) + const logEP = EntryPoint.interface.parseLog(receipt.logs[returnedEPlogIndex]) + const postUserBalance = await env.l2Provider.getBalance(env.l2Wallet_4.address) + const postPaymasterDeposit = await VerifyingPaymaster.getDeposit() + + expect(postUserBalance).to.eq(preUserBalance) + expect(postPaymasterDeposit).to.eq(prePaymasterDeposit.sub(logEP.args.actualGasCost)) + }) + }) +}) diff --git a/integration-tests/test/eth-l2/boba_aa_wallet.spec.ts b/integration-tests/test/eth-l2/boba_aa_wallet.spec.ts index 8ff2802f90..d311ecef27 100644 --- a/integration-tests/test/eth-l2/boba_aa_wallet.spec.ts +++ b/integration-tests/test/eth-l2/boba_aa_wallet.spec.ts @@ -1,209 +1,205 @@ -// import chai from 'chai' -// import chaiAsPromised from 'chai-as-promised' -// chai.use(chaiAsPromised) -// const expect = chai.expect - -// import { Contract, ContractFactory, utils } from 'ethers' - -// import { getFilteredLogIndex, l2Wallet } from './shared/utils' - -// import { OptimismEnv } from './shared/env' -// // use local sdk -// import { SimpleAccountAPI, wrapProvider } from '@boba/bundler_sdk' -// // change this to using factory -// import SimpleAccountDeployerJson from '@boba/accountabstraction/artifacts/contracts/samples/SimpleAccountDeployer.sol/SimpleAccountDeployer.json' -// import SimpleAccountJson from '@boba/accountabstraction/artifacts/contracts/samples/SimpleAccount.sol/SimpleAccount.json' -// import SenderCreatorJson from '@boba/accountabstraction/artifacts/contracts/core/SenderCreator.sol/SenderCreator.json' -// import SampleRecipientJson from '../../artifacts/contracts/SampleRecipient.sol/SampleRecipient.json' -// import { HttpRpcClient } from '@boba/bundler_sdk/dist/src/HttpRpcClient' - -// describe('AA Wallet Test\n', async () => { -// let env: OptimismEnv -// let SimpleAccount__factory: ContractFactory -// let recipient: Contract - -// let bundlerProvider: HttpRpcClient -// let entryPointAddress: string - -// let SampleRecipient__factory: ContractFactory - -// before(async () => { -// env = await OptimismEnv.new() -// entryPointAddress = env.addressesAABOBA.L2_BOBA_EntryPoint - -// SimpleAccount__factory = new ContractFactory( -// SimpleAccountJson.abi, -// SimpleAccountJson.bytecode, -// env.l2Wallet -// ) - -// SampleRecipient__factory = new ContractFactory( -// SampleRecipientJson.abi, -// SampleRecipientJson.bytecode, -// env.l2Wallet -// ) - -// recipient = await SampleRecipient__factory.deploy() -// console.log('recipient', recipient.address) - -// bundlerProvider = new HttpRpcClient( -// env.bundlerUrl, -// entryPointAddress, -// await env.l2Wallet.provider.getNetwork().then((net) => net.chainId) -// ) -// }) -// it('should be able to send a userOperation to a wallet through the bundler (low level api)', async () => { -// // deploy a 4337 Wallet and send operation to this wallet -// const account = await SimpleAccount__factory.deploy( -// entryPointAddress, -// env.l2Wallet.address -// ) -// await account.deployed() -// console.log('Account deployed to:', account.address) - -// await env.l2Wallet.sendTransaction({ -// value: utils.parseEther('2'), -// to: account.address, -// }) - -// const accountAPI = new SimpleAccountAPI({ -// provider: env.l2Provider, -// entryPointAddress, -// owner: env.l2Wallet, -// walletAddress: account.address, -// }) - -// const op = await accountAPI.createSignedUserOp({ -// target: recipient.address, -// data: recipient.interface.encodeFunctionData('something', ['hello']), -// }) - -// expect(await op.sender).to.be.eq(account.address) - -// const requestId = await bundlerProvider.sendUserOpToBundler(op) -// const txid = await accountAPI.getUserOpReceipt(requestId) -// console.log('reqId', requestId, 'txid=', txid) -// const receipt = await env.l2Provider.getTransactionReceipt(txid) -// const returnedlogIndex = await getFilteredLogIndex( -// receipt, -// SampleRecipientJson.abi, -// recipient.address, -// 'Sender' -// ) -// const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) -// // tx.origin is the bundler -// expect(log.args.txOrigin).to.eq(env.l2Wallet.address) -// // msg.sender is the 4337 wallet -// expect(log.args.msgSender).to.eq(account.address) -// // message is received and emitted -// expect(log.args.message).to.eq('hello') -// }) -// it('should be able to send a userOperation to a wallet through the bundler (high level api)', async () => { -// // deploy a senderCreator contract to get the create2 address on the provide -// const SenderCreator__factory = new ContractFactory( -// SenderCreatorJson.abi, -// SenderCreatorJson.bytecode, -// env.l2Wallet -// ) - -// const senderCreator = await SenderCreator__factory.deploy() - -// const aasigner = env.l2Provider.getSigner() -// const config = { -// chainId: await env.l2Provider.getNetwork().then(net => net.chainId), -// entryPointAddress, -// bundlerUrl: env.bundlerUrl -// } - -// const aaProvider = await wrapProvider(env.l2Provider, config, aasigner, env.l2Wallet_3, senderCreator.address) - -// const walletAddress = await aaProvider.getSigner().getAddress() -// await env.l2Wallet.sendTransaction({ -// value: utils.parseEther('2'), -// to: walletAddress, -// }) - -// recipient = recipient.connect(aaProvider.getSigner()) -// const tx = await recipient.something('hello') -// const receipt = await tx.wait() -// const returnedlogIndex = await getFilteredLogIndex( -// receipt, -// SampleRecipientJson.abi, -// recipient.address, -// 'Sender' -// ) -// const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) -// // tx.origin is the bundler -// expect(log.args.txOrigin).to.eq(env.l2Wallet.address) -// // msg.sender is the 4337 wallet -// expect(log.args.msgSender).to.eq(walletAddress) -// // message is received and emitted -// expect(log.args.message).to.eq('hello') -// }) -// it('should deploy a wallet if it does not exist through initCode', async () => { -// // Deploy WalletDeployer -// const SimpleAccountDeployer__factory = new ContractFactory( -// SimpleAccountDeployerJson.abi, -// SimpleAccountDeployerJson.bytecode, -// env.l2Wallet_2 -// ) -// const SimpleAccountDeployer = await SimpleAccountDeployer__factory.deploy() -// console.log('factory deployed to', SimpleAccountDeployer.address) - -// // deploy a senderCreator contract to get the create2 address on the provide -// const SenderCreator__factory = new ContractFactory( -// SenderCreatorJson.abi, -// SenderCreatorJson.bytecode, -// env.l2Wallet -// ) - -// const senderCreator = await SenderCreator__factory.deploy() - -// const accountAPI = new SimpleAccountAPI({ -// provider: env.l2Provider, -// entryPointAddress, -// senderCreatorAddress: senderCreator.address, -// owner: env.l2Wallet_2, -// factoryAddress: SimpleAccountDeployer.address, -// }) - -// const accountAddress = await accountAPI.getWalletAddress() -// // computed address is correct -// expect(accountAddress).to.be.eq(await SimpleAccountDeployer.getAddress(entryPointAddress, env.l2Wallet_2.address, 0)) - -// await env.l2Wallet.sendTransaction({ -// value: utils.parseEther('2'), -// to: accountAddress, -// }) - -// const op = await accountAPI.createSignedUserOp({ -// target: recipient.address, -// data: recipient.interface.encodeFunctionData('something', ['hello']), -// }) - -// expect(await op.sender).to.be.eq(accountAddress) -// const preAccountCode = await env.l2Provider.getCode(op.sender) -// expect(preAccountCode).to.be.eq('0x') - -// const requestId = await bundlerProvider.sendUserOpToBundler(op) -// const txid = await accountAPI.getUserOpReceipt(requestId) -// console.log('reqId', requestId, 'txid=', txid) -// const receipt = await env.l2Provider.getTransactionReceipt(txid) -// const returnedlogIndex = await getFilteredLogIndex( -// receipt, -// SampleRecipientJson.abi, -// recipient.address, -// 'Sender' -// ) -// const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) -// // tx.origin is the bundler -// expect(log.args.txOrigin).to.eq(env.l2Wallet.address) -// // msg.sender is the 4337 wallet -// expect(log.args.msgSender).to.eq(accountAddress) -// // message is received and emitted -// expect(log.args.message).to.eq('hello') - -// const postAccountCode = await env.l2Provider.getCode(op.sender) -// expect(postAccountCode).to.be.not.eq('0x') -// }) -// }) +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +chai.use(chaiAsPromised) +const expect = chai.expect + +import { Contract, ContractFactory, utils } from 'ethers' + +import { getFilteredLogIndex, l2Wallet } from './shared/utils' + +import { OptimismEnv } from './shared/env' +// use local sdk +import { SimpleAccountAPI, wrapProvider } from '@boba/bundler_sdk' +import SimpleAccountFactoryJson from '@boba/accountabstraction/artifacts/contracts/samples/SimpleAccountFactory.sol/SimpleAccountFactory.json' +import SenderCreatorJson from '@boba/accountabstraction/artifacts/contracts/core/SenderCreator.sol/SenderCreator.json' +import SampleRecipientJson from '../../artifacts/contracts/SampleRecipient.sol/SampleRecipient.json' +import { HttpRpcClient } from '@boba/bundler_sdk/dist/HttpRpcClient' + +describe('AA Wallet Test\n', async () => { + let env: OptimismEnv + let SimpleAccount__factory: ContractFactory + let recipient: Contract + + let bundlerProvider: HttpRpcClient + let entryPointAddress: string + + let SampleRecipient__factory: ContractFactory + + before(async () => { + env = await OptimismEnv.new() + entryPointAddress = env.addressesAABOBA.L2_BOBA_EntryPoint + + SimpleAccount__factory = new ContractFactory( + SimpleAccountFactoryJson.abi, + SimpleAccountFactoryJson.bytecode, + env.l2Wallet + ) + + SampleRecipient__factory = new ContractFactory( + SampleRecipientJson.abi, + SampleRecipientJson.bytecode, + env.l2Wallet + ) + + recipient = await SampleRecipient__factory.deploy() + console.log('recipient', recipient.address) + + bundlerProvider = new HttpRpcClient( + env.bundlerUrl, + entryPointAddress, + await env.l2Wallet.provider.getNetwork().then((net) => net.chainId) + ) + }) + it('should be able to send a userOperation to a wallet through the bundler (low level api)', async () => { + // deploy a 4337 Wallet and send operation to this wallet + const accountFactory = await SimpleAccount__factory.deploy( + entryPointAddress, + { gasLimit: 9_500_000 } + ) + await accountFactory.deployed() + console.log('Account Factory deployed to:', accountFactory.address) + await accountFactory.createAccount(env.l2Wallet.address, 0) + const account = await accountFactory.getAddress(env.l2Wallet.address, 0) + console.log('Account deployed to:', account) + + const accountAPI = new SimpleAccountAPI({ + provider: env.l2Provider, + entryPointAddress, + owner: env.l2Wallet, + accountAddress: account + }) + + const op = await accountAPI.createSignedUserOp({ + target: recipient.address, + data: recipient.interface.encodeFunctionData('something', ['hello']), + }) + await env.l2Wallet.sendTransaction({ + value: utils.parseEther('2'), + to: await op.sender, + }) + + expect(await op.sender).to.be.eq(account) + + const requestId = await bundlerProvider.sendUserOpToBundler(op) + const txid = await accountAPI.getUserOpReceipt(requestId) + console.log('reqId', requestId, 'txid=', txid) + const receipt = await env.l2Provider.getTransactionReceipt(txid) + const returnedlogIndex = await getFilteredLogIndex( + receipt, + SampleRecipientJson.abi, + recipient.address, + 'Sender' + ) + const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) + + expect(log.args.txOrigin).to.eq(env.l2Wallet.address) + expect(log.args.msgSender).to.eq(account) + // message is received and emitted + expect(log.args.message).to.eq('hello') + }) + it('should be able to send a userOperation to a wallet through the bundler (high level api)', async () => { + // deploy a senderCreator contract to get the create2 address on the provide + const SenderCreator__factory = new ContractFactory( + SenderCreatorJson.abi, + SenderCreatorJson.bytecode, + env.l2Wallet + ) + + const senderCreator = await SenderCreator__factory.deploy() + + const aasigner = env.l2Provider.getSigner() + const config = { + chainId: await env.l2Provider.getNetwork().then(net => net.chainId), + entryPointAddress, + bundlerUrl: env.bundlerUrl + } + + const aaProvider = await wrapProvider(env.l2Provider, config, aasigner, env.l2Wallet_3, senderCreator.address) + + const walletAddress = await aaProvider.getSigner().getAddress() + await env.l2Wallet.sendTransaction({ + value: utils.parseEther('2'), + to: walletAddress, + }) + + recipient = recipient.connect(aaProvider.getSigner()) + const tx = await recipient.something('hello') + const receipt = await tx.wait() + const returnedlogIndex = await getFilteredLogIndex( + receipt, + SampleRecipientJson.abi, + recipient.address, + 'Sender' + ) + const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) + // tx.origin is the bundler + expect(log.args.txOrigin).to.eq(env.l2Wallet.address) + // msg.sender is the 4337 wallet + expect(log.args.msgSender).to.eq(walletAddress) + // message is received and emitted + expect(log.args.message).to.eq('hello') + }) + it('should deploy a wallet if it does not exist through initCode', async () => { + + const accountFactory = await SimpleAccount__factory.deploy(entryPointAddress) + await accountFactory.deployed() + console.log('Account Factory deployed to:', accountFactory.address) + + // deploy a senderCreator contract to get the create2 address on the provide + const SenderCreator__factory = new ContractFactory( + SenderCreatorJson.abi, + SenderCreatorJson.bytecode, + env.l2Wallet + ) + + const senderCreator = await SenderCreator__factory.deploy() + + const accountAPI = new SimpleAccountAPI({ + provider: env.l2Provider, + entryPointAddress, + senderCreatorAddress: senderCreator.address, + owner: env.l2Wallet_2, + factoryAddress: accountFactory.address, + }) + + const accountAddress = await accountAPI.getAccountAddress() + + // computed address is correct + expect(accountAddress).to.be.eq(await accountFactory.getAddress(env.l2Wallet_2.address, 0)) + + await env.l2Wallet.sendTransaction({ + value: utils.parseEther('2'), + to: accountAddress, + }) + + const op = await accountAPI.createSignedUserOp({ + target: recipient.address, + data: recipient.interface.encodeFunctionData('something', ['hello']), + }) + + expect(await op.sender).to.be.eq(accountAddress) + const preAccountCode = await env.l2Provider.getCode(op.sender) + expect(preAccountCode).to.be.eq('0x') + + const requestId = await bundlerProvider.sendUserOpToBundler(op) + const txid = await accountAPI.getUserOpReceipt(requestId) + console.log('reqId', requestId, 'txid=', txid) + const receipt = await env.l2Provider.getTransactionReceipt(txid) + const returnedlogIndex = await getFilteredLogIndex( + receipt, + SampleRecipientJson.abi, + recipient.address, + 'Sender' + ) + const log = recipient.interface.parseLog(receipt.logs[returnedlogIndex]) + // tx.origin is the bundler + expect(log.args.txOrigin).to.eq(env.l2Wallet.address) + // msg.sender is the 4337 wallet + expect(log.args.msgSender).to.eq(accountAddress) + // message is received and emitted + expect(log.args.message).to.eq('hello') + + const postAccountCode = await env.l2Provider.getCode(op.sender) + expect(postAccountCode).to.be.not.eq('0x') + }) +}) diff --git a/ops/docker-compose-side.yml b/ops/docker-compose-side.yml index edb058595c..daee8da500 100644 --- a/ops/docker-compose-side.yml +++ b/ops/docker-compose-side.yml @@ -37,9 +37,7 @@ services: L2_NODE_WEB3_URL: http://l2geth:8545 GAS_PRICE_ORACLE_ADDRESS: "0x420000000000000000000000000000000000000F" ADDRESS_MANAGER_ADDRESS: "0x5FbDB2315678afecb367f032d93F642f64180aa3" - << : *gas-price-oracle_pk #same as the deployer - << : *relayer_pk - << : *fast-relayer_pk + << : [*gas-price-oracle_pk, *relayer_pk, *fast-relayer_pk] SEQUENCER_ADDRESS: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8" PROPOSER_ADDRESS: "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc" GAS_PRICE_ORACLE_FLOOR_PRICE: 1 @@ -122,38 +120,39 @@ services: deploy: replicas: 0 - # bundler: - # depends_on: - # - l1_chain - # - dtl - # - l2geth - # - boba_deployer - # - aa_deployer - # image: bobanetwork/bundler:latest - # build: - # context: .. - # dockerfile: ./ops/docker/Dockerfile.packages - # target: bundler - # deploy: - # replicas: 1 - # ports: [ '3000:3000' ] - # restart: on-failure - # environment: - # MIN_BALANCE: 0 - # URL: http://dtl:8081/addresses.json - # AA_DEPLOYER: http://dtl:8081/aa-addr.json - # MNEMONIC_OR_PK: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" - # L1_NODE_WEB3_URL: http://l1_chain:8545 - # L2_NODE_WEB3_URL: http://l2geth:8545 - # ENTRYPOINT: "0x" - # BENEFICIARY: "0xcd3b766ccdd6ae721141f452c550ca635964ce71" - # HELPER: "0x" - # mem_limit: 300M - # logging: - # driver: "json-file" - # options: - # max-size: 10m - # max-file: "10" + bundler: + depends_on: + - l1_chain + - dtl + - l2geth + - boba_deployer + - aa_deployer + image: bobanetwork/bundler:latest + build: + context: .. + dockerfile: ./ops/docker/Dockerfile.packages + target: bundler + deploy: + replicas: 1 + ports: [ '3000:3000' ] + restart: on-failure + environment: + MIN_BALANCE: 0 + URL: http://dtl:8081/addresses.json + AA_DEPLOYER: http://dtl:8081/aa-addr.json + MNEMONIC_OR_PK: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + L1_NODE_WEB3_URL: http://l1_chain:8545 + L2_NODE_WEB3_URL: http://l2geth:8545 + BENEFICIARY: "0xcd3b766ccdd6ae721141f452c550ca635964ce71" + MAX_BUNDLE_GAS: 5000000 + #should be set to false in prod! + UNSAFE: 'true' + mem_limit: 300M + logging: + driver: "json-file" + options: + max-size: 10m + max-file: "10" networks: default: diff --git a/ops/docker-compose.yml b/ops/docker-compose.yml index 34efec5191..3138c279c3 100644 --- a/ops/docker-compose.yml +++ b/ops/docker-compose.yml @@ -116,8 +116,7 @@ services: FRAUD_PROOF_WINDOW_SECONDS: 0 L1_NODE_WEB3_URL: http://l1_chain:8545 # these keys are hardhat's first 2 accounts, DO NOT use in production - << : *deployer_pk - << : *relayer_pk + << : [*deployer_pk, *relayer_pk] SEQUENCER_ADDRESS: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8" PROPOSER_ADDRESS: "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc" # setting the whitelist owner to address(0) disables the whitelist @@ -158,9 +157,7 @@ services: ADDRESS_MANAGER_ADDRESS: "0x5FbDB2315678afecb367f032d93F642f64180aa3" URL: http://dtl:8081/addresses.json # DO NOT use in production - << : *deployer_pk - << : *relayer_pk - << : *fast-relayer_pk + << : [*deployer_pk, *relayer_pk, *fast-relayer_pk] RETRIES: 200 DTL_REGISTRY_URL: http://dtl:8081/boba-addr.json # skip compilation when run in docker-compose, since the contracts @@ -336,10 +333,7 @@ services: NO_NETWORK: 1 RETRIES: 200 L2_CHAINID: 31338 #unfortunately, elsewhere the L2_CHAINID is called CHAIN_ID - << : *integration_pk - << : *integration_2_pk - << : *integration_3_pk - << : *bobalink_pk + << : [*integration_pk, *integration_2_pk, *integration_3_pk, *bobalink_pk] volumes: - ~/result:/opt/optimism/integration-tests/result diff --git a/ops/docker/Dockerfile.data-transport-layer b/ops/docker/Dockerfile.data-transport-layer index a59d961ee9..f4d326667d 100644 --- a/ops/docker/Dockerfile.data-transport-layer +++ b/ops/docker/Dockerfile.data-transport-layer @@ -35,4 +35,4 @@ COPY --from=builder /optimism/packages/data-transport-layer/package.json . COPY --from=builder /optimism/packages/data-transport-layer/node_modules ./node_modules COPY ./ops/scripts/dtl.sh . -ENTRYPOINT ["node", "dist/src/services/run.js"] +ENTRYPOINT ["node", "dist/services/run.js"] diff --git a/ops/docker/Dockerfile.packages b/ops/docker/Dockerfile.packages index 511f07bcd6..8700a5ad4d 100644 --- a/ops/docker/Dockerfile.packages +++ b/ops/docker/Dockerfile.packages @@ -22,19 +22,19 @@ ARG BUILDPLATFORM RUN echo "Build Platform: $BUILDPLATFORM" RUN if echo $BUILDPLATFORM | grep -i "amd64" ; then \ - echo "Building for x86_64" ; \ - apt-get update -y && apt-get install -y git curl bash jq ; \ - elif echo $BUILDPLATFORM | grep -i "arm64" ; then \ - echo "Building for Mac ($BUILDPLATFORM)" ; \ - apt-get update -y && apt-get install -y git curl bash jq python3.7 make gcc libudev-dev libusb-1.0-0-dev g++ pkg-config libc6-amd64-cross ; \ - update-alternatives --install /usr/bin/python python /usr/bin/python3.7 10 ; \ - elif echo "\"`uname -a`\"" | grep -q -E -i "aarch" ; then \ - apt-get update -y && apt-get install -y git curl bash jq python make gcc libudev-dev libusb-1.0-0-dev g++ pkg-config ; \ - echo "Building for aarch64 GNU/Linux" ; \ - else \ - echo "Building for something else" ; \ - apt-get update -y && apt-get install -y git curl bash jq ; \ - fi + echo "Building for x86_64" ; \ + apt-get update -y && apt-get install -y git curl bash jq ; \ + elif echo $BUILDPLATFORM | grep -i "arm64" ; then \ + echo "Building for Mac ($BUILDPLATFORM)" ; \ + apt-get update -y && apt-get install -y git curl bash jq python3.7 make gcc libudev-dev libusb-1.0-0-dev g++ pkg-config libc6-amd64-cross ; \ + update-alternatives --install /usr/bin/python python /usr/bin/python3.7 10 ; \ + elif echo "\"`uname -a`\"" | grep -q -E -i "aarch" ; then \ + apt-get update -y && apt-get install -y git curl bash jq python make gcc libudev-dev libusb-1.0-0-dev g++ pkg-config ; \ + echo "Building for aarch64 GNU/Linux" ; \ + else \ + echo "Building for something else" ; \ + apt-get update -y && apt-get install -y git curl bash jq ; \ + fi RUN ln -s /usr/x86_64-linux-gnu/lib64/ /lib64 ENV LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/lib64:/usr/x86_64-linux-gnu/lib" @@ -60,9 +60,9 @@ COPY packages/boba/ve-boba/package.json ./packages/boba/ve-boba/package.json COPY packages/boba/bobalink/package.json ./packages/boba/bobalink/package.json COPY packages/boba/teleportation/package.json ./packages/boba/teleportation/package.json COPY packages/boba/account-abstraction/package.json ./packages/boba/account-abstraction/package.json -# COPY packages/boba/bundler/package.json ./packages/boba/bundler/package.json -# COPY packages/boba/bundler_sdk/package.json ./packages/boba/bundler_sdk/package.json -# COPY packages/boba/bundler_utils/package.json ./packages/boba/bundler_utils/package.json +COPY packages/boba/bundler/package.json ./packages/boba/bundler/package.json +COPY packages/boba/bundler_sdk/package.json ./packages/boba/bundler_sdk/package.json +COPY packages/boba/bundler_utils/package.json ./packages/boba/bundler_utils/package.json FROM base as builder WORKDIR /opt/optimism @@ -98,7 +98,7 @@ COPY --from=builder /opt/optimism/node_modules ./node_modules COPY --from=builder /opt/optimism/packages/sdk/dist ./packages/sdk/dist COPY --from=builder /opt/optimism/packages/core-utils/dist ./packages/core-utils/dist COPY --from=builder /opt/optimism/packages/common-ts/dist ./packages/common-ts/dist -# COPY --from=builder /opt/optimism/packages/boba/bundler_sdk ./packages/boba/bundler_sdk +COPY --from=builder /opt/optimism/packages/boba/bundler_sdk ./packages/boba/bundler_sdk # some packages need to access the bytecode of the contracts and deployment files COPY --from=builder /opt/optimism/packages/contracts ./packages/contracts @@ -120,7 +120,7 @@ COPY --from=builder /opt/optimism/packages/data-transport-layer ./data-transport WORKDIR /opt/optimism/packages/data-transport-layer RUN mkdir ./state-dumps COPY ./ops/scripts/dtl.sh . -CMD ["node", "dist/src/services/run.js"] +CMD ["node", "dist/services/run.js"] FROM packages as integration-tests WORKDIR /opt/optimism/ @@ -128,8 +128,8 @@ COPY --from=builder /opt/optimism/integration-tests ./integration-tests COPY --from=builder /opt/optimism/ops_boba/api ./ops_boba/api WORKDIR /opt/optimism/packages/boba COPY --from=builder /opt/optimism/packages/boba/account-abstraction ./account-abstraction -# COPY --from=builder /opt/optimism/packages/boba/bundler_sdk ./bundler_sdk -# COPY --from=builder /opt/optimism/packages/boba/bundler_utils ./bundler_utils +COPY --from=builder /opt/optimism/packages/boba/bundler_sdk ./bundler_sdk +COPY --from=builder /opt/optimism/packages/boba/bundler_utils ./bundler_utils WORKDIR /opt/optimism/integration-tests COPY ./ops/scripts/integration-tests.sh ./ CMD ["yarn", "test:integration"] @@ -178,17 +178,17 @@ RUN chmod +x ./scripts/wait-for-l1-and-l2.sh RUN chmod +x ./scripts/deployer.sh ENTRYPOINT ["./scripts/wait-for-l1-and-l2.sh", "./scripts/deployer.sh"] -# FROM packages as bundler -# COPY --from=builder /opt/optimism/packages/boba/bundler /opt/optimism/packages/boba/bundler -# COPY --from=builder /opt/optimism/packages/boba/account-abstraction /opt/optimism/packages/boba/account-abstraction -# COPY --from=builder /opt/optimism/packages/boba/bundler_sdk /opt/optimism/packages/boba/bundler_sdk -# COPY --from=builder /opt/optimism/packages/boba/bundler_utils /opt/optimism/packages/boba/bundler_utils -# WORKDIR /opt/optimism/packages/boba/bundler -# RUN npx webpack -# RUN rm -rf /opt/optimism/packages/boba/account-abstraction -# RUN rm -rf /opt/optimism/packages/boba/bundler_sdk -# RUN rm -rf /opt/optimism/packages/boba/bundler_utils -# RUN rm -rf /opt/optimism/packages/boba/bundler/node_modules -# RUN chmod +x ./wait-for-l1-and-l2.sh -# RUN chmod +x ./bundler.sh -# ENTRYPOINT ["./wait-for-l1-and-l2.sh", "./bundler.sh"] +FROM packages as bundler +COPY --from=builder /opt/optimism/packages/boba/bundler /opt/optimism/packages/boba/bundler +COPY --from=builder /opt/optimism/packages/boba/account-abstraction /opt/optimism/packages/boba/account-abstraction +COPY --from=builder /opt/optimism/packages/boba/bundler_sdk /opt/optimism/packages/boba/bundler_sdk +COPY --from=builder /opt/optimism/packages/boba/bundler_utils /opt/optimism/packages/boba/bundler_utils +WORKDIR /opt/optimism/packages/boba/bundler +RUN npx webpack +RUN rm -rf /opt/optimism/packages/boba/account-abstraction +RUN rm -rf /opt/optimism/packages/boba/bundler_sdk +RUN rm -rf /opt/optimism/packages/boba/bundler_utils +RUN rm -rf /opt/optimism/packages/boba/bundler/node_modules +RUN chmod +x ./wait-for-l1-and-l2.sh +RUN chmod +x ./bundler.sh +ENTRYPOINT ["./wait-for-l1-and-l2.sh", "./bundler.sh"] diff --git a/ops/scripts/dtl.sh b/ops/scripts/dtl.sh index bc20546fe6..6f2ac290f8 100755 --- a/ops/scripts/dtl.sh +++ b/ops/scripts/dtl.sh @@ -2,4 +2,4 @@ set -e -exec node dist/src/services/run.js +exec node dist/services/run.js diff --git a/ops/scripts/geth.sh b/ops/scripts/geth.sh index 48df9cbf54..b6771626e9 100755 --- a/ops/scripts/geth.sh +++ b/ops/scripts/geth.sh @@ -51,5 +51,5 @@ exec geth \ --mine \ --miner.etherbase $BLOCK_SIGNER_ADDRESS \ --rangelimit \ - --rpc.gascap ${GAS_CAP:-11000000} \ + --rpc.gascap ${GAS_CAP:-501000000} \ "$@" diff --git a/package.json b/package.json index 2bc2c1625c..9a2b1ed9b8 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "packages": [ "packages/*", "packages/boba/*", - "packages/boba/bundler/packages/*", "l2geth", "integration-tests", "specs", diff --git a/packages/boba/account-abstraction/.github/workflows/build.yml b/packages/boba/account-abstraction/.github/workflows/build.yml deleted file mode 100644 index 308b4cb37d..0000000000 --- a/packages/boba/account-abstraction/.github/workflows/build.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Build -on: - push: - branches: - - '*' - pull_request: - types: [opened, reopened, synchronize] - -env: - TS_NODE_TRANSPILE_ONLY: 1 - FORCE_COLORS: 1 - -# todo: extract shared seto/checkout/install/compile, instead of repeat in each job. -jobs: - - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/setup-node@v1 - with: - node-version: '14' - - uses: actions/checkout@v1 - - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} - - run: yarn install - - run: yarn compile - - - run: yarn run ci - - gas-checks: - runs-on: ubuntu-latest - services: - localgeth: - image: dtr22/geth-dev - - steps: - - uses: actions/setup-node@v1 - with: - node-version: '14' - - uses: actions/checkout@v1 - - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} - - run: yarn install - - run: yarn compile - - run: yarn ci-gas-calc - - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-node@v1 - with: - node-version: '14' - - uses: actions/checkout@v1 - - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} - - run: yarn install - - run: yarn lint - - coverage: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-node@v1 - with: - node-version: '14' - - uses: actions/checkout@v1 - - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} - - run: yarn install - - - run: yarn compile - - - run: FORCE_COLOR=1 yarn coverage - - uses: actions/upload-artifact@v2 - with: - name: solidity-coverage - path: | - coverage/ - coverage.json - diff --git a/packages/boba/account-abstraction/contracts/bundler/EntryPointWrapper.sol b/packages/boba/account-abstraction/contracts/bundler/EntryPointWrapper.sol new file mode 100644 index 0000000000..22413acc9a --- /dev/null +++ b/packages/boba/account-abstraction/contracts/bundler/EntryPointWrapper.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +/* solhint-disable avoid-low-level-calls */ +/* solhint-disable no-inline-assembly */ + +import "../interfaces/IEntryPoint.sol"; + +contract EntryPointWrapper { + /** + * gas and return values during simulation + * @param preOpGas the gas used for validation (including preValidationGas) + * @param prefund the required prefund for this operation + * @param sigFailed validateUserOp's (or paymaster's) signature check failed + * @param validAfter - first timestamp this UserOp is valid (merging account and paymaster time-range) + * @param validUntil - last timestamp this UserOp is valid (merging account and paymaster time-range) + * @param paymasterContext returned by validatePaymasterUserOp (to be passed into postOp) + */ + struct ReturnInfo { + uint256 preOpGas; + uint256 prefund; + bool sigFailed; + uint48 validAfter; + uint48 validUntil; + bytes paymasterContext; + } + + struct StakeInfo { + uint256 stake; + uint256 unstakeDelaySec; + } + + /** + * returned aggregated signature info. + * the aggregator returned by the account, and its current stake. + */ + struct AggregatorStakeInfo { + address aggregator; + StakeInfo stakeInfo; + } + + struct FailedOpStatus { + bool status; + uint256 opIndex; + string reason; + } + + struct Response { + string selectorType; + ReturnInfo returnInfo; + StakeInfo senderInfo; + StakeInfo factoryInfo; + StakeInfo paymasterInfo; + AggregatorStakeInfo aggregatorInfo; + } + + + /** + * a custom revert error of handleOps, to identify the offending op. + * NOTE: if simulateValidation passes successfully, there should be no reason for handleOps to fail on it. + * @param opIndex - index into the array of ops to the failed one (in simulateValidation, this is always zero) + * @param reason - revert reason + * The string starts with a unique code "AAmn", where "m" is "1" for factory, "2" for account and "3" for paymaster issues, + * so a failure can be attributed to the correct entity. + * Should be caught in off-chain handleOps simulation and not happen on-chain. + * Useful for mitigating DoS attempts against batchers or for troubleshooting of factory/account/paymaster reverts. + */ + error FailedOp(uint256 opIndex, string reason); + + /** + * Successful result from simulateValidation. + * @param returnInfo gas and time-range returned values + * @param senderInfo stake information about the sender + * @param factoryInfo stake information about the factory (if any) + * @param paymasterInfo stake information about the paymaster (if any) + */ + error ValidationResult(ReturnInfo returnInfo, + StakeInfo senderInfo, StakeInfo factoryInfo, StakeInfo paymasterInfo); + + /** + * Successful result from simulateValidation, if the account returns a signature aggregator + * @param returnInfo gas and time-range returned values + * @param senderInfo stake information about the sender + * @param factoryInfo stake information about the factory (if any) + * @param paymasterInfo stake information about the paymaster (if any) + * @param aggregatorInfo signature aggregation info (if the account requires signature aggregator) + * bundler MUST use it to verify the signature, or reject the UserOperation + */ + error ValidationResultWithAggregation(ReturnInfo returnInfo, + StakeInfo senderInfo, StakeInfo factoryInfo, StakeInfo paymasterInfo, + AggregatorStakeInfo aggregatorInfo); + + IEntryPoint public entryPoint; + + StakeInfo private emptyStakeInfo = StakeInfo(0, 0); + AggregatorStakeInfo private emptyAggregatorInfo = AggregatorStakeInfo(address(0), emptyStakeInfo); + ReturnInfo private emptyReturnInfo = ReturnInfo(0, 0, false, 0, 0, new bytes(0)); + Response private emptyResponse = Response("", emptyReturnInfo, emptyStakeInfo, emptyStakeInfo, emptyStakeInfo, emptyAggregatorInfo); + FailedOpStatus private emptyFailedOp = FailedOpStatus(false, 0, ""); + + constructor(IEntryPoint _entryPoint) { + entryPoint = _entryPoint; + } + + function simulateValidation(UserOperation calldata userOp) external returns (FailedOpStatus memory, Response memory) { + try entryPoint.simulateValidation(userOp) {} + catch (bytes memory revertData) { + bytes4 receivedSelector = bytes4(revertData); + + if (receivedSelector == ValidationResult.selector) { + (ReturnInfo memory returnInfo, StakeInfo memory senderInfo, StakeInfo memory factoryInfo, StakeInfo memory paymasterInfo) = abi.decode(slice(revertData, 4, revertData.length - 4), (ReturnInfo, StakeInfo, StakeInfo, StakeInfo)); + return (emptyFailedOp, Response('ValidationResult', returnInfo, senderInfo, factoryInfo, paymasterInfo, emptyAggregatorInfo)); + } else if (receivedSelector == ValidationResultWithAggregation.selector) { + (ReturnInfo memory returnInfo, StakeInfo memory senderInfo, StakeInfo memory factoryInfo, StakeInfo memory paymasterInfo, AggregatorStakeInfo memory aggregatorInfo) = abi.decode(slice(revertData, 4, revertData.length - 4), (ReturnInfo, StakeInfo, StakeInfo, StakeInfo, AggregatorStakeInfo)); + return (emptyFailedOp, Response('ValidationResultWithAggregation', returnInfo, senderInfo, factoryInfo, paymasterInfo, aggregatorInfo)); + } else if (receivedSelector == FailedOp.selector){ + (uint256 opIndex, string memory reason) = abi.decode(slice(revertData, 4, revertData.length - 4), (uint256, string)); + return (FailedOpStatus(true, opIndex, reason), emptyResponse); + } + } + } + + function slice( + bytes memory _bytes, + uint256 _start, + uint256 _length + ) + internal + pure + returns (bytes memory) + { + require(_length + 31 >= _length, "slice_overflow"); + require(_bytes.length >= _start + _length, "slice_outOfBounds"); + + bytes memory tempBytes; + + assembly { + switch iszero(_length) + case 0 { + // Get a location of some free memory and store it in tempBytes as + // Solidity does for memory variables. + tempBytes := mload(0x40) + + // The first word of the slice result is potentially a partial + // word read from the original array. To read it, we calculate + // the length of that partial word and start copying that many + // bytes into the array. The first word we copy will start with + // data we don't care about, but the last `lengthmod` bytes will + // land at the beginning of the contents of the new array. When + // we're done copying, we overwrite the full first word with + // the actual length of the slice. + let lengthmod := and(_length, 31) + + // The multiplication in the next line is necessary + // because when slicing multiples of 32 bytes (lengthmod == 0) + // the following copy loop was copying the origin's length + // and then ending prematurely not copying everything it should. + let mc := add(add(tempBytes, lengthmod), mul(0x20, iszero(lengthmod))) + let end := add(mc, _length) + + for { + // The multiplication in the next line has the same exact purpose + // as the one above. + let cc := add(add(add(_bytes, lengthmod), mul(0x20, iszero(lengthmod))), _start) + } lt(mc, end) { + mc := add(mc, 0x20) + cc := add(cc, 0x20) + } { + mstore(mc, mload(cc)) + } + + mstore(tempBytes, _length) + + //update free-memory pointer + //allocating the array padded to 32 bytes like the compiler does now + mstore(0x40, and(add(mc, 31), not(31))) + } + //if we want a zero-length slice let's just return a zero-length array + default { + tempBytes := mload(0x40) + //zero out the 32 bytes slice we are about to return + //we need to do it because Solidity does not garbage collect + mstore(tempBytes, 0) + + mstore(0x40, add(tempBytes, 0x20)) + } + } + + return tempBytes; + } + + function getUserOpHashes(IEntryPoint entryPoint, UserOperation[] memory userOps) public view returns (bytes32[] memory ret) { + ret = new bytes32[](userOps.length); + for (uint i = 0; i < userOps.length; i++) { + ret[i] = entryPoint.getUserOpHash(userOps[i]); + } + return ret; + } + + function getCodeHashes(address[] memory addresses) public view returns (bytes32) { + bytes32[] memory hashes = new bytes32[](addresses.length); + for (uint i = 0; i < addresses.length; i++) { + hashes[i] = addresses[i].codehash; + } + bytes memory data = abi.encode(hashes); + return (keccak256(data)); + } +} diff --git a/packages/boba/account-abstraction/deploy/4-deploy-bundler-helper-wrapper.ts b/packages/boba/account-abstraction/deploy/4-deploy-bundler-helper-wrapper.ts new file mode 100644 index 0000000000..ebb2e34641 --- /dev/null +++ b/packages/boba/account-abstraction/deploy/4-deploy-bundler-helper-wrapper.ts @@ -0,0 +1,43 @@ +import { DeployFunction, DeploymentSubmission } from 'hardhat-deploy/types' +import { ethers } from 'hardhat' +import { Contract, ContractFactory } from 'ethers' +import { registerBobaAddress } from './1-deploy_entrypoint' +import EntryPointWrapperJson from '../artifacts/contracts/bundler/EntryPointWrapper.sol/EntryPointWrapper.json' +import { DeterministicDeployer } from '../src/DeterministicDeployer' + +let Factory__EntryPointWrapper: ContractFactory + +const deployFn: DeployFunction = async (hre) => { + Factory__EntryPointWrapper = new ContractFactory( + EntryPointWrapperJson.abi, + EntryPointWrapperJson.bytecode, + (hre as any).deployConfig.deployer_l2 + ) + const entryPoint = await hre.deployments.getOrNull('EntryPoint') + console.log(`EntryPoint is located at: ${entryPoint.address}`) + const entryPointFromAM = await (hre as any).deployConfig.addressManager.getAddress('L2_Boba_EntryPoint') + if (entryPoint.address.toLowerCase() === entryPointFromAM.toLowerCase()) { + const entryPointWrapperConstructorArgs = ethers.utils.defaultAbiCoder.encode( + ["address"], + [entryPoint.address] + ) + const entryPointWrapperCreationCode = ethers.utils.solidityPack( + ["bytes", "bytes"], + [Factory__EntryPointWrapper.bytecode, entryPointWrapperConstructorArgs] + ) + const dep = new DeterministicDeployer((hre as any).deployConfig.l2Provider, (hre as any).deployConfig.deployer_l2, 'local') + const EntryPointWrapperAddress = await dep.deterministicDeploy(entryPointWrapperCreationCode) + console.log('EntryPoint Wrapper at', EntryPointWrapperAddress) + + const entryPointWrapperDeploymentSubmission: DeploymentSubmission = { + address: EntryPointWrapperAddress, + abi: EntryPointWrapperJson.abi + } + await hre.deployments.save('EntryPointWrapper', entryPointWrapperDeploymentSubmission) + + await registerBobaAddress( (hre as any).deployConfig.addressManager, 'L2_EntryPointWrapper', EntryPointWrapperAddress ) + } +} + +export default deployFn +deployFn.tags = ['EntryPointWrapper'] diff --git a/packages/boba/account-abstraction/deploy/5-dump-addresses.ts b/packages/boba/account-abstraction/deploy/5-dump-addresses.ts new file mode 100644 index 0000000000..397a9fdfbf --- /dev/null +++ b/packages/boba/account-abstraction/deploy/5-dump-addresses.ts @@ -0,0 +1,35 @@ +/* Imports: External */ +import { DeployFunction } from 'hardhat-deploy/types' +import path from 'path' +import fs from 'fs' + +const deployFn: DeployFunction = async (hre) => { + const contracts = {} + const deployments = await hre.deployments.all() + + for (const key in deployments) { + if (deployments.hasOwnProperty(key)) { + if (key == 'EntryPoint') { + contracts['L2_BOBA_'+key] = deployments[key].address + } else { + contracts['L2_'+key] = deployments[key].address + } + } + } + + const addresses = JSON.stringify(contracts, null, 2) + + console.log(addresses) + + const dumpsPath = path.resolve(__dirname, '../dist/dumps') + + if (!fs.existsSync(dumpsPath)) { + fs.mkdirSync(dumpsPath, { recursive: true }) + } + const addrsPath = path.resolve(dumpsPath, 'addresses.json') + fs.writeFileSync(addrsPath, addresses) +} + +deployFn.tags = ['Log', 'required'] + +export default deployFn diff --git a/packages/boba/account-abstraction/test/helpers.test.ts b/packages/boba/account-abstraction/test/helpers.test.ts index dd69d55a69..fb730f6e5e 100644 --- a/packages/boba/account-abstraction/test/helpers.test.ts +++ b/packages/boba/account-abstraction/test/helpers.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ +/* eslint-disable prettier/prettier */ +/* eslint-disable @typescript-eslint/no-shadow */ import './aa.init' import { BigNumber } from 'ethers' import { AddressZero } from './testutils' diff --git a/packages/boba/account-abstraction/test/verifying_paymaster.test.ts b/packages/boba/account-abstraction/test/verifying_paymaster.test.ts index 7a06b0665f..c13bfe9e14 100644 --- a/packages/boba/account-abstraction/test/verifying_paymaster.test.ts +++ b/packages/boba/account-abstraction/test/verifying_paymaster.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ +/* eslint-disable prettier/prettier */ import { Wallet } from 'ethers' import { ethers } from 'hardhat' import { expect } from 'chai' diff --git a/packages/boba/account-abstraction/test/verifying_paymaster_boba.test.ts b/packages/boba/account-abstraction/test/verifying_paymaster_boba.test.ts index c611bd70c2..d871bee8ac 100644 --- a/packages/boba/account-abstraction/test/verifying_paymaster_boba.test.ts +++ b/packages/boba/account-abstraction/test/verifying_paymaster_boba.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ import { Wallet } from 'ethers' import { ethers } from 'hardhat' import { expect } from 'chai' diff --git a/packages/boba/bobalink/test/bobalink.spec.ts b/packages/boba/bobalink/test/bobalink.spec.ts index 392b0892e1..f975ac4348 100644 --- a/packages/boba/bobalink/test/bobalink.spec.ts +++ b/packages/boba/bobalink/test/bobalink.spec.ts @@ -13,7 +13,7 @@ import L1ERC20Json from '@boba/contracts/artifacts/contracts/test-helpers/L1ERC2 import { BobaLinkPairs } from '../src/utils/types' /* Imports: Core */ -import { BobaLinkService } from '../dist/src/service' +import { BobaLinkService } from '../dist/service' describe('bobalink', () => { let signer: Signer diff --git a/packages/boba/bundler/bundler.sh b/packages/boba/bundler/bundler.sh index 4b49e092f1..cbc6a08233 100644 --- a/packages/boba/bundler/bundler.sh +++ b/packages/boba/bundler/bundler.sh @@ -1,4 +1,4 @@ #!/bin/sh set -e -node --trace-warnings --no-deprecation bundler.js --minBalance $MIN_BALANCE --mnemonic $MNEMONIC_OR_PK --network $L2_NODE_WEB3_URL --helper $HELPER --entryPoint $ENTRYPOINT --beneficiary $BENEFICIARY --addressManager $ADDRESS_MANAGER_ADDRESS --l1NodeWeb3Url $L1_NODE_WEB3_URL +node --trace-warnings --no-deprecation bundler.js --minBalance $MIN_BALANCE --mnemonic $MNEMONIC_OR_PK --network $L2_NODE_WEB3_URL --beneficiary $BENEFICIARY --addressManager $ADDRESS_MANAGER_ADDRESS --l1NodeWeb3Url $L1_NODE_WEB3_URL --maxBundleGas $MAX_BUNDLE_GAS --unsafe $UNSAFE diff --git a/packages/boba/bundler/contracts/BundlerHelper.sol b/packages/boba/bundler/contracts/BundlerHelper.sol index 374d7a05b0..bb9789d1f1 100644 --- a/packages/boba/bundler/contracts/BundlerHelper.sol +++ b/packages/boba/bundler/contracts/BundlerHelper.sol @@ -1,31 +1,38 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.15; -import "@boba/accountabstraction/contracts/core/EntryPoint.sol"; -import "solidity-string-utils/StringUtils.sol"; - -contract BundlerHelper { - using StringUtils for *; - - /** - * run handleop. require to get refund for the used gas. - */ - function handleOps(uint expectedPaymentGas, EntryPoint ep, UserOperation[] calldata ops, address payable beneficiary) - public returns (uint paid, uint gasPrice, bytes memory errorReason){ - gasPrice = tx.gasprice; - uint expectedPayment = expectedPaymentGas * gasPrice; - uint preBalance = beneficiary.balance; - try ep.handleOps(ops, beneficiary) { - } catch (bytes memory err) { - errorReason = err; +import "@boba/accountabstraction/contracts/interfaces/IEntryPoint.sol"; + +contract GetUserOpHashes { + error UserOpHashesResult(bytes32[] userOpHashes); + + constructor(IEntryPoint entryPoint, UserOperation[] memory userOps) { + revert UserOpHashesResult( + getUserOpHashes(entryPoint, userOps)); + } + + function getUserOpHashes(IEntryPoint entryPoint, UserOperation[] memory userOps) public view returns (bytes32[] memory ret) { + ret = new bytes32[](userOps.length); + for (uint i = 0; i < userOps.length; i++) { + ret[i] = entryPoint.getUserOpHash(userOps[i]); } - paid = beneficiary.balance - preBalance; - if (paid < expectedPayment) { - revert(string.concat( - "didn't pay enough: paid ", paid.toString(), - " expected ", expectedPayment.toString(), - " gasPrice ", gasPrice.toString() - )); + } +} + +contract GetCodeHashes { + + error CodeHashesResult(bytes32 hash); + constructor(address[] memory addresses) { + revert CodeHashesResult(getCodeHashes(addresses)); + } + + function getCodeHashes(address[] memory addresses) public view returns (bytes32) { + bytes32[] memory hashes = new bytes32[](addresses.length); + for (uint i = 0; i < addresses.length; i++) { + hashes[i] = addresses[i].codehash; } + bytes memory data = abi.encode(hashes); + return (keccak256(data)); } + } diff --git a/packages/boba/bundler/contracts/Import.sol b/packages/boba/bundler/contracts/Import.sol index 3517c46c7b..459018af06 100644 --- a/packages/boba/bundler/contracts/Import.sol +++ b/packages/boba/bundler/contracts/Import.sol @@ -4,4 +4,4 @@ pragma solidity ^0.8.0; // import contracts to get their type info. import "@boba/bundler_utils/contracts/test/SampleRecipient.sol"; import "@boba/bundler_utils/contracts/test/SingletonFactory.sol"; -import "@boba/accountabstraction/contracts/samples/SimpleAccountDeployer.sol"; +import "@boba/accountabstraction/contracts/samples/SimpleAccountFactory.sol"; diff --git a/packages/boba/bundler/contracts/tests/TestCoin.sol b/packages/boba/bundler/contracts/tests/TestCoin.sol new file mode 100644 index 0000000000..4f88a4d4a4 --- /dev/null +++ b/packages/boba/bundler/contracts/tests/TestCoin.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +contract TestCoin { + mapping(address => uint) balances; + mapping(address => mapping(address => uint)) allowances; + + struct Struct { + uint a; + uint b; + uint c; + } + mapping(address=>Struct) public structInfo; + + function getInfo(address addr) public returns (Struct memory) { + return structInfo[addr]; + } + function balanceOf(address addr) public view returns (uint) { + return balances[addr]; + } + + function allowance(address owner, address spender) public view returns (uint256) { + return allowances[owner][spender]; + } + + function mint(address addr) public returns (uint) { + return balances[addr] += 100; + } + + //unrelated to token: testing inner object revert + function reverting() public returns (uint) { + revert("inner-revert"); + } + + function wasteGas() public returns (uint) { + string memory buffer = "string to be duplicated"; + while (true) { + buffer = string.concat(buffer, buffer); + } + return 0; + } +} diff --git a/packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol b/packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol new file mode 100644 index 0000000000..ae59961fb9 --- /dev/null +++ b/packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import "@boba/accountabstraction/contracts/interfaces/IAccount.sol"; +import "@boba/accountabstraction/contracts/interfaces/IPaymaster.sol"; +import "@boba/accountabstraction/contracts/interfaces/IEntryPoint.sol"; +import "./TestRuleAccount.sol"; + +contract Dummy { +} + + +/** + * an account with "rules" to trigger different opcode validation rules + */ +contract TestOpcodesAccount is TestRuleAccount { + + event TestMessage(address eventSender); + + function runRule(string memory rule) public virtual override returns (uint) { + if (eq(rule, "number")) return block.number; + else if (eq(rule, "coinbase")) return uint160(address(block.coinbase)); + else if (eq(rule, "blockhash")) return uint(blockhash(0)); + else if (eq(rule, "create2")) { + new Dummy{salt : bytes32(uint(0x1))}(); + return 0; + } + else if (eq(rule, "emit-msg")) { + emit TestMessage(address(this)); + return 0; + } + return super.runRule(rule); + } +} + +contract TestOpcodesAccountFactory { + function create(string memory rule) public returns (TestOpcodesAccount) { + TestOpcodesAccount a = new TestOpcodesAccount{salt : bytes32(uint(0))}(); + a.runRule(rule); + return a; + } + +} diff --git a/packages/boba/bundler/contracts/tests/TestRecursionAccount.sol b/packages/boba/bundler/contracts/tests/TestRecursionAccount.sol new file mode 100644 index 0000000000..81176261b7 --- /dev/null +++ b/packages/boba/bundler/contracts/tests/TestRecursionAccount.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import "@boba/accountabstraction/contracts/interfaces/IAccount.sol"; +import "@boba/accountabstraction/contracts/interfaces/IPaymaster.sol"; +import "@boba/accountabstraction/contracts/interfaces/IEntryPoint.sol"; +import "./TestRuleAccount.sol"; + +contract TestRecursionAccount is TestRuleAccount { + + IEntryPoint public immutable ep; + constructor(IEntryPoint _ep) { + ep = _ep; + } + + function runRule(string memory rule) public virtual override returns (uint) { + + if (eq(rule, "handleOps")) { + UserOperation[] memory ops = new UserOperation[](0); + ep.handleOps(ops, payable(address (1))); + return 0; + } + + return super.runRule(rule); + } +} diff --git a/packages/boba/bundler/contracts/tests/TestRuleAccount.sol b/packages/boba/bundler/contracts/tests/TestRuleAccount.sol new file mode 100644 index 0000000000..7acf8abfe1 --- /dev/null +++ b/packages/boba/bundler/contracts/tests/TestRuleAccount.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import "@boba/accountabstraction/contracts/interfaces/IAccount.sol"; +import "@boba/accountabstraction/contracts/interfaces/IPaymaster.sol"; +import "@boba/accountabstraction/contracts/interfaces/IEntryPoint.sol"; + +/** + * contract for testing account interaction. + * doesn't really do validation: the signature is a "rule" to define the validation action to take. + * as a paymaster, the paymasterAndData is the "rule" to take (at offset 20, just after the paymaster address) + * the account also as a "state" variable and event, so we can use it to test state transitions + */ +contract TestRuleAccount is IAccount, IPaymaster { + + uint state; + + event State(uint oldState, uint newState); + + function setState(uint _state) external { + emit State(state, _state); + state = _state; + } + + function eq(string memory a, string memory b) internal pure returns (bool) { + return keccak256(bytes(a)) == keccak256(bytes(b)); + } + + /** + * "rules" to test. override to add more "rules" + */ + function runRule(string memory rule) public virtual returns (uint) { + if (eq(rule, "")) return 0; + else if (eq(rule, "ok")) return 0; + else if (eq(rule, "fail")) revert("fail rule"); + else + revert(string.concat("unknown rule: ", rule)); + } + + //needed in order to make it a valid paymaster + function addStake(IEntryPoint entryPoint) public payable { + entryPoint.addStake{value : msg.value}(1); + } + + function validateUserOp(UserOperation calldata userOp, bytes32, uint256 missingAccountFunds) + external virtual override returns (uint256) { + if (missingAccountFunds > 0) { + /* solhint-disable-next-line avoid-low-level-calls */ + (bool success,) = msg.sender.call{value : missingAccountFunds}(""); + success; + } + runRule(string(userOp.signature)); + return 0; + } + + function validatePaymasterUserOp(UserOperation calldata userOp, bytes32, uint256) + public virtual override returns (bytes memory context, uint256 deadline) { + string memory rule = string(userOp.paymasterAndData[20 :]); + runRule(rule); + return ("", 0); + } + + function postOp(PostOpMode, bytes calldata, uint256) external {} +} + +contract TestRuleAccountFactory { + function create(string memory rule) public returns (TestRuleAccount) { + TestRuleAccount a = new TestRuleAccount{salt : bytes32(uint(0))}(); + a.runRule(rule); + return a; + } +} diff --git a/packages/boba/bundler/contracts/tests/TestRulesAccount.sol b/packages/boba/bundler/contracts/tests/TestRulesAccount.sol new file mode 100644 index 0000000000..d0c5256ddf --- /dev/null +++ b/packages/boba/bundler/contracts/tests/TestRulesAccount.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import "@boba/accountabstraction/contracts/interfaces/IAccount.sol"; +import "@boba/accountabstraction/contracts/interfaces/IPaymaster.sol"; +import "@boba/accountabstraction/contracts/interfaces/IEntryPoint.sol"; +import "./TestCoin.sol"; + +contract Dummy { + uint public value = 1; +} + +contract TestRulesAccount is IAccount, IPaymaster { + + uint state; + TestCoin public coin; + + event State(uint oldState, uint newState); + + function setState(uint _state) external { + emit State(state, _state); + state = _state; + } + + function setCoin(TestCoin _coin) public returns (uint){ + coin = _coin; + return 0; + } + + function eq(string memory a, string memory b) internal pure returns (bool) { + return keccak256(bytes(a)) == keccak256(bytes(b)); + } + + event TestMessage(address eventSender); + + function runRule(string memory rule) public returns (uint) { + if (eq(rule, "")) return 0; + else if (eq(rule, "number")) return block.number; + else if (eq(rule, "coinbase")) return uint160(address(block.coinbase)); + else if (eq(rule, "blockhash")) return uint(blockhash(0)); + else if (eq(rule, "create2")) return new Dummy{salt : bytes32(uint(0x1))}().value(); + else if (eq(rule, "balance-self")) return coin.balanceOf(address(this)); + else if (eq(rule, "allowance-self-1")) return coin.allowance(address(this), address(1)); + else if (eq(rule, "allowance-1-self")) return coin.allowance(address(1), address(this)); + else if (eq(rule, "mint-self")) return coin.mint(address(this)); + else if (eq(rule, "balance-1")) return coin.balanceOf(address(1)); + else if (eq(rule, "mint-1")) return coin.mint(address(1)); + else if (eq(rule, "struct-self")) return coin.getInfo(address(this)).c; + else if (eq(rule, "struct-1")) return coin.getInfo(address(1)).c; + + else if (eq(rule, "inner-revert")) return coin.reverting(); + else if (eq(rule, "emit-msg")) { + emit TestMessage(address(this)); + return 0;} + + revert(string.concat("unknown rule: ", rule)); + } + + function addStake(IEntryPoint entryPoint) public payable { + entryPoint.addStake{value : msg.value}(1); + } + + function validateUserOp(UserOperation calldata userOp, bytes32, uint256 missingAccountFunds) + external override returns (uint256) { + if (missingAccountFunds > 0) { + /* solhint-disable-next-line avoid-low-level-calls */ + (bool success,) = msg.sender.call{value : missingAccountFunds}(""); + success; + } + if (userOp.signature.length == 4) { + uint32 deadline = uint32(bytes4(userOp.signature)); + return deadline; + } + runRule(string(userOp.signature)); + return 0; + } + + function validatePaymasterUserOp(UserOperation calldata userOp, bytes32, uint256) + external returns (bytes memory context, uint256 deadline) { + string memory rule = string(userOp.paymasterAndData[20 :]); + runRule(rule); + return ("", 0); + } + + function postOp(PostOpMode, bytes calldata, uint256) external {} + +} + +contract TestRulesAccountFactory { + TestCoin public immutable coin = new TestCoin(); + function create(string memory rule) public returns (TestRulesAccount) { + TestRulesAccount a = new TestRulesAccount{salt : bytes32(uint(0))}(); + a.setCoin(coin); + a.runRule(rule); + return a; + } + +} diff --git a/packages/boba/bundler/contracts/tests/TestStorageAccount.sol b/packages/boba/bundler/contracts/tests/TestStorageAccount.sol new file mode 100644 index 0000000000..1a1229a004 --- /dev/null +++ b/packages/boba/bundler/contracts/tests/TestStorageAccount.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import "@boba/accountabstraction/contracts/interfaces/IAccount.sol"; +import "@boba/accountabstraction/contracts/interfaces/IPaymaster.sol"; +import "@boba/accountabstraction/contracts/interfaces/IEntryPoint.sol"; +import "./TestRuleAccount.sol"; +import "./TestCoin.sol"; + +/** + * an account with "rules" to trigger different opcode validation rules + */ +contract TestStorageAccount is TestRuleAccount { + + TestCoin public coin; + + function setCoin(TestCoin _coin) public returns (uint){ + coin = _coin; + return 0; + } + + event TestMessage(address eventSender); + + function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) + public virtual override returns (bytes memory context, uint256 deadline) { + string memory rule = string(userOp.paymasterAndData[20 :]); + if (eq(rule, 'postOp-context')) { + return ("some-context", 0); + } + // return ("",0); + return super.validatePaymasterUserOp(userOp, userOpHash, maxCost); + } + + function runRule(string memory rule) public virtual override returns (uint) { + if (eq(rule, "number")) return block.number; + else if (eq(rule, "balance-self")) return coin.balanceOf(address(this)); + else if (eq(rule, "mint-self")) return coin.mint(address(this)); + else if (eq(rule, "balance-1")) return coin.balanceOf(address(1)); + else if (eq(rule, "mint-1")) return coin.mint(address(1)); + else if (eq(rule, "read-self")) return uint160(address(coin)); + else if (eq(rule, "allowance-self-1")) return coin.allowance(address(this), address(1)); + else if (eq(rule, "allowance-1-self")) return coin.allowance(address(1), address(this)); + else if (eq(rule, "struct-self")) return coin.getInfo(address(this)).c; + else if (eq(rule, "struct-1")) return coin.getInfo(address(1)).c; + else if (eq(rule, "inner-revert")) { + (bool success,) = address(coin).call(abi.encode(coin.reverting)); + success; + return 0; + } + else if (eq(rule, "oog")) { + try coin.wasteGas{gas : 10000}() {} + catch {} + return 0; + } + return super.runRule(rule); + } +} + +contract TestStorageAccountFactory { + TestCoin public immutable coin; + + constructor(TestCoin _coin) { + coin = _coin; + } + + function create(uint salt, string memory rule) public returns (TestStorageAccount) { + TestStorageAccount a = new TestStorageAccount{salt : bytes32(salt)}(); + a.setCoin(coin); + a.runRule(rule); + return a; + } + +} diff --git a/packages/boba/bundler/contracts/tests/TracerTest.sol b/packages/boba/bundler/contracts/tests/TracerTest.sol index 42b0a951e6..a80bcfd103 100644 --- a/packages/boba/bundler/contracts/tests/TracerTest.sol +++ b/packages/boba/bundler/contracts/tests/TracerTest.sol @@ -1,9 +1,6 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.15; -// import "@account-abstraction/contracts/core/EntryPoint.sol"; -// import "solidity-string-utils/StringUtils.sol"; - contract TracerTest { uint public a; diff --git a/packages/boba/bundler/package.json b/packages/boba/bundler/package.json index 1e17cf0b3d..16db97e959 100644 --- a/packages/boba/bundler/package.json +++ b/packages/boba/bundler/package.json @@ -10,16 +10,17 @@ ] }, "scripts": { + "build": "hardhat compile && tsc -p ./tsconfig.json", "bundler": "ts-node ./src/exec.ts --config ./localconfig/bundler.config.json", - "build:stopp": "tsc -p ./tsconfig.json && hardhat compile", "clean": "rimraf artifacts cache node_modules dist dockers/bundler ./tsconfig.tsbuildinfo", - "test": "hardhat test --grep '/^((?!Flow).)*$/'", - "test:coverage:stop": "yarn test", - "test-flows": "npx hardhat test --network localhost --grep \"Flow\"", + "test:coverage": "yarn test", + "test": "hardhat test", "hardhat-test": "hardhat test --grep '/^((?!Flow).)*$/'", "hardhat-node": "npx hardhat node --no-deploy" }, "dependencies": { + "@openzeppelin/contracts": "^4.2.0", + "@openzeppelin/contracts-upgradeable": "4.3.2", "@boba/accountabstraction": "^1.0.0", "@boba/bundler_utils": "^0.2.3", "@boba/bundler_sdk": "^0.2.3", diff --git a/packages/boba/bundler/src/BundlerCollectorTracer.ts b/packages/boba/bundler/src/BundlerCollectorTracer.ts index a4b81b7ae8..c7527fed04 100644 --- a/packages/boba/bundler/src/BundlerCollectorTracer.ts +++ b/packages/boba/bundler/src/BundlerCollectorTracer.ts @@ -9,11 +9,17 @@ import { LogDb, LogFrameResult, LogStep, - LogTracer + LogTracer, } from './GethTracer' -// toHex is available in a context of geth tracer -declare function toHex (a: any): string +// functions available in a context of geth tracer +declare function toHex(a: any): string + +declare function toWord(a: any): string + +declare function toAddress(a: any): string + +declare function isPrecompiled(addr: any): boolean /** * return type of our BundlerCollectorTracer. @@ -24,23 +30,42 @@ export interface BundlerCollectorReturn { /** * storage and opcode info, collected between "NUMBER" opcode calls (which is used as our "level marker") */ - numberLevels: { [numberOpcodeLevel: string]: NumberLevelInfo } + numberLevels: NumberLevelInfo[] /** * values passed into KECCAK opcode */ keccak: string[] - calls: Array<{ type: string, from: string, to: string, value: any }> + calls: Array logs: LogInfo[] debug: any[] } +export interface MethodInfo { + type: string + from: string + to: string + method: string + value: any + gas: number +} + +export interface ExitInfo { + type: 'REVERT' | 'RETURN' + gasUsed: number + data: string +} + export interface NumberLevelInfo { - opcodes: { [opcode: string]: number | undefined } - access: { [address: string]: AccessInfo | undefined } + opcodes: { [opcode: string]: number } + access: { [address: string]: AccessInfo } + contractSize: { [addr: string]: number } + oog?: boolean } export interface AccessInfo { - reads: { [slot: string]: number } + // slot value, just prior this operation + reads: { [slot: string]: string } + // count of writes. writes: { [slot: string]: number } } @@ -70,9 +95,9 @@ interface BundlerCollectorTracer extends LogTracer, BundlerCollectorReturn { * calls: for each call, an array of [type, from, to, value] * slots: accessed slots (on any address) */ -export function bundlerCollectorTracer (): BundlerCollectorTracer { +export function bundlerCollectorTracer(): BundlerCollectorTracer { return { - numberLevels: {}, + numberLevels: [], currentLevel: null as any, keccak: [], calls: [], @@ -81,58 +106,103 @@ export function bundlerCollectorTracer (): BundlerCollectorTracer { lastOp: '', numberCounter: 0, - fault (log: LogStep, db: LogDb): void { - this.debug.push(['fault', log.getError()]) + fault(log: LogStep, db: LogDb): void { + this.debug.push( + 'fault depth=', + log.getDepth(), + ' gas=', + log.getGas(), + ' cost=', + log.getCost(), + ' err=', + log.getError() + ) }, - result (ctx: LogContext, db: LogDb): any { + result(ctx: LogContext, db: LogDb): BundlerCollectorReturn { return { numberLevels: this.numberLevels, keccak: this.keccak, logs: this.logs, calls: this.calls, - debug: this.debug // for internal debugging. + debug: this.debug, // for internal debugging. } }, - enter (frame: LogCallFrame): void { - this.debug.push([ - 'enter ' + - frame.getType() + - ' ' + - toHex(frame.getTo()) + - ' ' + - toHex(frame.getInput()).slice(0, 100) - ]) + enter(frame: LogCallFrame): void { + // this.debug.push('enter gas=', frame.getGas(), ' type=', frame.getType(), ' to=', toHex(frame.getTo()), ' in=', toHex(frame.getInput()).slice(0, 500)) this.calls.push({ type: frame.getType(), from: toHex(frame.getFrom()), to: toHex(frame.getTo()), - value: frame.getValue() + method: toHex(frame.getInput()).slice(0, 10), + gas: frame.getGas(), + value: frame.getValue(), }) }, - exit (frame: LogFrameResult): void { - this.debug.push( - `exit err=${frame.getError() as string}, gas=${frame.getGasUsed()}` - ) + exit(frame: LogFrameResult): void { + this.calls.push({ + type: frame.getError() != null ? 'REVERT' : 'RETURN', + gasUsed: frame.getGasUsed(), + data: toHex(frame.getOutput()).slice(0, 1000), + }) }, // increment the "key" in the list. if the key is not defined yet, then set it to "1" - countSlot (list: { [key: string]: number | undefined }, key: any) { + countSlot(list: { [key: string]: number | undefined }, key: any) { list[key] = (list[key] ?? 0) + 1 }, - step (log: LogStep, db: LogDb): any { + step(log: LogStep, db: LogDb): any { const opcode = log.op.toString() - // this.debug.push(this.lastOp + '-' + opcode + '-' + log.getDepth()) - if (opcode === 'NUMBER') this.numberCounter++ - if (this.numberLevels[this.numberCounter] == null) { - this.currentLevel = this.numberLevels[this.numberCounter] = { - access: {}, - opcodes: {} + // this.debug.push(this.lastOp + '-' + opcode + '-' + log.getDepth() + '-' + log.getGas() + '-' + log.getCost()) + if (log.getGas() < log.getCost()) { + this.currentLevel.oog = true + } + + if (opcode === 'REVERT' || opcode === 'RETURN') { + if (log.getDepth() === 1) { + // exit() is not called on top-level return/revent, so we reconstruct it + // from opcode + const ofs = parseInt(log.stack.peek(0).toString()) + const len = parseInt(log.stack.peek(1).toString()) + const data = toHex(log.memory.slice(ofs, ofs + len)).slice(0, 1000) + // this.debug.push(opcode + ' ' + data) + this.calls.push({ + type: opcode, + gasUsed: 0, + data, + }) + } + } + + if ( + opcode.match(/^(EXT.*|CALL|CALLCODE|DELEGATECALL|STATICCALL)$/) != null + ) { + // this.debug.push('op=' + opcode + ' last=' + this.lastOp + ' stacksize=' + log.stack.length()) + const idx = opcode.startsWith('EXT') ? 0 : 1 + const addr = toAddress(log.stack.peek(idx).toString(16)) + const addrHex = toHex(addr) + if ( + (this.currentLevel.contractSize[addrHex] ?? 0) === 0 && + !isPrecompiled(addr) + ) { + this.currentLevel.contractSize[addrHex] = db.getCode(addr).length } } if (log.getDepth() === 1) { + // NUMBER opcode at top level split levels + if (opcode === 'NUMBER') { + this.numberCounter++ + } + if (this.numberLevels[this.numberCounter] == null) { + this.currentLevel = this.numberLevels[this.numberCounter] = { + access: {}, + opcodes: {}, + contractSize: {}, + } + } + this.lastOp = '' return } @@ -153,32 +223,37 @@ export function bundlerCollectorTracer (): BundlerCollectorTracer { this.lastOp = opcode if (opcode === 'SLOAD' || opcode === 'SSTORE') { - const slot = log.stack.peek(0).toString(16) - const addr = toHex(log.contract.getAddress()) - let access - if ((access = this.currentLevel.access[addr]) == null) { - this.currentLevel.access[addr] = access = { + const slot = toWord(log.stack.peek(0).toString(16)) + const slotHex = toHex(slot) + const addr = log.contract.getAddress() + const addrHex = toHex(addr) + let access = this.currentLevel.access[addrHex] as any + if (access == null) { + access = { reads: {}, - writes: {} + writes: {}, + } + this.currentLevel.access[addrHex] = access + } + if (opcode === 'SLOAD') { + // read slot values before this UserOp was created + // (so saving it if it was written before the first read) + if (access.reads[slotHex] == null && access.writes[slotHex] == null) { + access.reads[slotHex] = toHex(db.getState(addr, slot)) } + } else { + this.countSlot(access.writes, slotHex) } - this.countSlot(opcode === 'SLOAD' ? access.reads : access.writes, slot) } - if (opcode === 'REVERT' || opcode === 'RETURN') { - const ofs = parseInt(log.stack.peek(0).toString()) - const len = parseInt(log.stack.peek(1).toString()) - this.debug.push( - opcode + ' ' + toHex(log.memory.slice(ofs, ofs + len)).slice(0, 100) - ) - } else if (opcode === 'KECCAK256') { + if (opcode === 'KECCAK256') { // collect keccak on 64-byte blocks const ofs = parseInt(log.stack.peek(0).toString()) const len = parseInt(log.stack.peek(1).toString()) // currently, solidity uses only 2-word (6-byte) for a key. this might change.. // still, no need to return too much - if (len < 512) { - // if (len == 64) { + if (len > 20 && len < 512) { + // if (len === 64) { this.keccak.push(toHex(log.memory.slice(ofs, ofs + len))) } } else if (opcode.startsWith('LOG')) { @@ -193,9 +268,9 @@ export function bundlerCollectorTracer (): BundlerCollectorTracer { const data = toHex(log.memory.slice(ofs, ofs + len)) this.logs.push({ topics, - data + data, }) } - } + }, } } diff --git a/packages/boba/bundler/src/BundlerConfig.ts b/packages/boba/bundler/src/BundlerConfig.ts index dda0df06c5..f52d14e866 100644 --- a/packages/boba/bundler/src/BundlerConfig.ts +++ b/packages/boba/bundler/src/BundlerConfig.ts @@ -4,12 +4,21 @@ import ow from 'ow' export interface BundlerConfig { beneficiary: string entryPoint: string + entryPointWrapper?: string gasFactor: string - helper: string minBalance: string mnemonic: string network: string port: string + unsafe: boolean + conditionalRpc: boolean + whitelist?: string[] + blacklist?: string[] + maxBundleGas: number + minStake: string + minUnstakeDelay: number + autoBundleInterval: number + autoBundleMempoolSize: number addressManager: string l1NodeWeb3Url: string } @@ -18,20 +27,34 @@ export interface BundlerConfig { export const BundlerConfigShape = { beneficiary: ow.string, entryPoint: ow.string, + entryPointWrapper: ow.optional.string, gasFactor: ow.string, - helper: ow.string, minBalance: ow.string, mnemonic: ow.string, network: ow.string, port: ow.string, + unsafe: ow.boolean, + conditionalRpc: ow.boolean, + whitelist: ow.optional.array.ofType(ow.string), + blacklist: ow.optional.array.ofType(ow.string), + maxBundleGas: ow.number, + minStake: ow.string, + minUnstakeDelay: ow.number, + autoBundleInterval: ow.number, + autoBundleMempoolSize: ow.number, addressManager: ow.string, - l1NodeWeb3Url: ow.string + l1NodeWeb3Url: ow.string, } // TODO: consider if we want any default fields at all // TODO: implement merging config (args -> config.js -> default) and runtime shape validation export const bundlerConfigDefault: Partial = { port: '3000', - helper: '0xdD747029A0940e46D20F17041e747a7b95A67242', - entryPoint: '0x602aB3881Ff3Fa8dA60a8F44Cf633e91bA1FdB69' + entryPoint: '0x1306b01bC3e4AD202612D3843387e94737673F53', + unsafe: false, + conditionalRpc: false, + minStake: '1', + minUnstakeDelay: 60, + autoBundleInterval: 1, + autoBundleMempoolSize: 1, } diff --git a/packages/boba/bundler/src/BundlerServer.ts b/packages/boba/bundler/src/BundlerServer.ts index 959ee1bccd..8db931e3b8 100644 --- a/packages/boba/bundler/src/BundlerServer.ts +++ b/packages/boba/bundler/src/BundlerServer.ts @@ -3,20 +3,32 @@ import cors from 'cors' import express, { Express, Response, Request } from 'express' import { Provider } from '@ethersproject/providers' import { Wallet, utils } from 'ethers' -import { hexlify, parseEther } from 'ethers/lib/utils' +import { parseEther } from 'ethers/lib/utils' -import { erc4337RuntimeVersion } from '@boba/bundler_utils' +import { + AddressZero, + deepHexlify, + erc4337RuntimeVersion, +} from '@boba/bundler_utils' import { BundlerConfig } from './BundlerConfig' import { UserOpMethodHandler } from './UserOpMethodHandler' import { Server } from 'http' +import { RpcError } from './utils' +import { UserOperationStruct } from '@boba/accountabstraction' +import { DebugMethodHandler } from './DebugMethodHandler' +import Debug from 'debug' +import { ExecutionErrors } from './modules/Types' + +const debug = Debug('aa.rpc') export class BundlerServer { app: Express private readonly httpServer: Server - constructor ( + constructor( readonly methodHandler: UserOpMethodHandler, + readonly debugHandler: DebugMethodHandler, readonly config: BundlerConfig, readonly provider: Provider, readonly wallet: Wallet @@ -37,18 +49,44 @@ export class BundlerServer { startingPromise: Promise - async asyncStart (): Promise { + async asyncStart(): Promise { await this.startingPromise } - async stop (): Promise { + async stop(): Promise { this.httpServer.close() } - async _preflightCheck (): Promise { + async _preflightCheck(): Promise { if ((await this.provider.getCode(this.config.entryPoint)) === '0x') { this.fatal(`entrypoint not deployed at ${this.config.entryPoint}`) } + + if ((await this.provider.getCode(this.config.entryPointWrapper)) === '0x') { + this.fatal(`entrypoint wrapper not deployed at ${this.config.entryPointWrapper}`) + } + + // minimal UserOp to revert with "FailedOp" + const emptyUserOp: UserOperationStruct = { + sender: AddressZero, + callData: '0x', + initCode: AddressZero, + paymasterAndData: '0x', + nonce: 0, + preVerificationGas: 0, + verificationGasLimit: 100000, + callGasLimit: 0, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + signature: '0x', + } + // TODO: https://github.com/bobanetwork/boba/issues/759 + // // await EntryPoint__factory.connect(this.config.entryPoint,this.provider).callStatic.addStake(0) + // const err = await EntryPoint__factory.connect(this.config.entryPoint, this.provider).callStatic.simulateValidation(emptyUserOp) + // .catch(e => e) + // if (err?.errorName !== 'FailedOp') { + // this.fatal(`Invalid entryPoint contract at ${this.config.entryPoint}. wrong version?`) + // } const bal = await this.provider.getBalance(this.wallet.address) console.log( 'signer', @@ -66,49 +104,53 @@ export class BundlerServer { } } - fatal (msg: string): never { + fatal(msg: string): never { console.error('FATAL:', msg) process.exit(1) } - intro (req: Request, res: Response): void { + intro(req: Request, res: Response): void { res.send( `Account-Abstraction Bundler v.${erc4337RuntimeVersion}. please use "/rpc"` ) } - async rpc (req: Request, res: Response): Promise { + async rpc(req: Request, res: Response): Promise { const { method, params, jsonrpc, id } = req.body + debug('>>', { jsonrpc, id, method, params }) try { - const result = await this.handleMethod(method, params) + const result = deepHexlify(await this.handleMethod(method, params)) console.log('sent', method, '-', result) + debug('<<', { jsonrpc, id, result }) res.send({ jsonrpc, id, - result + result, }) } catch (err: any) { const error = { message: err.message, data: err.data, - code: err.code + code: err.code, } - console.log('failed: ', method, JSON.stringify(error)) + console.log('failed: ', method, 'error:', JSON.stringify(error)) + debug('<<', { jsonrpc, id, error }) + res.send({ jsonrpc, id, - error + error, }) } } - async handleMethod (method: string, params: any[]): Promise { + async handleMethod(method: string, params: any[]): Promise { let result: any switch (method) { case 'eth_chainId': // eslint-disable-next-line no-case-declarations const { chainId } = await this.provider.getNetwork() - result = hexlify(chainId) + result = chainId break case 'eth_supportedEntryPoints': result = await this.methodHandler.getSupportedEntryPoints() @@ -119,8 +161,56 @@ export class BundlerServer { params[1] ) break + // this uses custom errors that will work post bedrock. for now they need to be handled via wrapper + // https://github.com/bobanetwork/boba/issues/752 + case 'eth_estimateUserOperationGas': + // result = await this.methodHandler.estimateUserOperationGas( + // params[0], + // params[1] + // ) + //break + // remove after https://github.com/bobanetwork/boba/issues/752 + throw new RpcError(`Method ${method} is not supported`, -32601) + case 'eth_getUserOperationReceipt': + result = await this.methodHandler.getUserOperationReceipt(params[0]) + break + case 'eth_getUserOperationByHash': + result = await this.methodHandler.getUserOperationByHash(params[0]) + break + case 'web3_clientVersion': + result = this.methodHandler.clientVersion() + break + //TODO https://github.com/bobanetwork/boba/issues/761 + // case 'debug_bundler_clearState': + // this.debugHandler.clearState() + // result = 'ok' + // break + // case 'debug_bundler_dumpMempool': + // result = await this.debugHandler.dumpMempool() + // break + // case 'debug_bundler_setReputation': + // await this.debugHandler.setReputation(params[0]) + // result = 'ok' + // break + // case 'debug_bundler_dumpReputation': + // result = await this.debugHandler.dumpReputation() + // break + // case 'debug_bundler_setBundlingMode': + // await this.debugHandler.setBundlingMode(params[0]) + // result = 'ok' + // break + // case 'debug_bundler_setBundleInterval': + // await this.debugHandler.setBundleInterval(params[0], params[1]) + // result = 'ok' + // break + // case 'debug_bundler_sendBundleNow': + // result = await this.debugHandler.sendBundleNow() + // if (result == null) { + // result = 'ok' + // } + // break default: - throw new Error(`Method ${method} is not supported`) + throw new RpcError(`Method ${method} is not supported`, -32601) } return result } diff --git a/packages/boba/bundler/src/Config.ts b/packages/boba/bundler/src/Config.ts new file mode 100644 index 0000000000..088ea97c83 --- /dev/null +++ b/packages/boba/bundler/src/Config.ts @@ -0,0 +1,56 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ +import ow from 'ow' +import fs from 'fs' + +import { BundlerConfig, bundlerConfigDefault, BundlerConfigShape } from './BundlerConfig' +import { ethers, Wallet } from 'ethers' +import { BaseProvider } from '@ethersproject/providers' + +function getCommandLineParams (programOpts: any): Partial { + const params: any = {} + for (const bundlerConfigShapeKey in BundlerConfigShape) { + const optionValue = programOpts[bundlerConfigShapeKey] + if (optionValue != null) { + params[bundlerConfigShapeKey] = optionValue + } + } + console.log(params['maxBundleGas']) + params['maxBundleGas'] = parseInt(params['maxBundleGas'], 10) + return params as BundlerConfig +} + +function mergeConfigs (...sources: Array>): BundlerConfig { + const mergedConfig = Object.assign({}, ...sources) + ow(mergedConfig, ow.object.exactShape(BundlerConfigShape)) + return mergedConfig +} + +export async function resolveConfiguration (programOpts: any): Promise<{ config: BundlerConfig, provider: BaseProvider, wallet: Wallet }> { + const commandLineParams = getCommandLineParams(programOpts) + let fileConfig: Partial = {} + const configFileName = programOpts.config + if (fs.existsSync(configFileName)) { + fileConfig = JSON.parse(fs.readFileSync(configFileName, 'ascii')) + } + + const config = mergeConfigs(bundlerConfigDefault, fileConfig, commandLineParams) + + const provider: BaseProvider = config.network === 'hardhat' + // eslint-disable-next-line + ? require('hardhat').ethers.provider + : ethers.getDefaultProvider(config.network) + + let mnemonic: string + let wallet: Wallet + try { + if (fs.existsSync(config.mnemonic)) { + mnemonic = fs.readFileSync(config.mnemonic, 'ascii').trim() + wallet = Wallet.fromMnemonic(mnemonic).connect(provider) + } else { + wallet = new Wallet(config.mnemonic, provider) + } + } catch (e: any) { + throw new Error(`Unable to read --mnemonic ${config.mnemonic}: ${e.message as string}`) + } + return { config, provider, wallet } +} diff --git a/packages/boba/bundler/src/DebugMethodHandler.ts b/packages/boba/bundler/src/DebugMethodHandler.ts new file mode 100644 index 0000000000..98580de466 --- /dev/null +++ b/packages/boba/bundler/src/DebugMethodHandler.ts @@ -0,0 +1,63 @@ +import { ExecutionManager } from './modules/ExecutionManager' +import { ReputationDump, ReputationManager } from './modules/ReputationManager' +import { MempoolManager } from './modules/MempoolManager' +import { SendBundleReturn } from './modules/BundleManager' +import { EventsManager } from './modules/EventsManager' + +export class DebugMethodHandler { + constructor ( + readonly execManager: ExecutionManager, + readonly eventsManager: EventsManager, + readonly repManager: ReputationManager, + readonly mempoolMgr: MempoolManager + ) { + } + + setBundlingMode (mode: 'manual' | 'auto'): void { + this.setBundleInterval(mode) + } + + setBundleInterval (interval: number | 'manual' | 'auto', maxPoolSize = 100): void { + if (interval == null) { + throw new Error('must specify interval |manual|auto') + } + if (interval === 'auto') { + // size=0 ==> auto-bundle on each userop + this.execManager.setAutoBundler(0, 0) + } else if (interval === 'manual') { + // interval=0, but never auto-mine + this.execManager.setAutoBundler(0, 1000) + } else { + this.execManager.setAutoBundler(interval, maxPoolSize) + } + } + + async sendBundleNow (): Promise { + const ret = await this.execManager.attemptBundle(true) + // handlePastEvents is performed before processing the next bundle. + // however, in debug mode, we are interested in the side effects + // (on the mempool) of this "sendBundle" operation + await this.eventsManager.handlePastEvents() + return ret + } + + clearState (): void { + this.mempoolMgr.clearState() + this.repManager.clearState() + } + + async dumpMempool (): Promise { + return this.mempoolMgr.dump() + } + + setReputation (param: any): ReputationDump { + if (param.reputation == null) { + throw new Error('expected structure { reputation: {addr:{opsSeen:1, opsIncluded:2} }') + } + return this.repManager.setReputation(param) + } + + dumpReputation (): ReputationDump { + return this.repManager.dump() + } +} diff --git a/packages/boba/bundler/src/GethTracer.ts b/packages/boba/bundler/src/GethTracer.ts index d5251bbb18..b0d9799342 100644 --- a/packages/boba/bundler/src/GethTracer.ts +++ b/packages/boba/bundler/src/GethTracer.ts @@ -1,3 +1,5 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ +/* eslint-disable prettier/prettier */ import { JsonRpcProvider, TransactionRequest } from '@ethersproject/providers' import { BigNumber } from 'ethers' import { Deferrable } from '@ethersproject/properties' @@ -15,41 +17,27 @@ import { resolveProperties } from 'ethers/lib/utils' type LogTracerFunc = () => LogTracer // eslint-disable-next-line @typescript-eslint/naming-convention -export async function debug_traceCall ( - provider: JsonRpcProvider, - tx: Deferrable, - options: TraceOptions -): Promise { +export async function debug_traceCall (provider: JsonRpcProvider, tx: Deferrable, options: TraceOptions): Promise { const tx1 = await resolveProperties(tx) - const ret = await provider.send('debug_traceCall', [ - tx1, - 'latest', - tracer2string(options) - ]) + const traceOptions = tracer2string(options) + const ret = await provider.send('debug_traceCall', [tx1, 'latest', traceOptions]).catch(e => { + console.log('ex=', e.message) + console.log('tracer=', traceOptions.tracer?.toString().split('\n').map((line, index) => `${index + 1}: ${line}`).join('\n')) + throw e + }) // return applyTracer(ret, options) return ret } // a hack for network that doesn't have traceCall: mine the transaction, and use debug_traceTransaction -export async function execAndTrace ( - provider: JsonRpcProvider, - tx: Deferrable, - options: TraceOptions -): Promise { +export async function execAndTrace (provider: JsonRpcProvider, tx: Deferrable, options: TraceOptions): Promise { const hash = await provider.getSigner().sendUncheckedTransaction(tx) return await debug_traceTransaction(provider, hash, options) } // eslint-disable-next-line @typescript-eslint/naming-convention -export async function debug_traceTransaction ( - provider: JsonRpcProvider, - hash: string, - options: TraceOptions -): Promise { - const ret = await provider.send('debug_traceTransaction', [ - hash, - tracer2string(options) - ]) +export async function debug_traceTransaction (provider: JsonRpcProvider, hash: string, options: TraceOptions): Promise { + const ret = await provider.send('debug_traceTransaction', [hash, tracer2string(options)]) // const tx = await provider.getTransaction(hash) // return applyTracer(tx, ret, options) return ret @@ -58,20 +46,26 @@ export async function debug_traceTransaction ( /** * extract the body of "LogTracerFunc". * note that we extract the javascript body, even if the function was created as typescript - * * @param func */ export function getTracerBodyString (func: LogTracerFunc): string { const tracerFunc = func.toString() // function must return a plain object: // function xyz() { return {...}; } - const regexp = - /function \w+\s*\(\s*\)\s*{\s*return\s*(\{[\s\S]+\});?\s*\}\s*$/ // (\{[\s\S]+\}); \} $/ + const regexp = /function\w*\s*\(\s*\)\s*{\s*return\s*(\{[\s\S]+\});?\s*\}\s*$/ // (\{[\s\S]+\}); \} $/ const match = tracerFunc.match(regexp) if (match == null) { throw new Error('Not a simple method returning value') } - return match[1] + + let ret = match[1] + console.log(ret) + ret = ret + // .replace(/\/\/.*\n/g,'\n') + // .replace(/\n\s*\n/g, '\n') + .replace(/\b(?:const|let)\b/g, '') + // console.log('== tracer source',ret.split('\n').map((line,index)=>`${index}: ${line}`).join('\n')) + return ret } function tracer2string (options: TraceOptions): TraceOptions { @@ -156,39 +150,19 @@ export interface LogTracer { exit?: (frame: LogFrameResult) => void } -export class LogCallFrame { - constructor ( - readonly type: string, - readonly caller: string, - readonly address: string, - readonly value: BigNumber, - readonly input: string, - readonly gas: BigNumber - ) {} - - getType (): string { - return this.type - } // - returns a string which has the type of the call frame - - getFrom (): string { - return this.caller - } // - returns the address of the call frame sender - - getTo (): string { - return this.address - } // - returns the address of the call frame target - - getInput (): string { - return this.input - } // - returns the input as a buffer - - getGas (): BigNumber { - return this.gas - } // - returns a Number which has the amount of gas provided for the frame - - getValue (): BigNumber { - return this.value - } // - returns a big.Int with the amount to be transferred only if available, otherwise undefined +export interface LogCallFrame { + // - returns a string which has the type of the call frame + getType: () => string + // - returns the address of the call frame sender + getFrom: () => string + // - returns the address of the call frame target + getTo: () => string + // - returns the input as a buffer + getInput: () => string + // - returns a Number which has the amount of gas provided for the frame + getGas: () => number + // - returns a big.Int with the amount to be transferred only if available, otherwise undefined + getValue: () => BigNumber } export interface LogFrameResult { @@ -232,7 +206,7 @@ export interface LogStep { getCost: () => number // returns the cost of the opcode as a Number getDepth: () => number // returns the execution depth as a Number getRefund: () => number // returns the amount to be refunded as a Number - getError: () => any // returns information about the error if one occured, otherwise returns undefined + getError: () => string | undefined // returns information about the error if one occured, otherwise returns undefined // If error is non-empty, all other fields should be ignored. } diff --git a/packages/boba/bundler/src/RpcTypes.ts b/packages/boba/bundler/src/RpcTypes.ts new file mode 100644 index 0000000000..a045f2c42d --- /dev/null +++ b/packages/boba/bundler/src/RpcTypes.ts @@ -0,0 +1,61 @@ +import { BigNumberish } from 'ethers' +import { TransactionReceipt } from '@ethersproject/providers' +import { UserOperation } from './modules/Types' + +/** + * RPC calls return types + */ + +/** + * return value from estimateUserOpGas + */ +export interface EstimateUserOpGasResult { + /** + * the preVerification gas used by this UserOperation. + */ + preVerificationGas: BigNumberish + /** + * gas used for validation of this UserOperation, including account creation + */ + verificationGas: BigNumberish + /** + * the deadline after which this UserOperation is invalid (not a gas estimation parameter, but returned by validation + */ + deadline?: BigNumberish + /** + * estimated cost of calling the account with the given callData + */ + callGasLimit: BigNumberish +} + +export interface UserOperationByHashResponse { + userOperation: UserOperation + entryPoint: string + blockNumber: number + blockHash: string + transactionHash: string +} + +export interface UserOperationReceipt { + /// the request hash + userOpHash: string + /// the account sending this UserOperation + sender: string + /// account nonce + nonce: BigNumberish + /// the paymaster used for this userOp (or empty) + paymaster?: string + /// actual payment for this UserOperation (by either paymaster or account) + actualGasCost: BigNumberish + /// total gas used by this UserOperation (including preVerification, creation, validation and execution) + actualGasUsed: BigNumberish + /// did this execution completed without revert + success: boolean + /// in case of revert, this is the revert reason + reason?: string + /// the logs generated by this UserOperation (not including logs of other UserOperations in the same bundle) + logs: any[] + + // the transaction receipt for this transaction (of entire bundle, not only this UserOperation) + receipt: TransactionReceipt +} diff --git a/packages/boba/bundler/src/UserOpMethodHandler.ts b/packages/boba/bundler/src/UserOpMethodHandler.ts index a021a0ad6e..8d2bbe4e06 100644 --- a/packages/boba/bundler/src/UserOpMethodHandler.ts +++ b/packages/boba/bundler/src/UserOpMethodHandler.ts @@ -1,54 +1,67 @@ -import { BigNumber, ethers, Wallet } from 'ethers' -import { - JsonRpcProvider, - JsonRpcSigner, - Provider -} from '@ethersproject/providers' +import { BigNumber, BigNumberish, Signer } from 'ethers' +import { Log, Provider } from '@ethersproject/providers' import { BundlerConfig } from './BundlerConfig' -import { hexValue, resolveProperties } from 'ethers/lib/utils' -import { AddressZero, rethrowError } from '@boba/bundler_utils' -import { debug_traceCall } from './GethTracer' -import { - BundlerCollectorReturn, - bundlerCollectorTracer -} from './BundlerCollectorTracer' -import { UserOperationStruct, EntryPoint } from '@boba/accountabstraction' +import { resolveProperties } from 'ethers/lib/utils' +import { deepHexlify, erc4337RuntimeVersion } from '@boba/bundler_utils' +import { UserOperationStruct, EntryPoint, EntryPointWrapper } from '@boba/accountabstraction' import { UserOperationEventEvent } from '@boba/accountabstraction/dist/types/EntryPoint' -import { deepHexlify, requireCond, RpcError } from './utils' -import Debug from 'debug' -import { calcPreVerificationGas } from '@boba/bundler_sdk/dist/src/calcPreVerificationGas' - -const debug = Debug('aa.handler.userop') +import { calcPreVerificationGas } from '@boba/bundler_sdk/dist/calcPreVerificationGas' +import { requireCond, RpcError, tostr } from './utils' +import { ExecutionManager } from './modules/ExecutionManager' +import { getAddr } from './modules/moduleUtils' +import { UserOperationByHashResponse, UserOperationReceipt } from './RpcTypes' +import { + ExecutionErrors, + UserOperation, + ValidationErrors, +} from './modules/Types' const HEX_REGEX = /^0x[a-fA-F\d]*$/i +/** + * return value from estimateUserOpGas + */ +export interface EstimateUserOpGasResult { + /** + * the preVerification gas used by this UserOperation. + */ + preVerificationGas: BigNumberish + /** + * gas used for validation of this UserOperation, including account creation + */ + verificationGas: BigNumberish + + /** + * (possibly future timestamp) after which this UserOperation is valid + */ + validAfter?: BigNumberish + + /** + * the deadline after which this UserOperation is invalid (not a gas estimation parameter, but returned by validation + */ + validUntil?: BigNumberish + /** + * estimated cost of calling the account with the given callData + */ + callGasLimit: BigNumberish +} + export class UserOpMethodHandler { - constructor ( + constructor( + readonly execManager: ExecutionManager, readonly provider: Provider, - readonly signer: Wallet | JsonRpcSigner, + readonly signer: Signer, readonly config: BundlerConfig, - readonly entryPoint: EntryPoint // readonly bundlerHelper: BundlerHelper + readonly entryPoint: EntryPoint, + readonly entryPointWrapper?: EntryPointWrapper ) {} - clientVersion?: string - - async isGeth (): Promise { - if (this.clientVersion == null) { - this.clientVersion = await (this.provider as JsonRpcProvider).send( - 'web3_clientVersion', - [] - ) - } - debug('client version', this.clientVersion) - return this.clientVersion?.match('Geth') != null - } - - async getSupportedEntryPoints (): Promise { + async getSupportedEntryPoints(): Promise { return [this.config.entryPoint] } - async selectBeneficiary (): Promise { + async selectBeneficiary(): Promise { const currentBalance = await this.provider.getBalance( this.signer.getAddress() ) @@ -66,20 +79,45 @@ export class UserOpMethodHandler { return beneficiary } - async validateUserOperation ( + async _validateParameters( userOp1: UserOperationStruct, - requireSignature = true + entryPointInput: string, + requireSignature = true, + requireGasParams = true ): Promise { + requireCond(entryPointInput != null, 'No entryPoint param', -32602) + + if ( + entryPointInput?.toString().toLowerCase() !== + this.config.entryPoint.toLowerCase() + ) { + throw new Error( + `The EntryPoint at "${entryPointInput}" is not supported. This bundler uses ${this.config.entryPoint}` + ) + } // minimal sanity check: userOp exists, and all members are hex requireCond(userOp1 != null, 'No UserOperation param') const userOp = (await resolveProperties(userOp1)) as any - const fieldNames = - 'sender,nonce,initCode,callData,callGasLimit,verificationGasLimit,preVerificationGas,maxFeePerGas,maxPriorityFeePerGas,paymasterAndData' - const fields = fieldNames.split(',') + const fields = [ + 'sender', + 'nonce', + 'initCode', + 'callData', + 'paymasterAndData', + ] if (requireSignature) { fields.push('signature') } + if (requireGasParams) { + fields.push( + 'preVerificationGas', + 'verificationGasLimit', + 'callGasLimit', + 'maxFeePerGas', + 'maxPriorityFeePerGas' + ) + } fields.forEach((key) => { requireCond( userOp[key] != null, @@ -96,186 +134,135 @@ export class UserOpMethodHandler { } /** - * simulate UserOperation. - * Note that simulation requires debug API: - * - debug_traceCall, to trace the call. - * + * eth_estimateUserOperationGas RPC api. + * TODO: gonna work post bedrock! * @param userOp1 * @param entryPointInput */ - async simulateUserOp ( + async estimateUserOperationGas( userOp1: UserOperationStruct, entryPointInput: string - ): Promise { - const userOp = deepHexlify(await resolveProperties(userOp1)) - - await this.validateUserOperation(userOp, false) - requireCond(entryPointInput != null, 'No entryPoint param') - - if ( - entryPointInput.toLowerCase() !== this.config.entryPoint.toLowerCase() - ) { - throw new Error( - `The EntryPoint at "${entryPointInput}" is not supported. This bundler uses ${this.config.entryPoint}` - ) + ): Promise { + const userOp = { + ...(await resolveProperties(userOp1)), + // default values for missing fields. + paymasterAndData: '0x', + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + preVerificationGas: 0, + verificationGasLimit: 10e6, } - const simulateCall = this.entryPoint.interface.encodeFunctionData( - 'simulateValidation', - [userOp] - ) - const revert = await this.entryPoint.callStatic - .simulateValidation(userOp, { gasLimit: 10e6 }) + // todo: checks the existence of parameters, but since we hexlify the inputs, it fails to validate + await this._validateParameters(deepHexlify(userOp), entryPointInput) + // todo: validation manager duplicate? + const errorResult = await this.entryPoint.callStatic + .simulateValidation(userOp) .catch((e) => e) - // simulation always reverts... - if (revert.errorName === 'FailedOp') { - let data: any - if (revert.errorArgs.paymaster !== AddressZero) { - data = { paymaster: revert.errorArgs.paymaster } - } - throw new RpcError(revert.errorArgs.reason, -32500, data) - } - const provider = this.provider as JsonRpcProvider - if (await this.isGeth()) { - debug('=== sending simulate') - const simulationGas = BigNumber.from(50000).add( - userOp.verificationGasLimit - ) - - const result: BundlerCollectorReturn = await debug_traceCall( - provider, - { - from: ethers.constants.AddressZero, - to: this.entryPoint.address, - data: simulateCall, - gasLimit: simulationGas - }, - { tracer: bundlerCollectorTracer } + if (errorResult.errorName === 'FailedOp') { + throw new RpcError( + errorResult.errorArgs.at(-1), + ValidationErrors.SimulateValidation ) + } + // todo throw valid rpc error + if (errorResult.errorName !== 'ValidationResult') { + throw errorResult + } - debug('=== simulation result:', result) - // todo: validate keccak, access - // todo: block access to no-code addresses (might need update to tracer) - - const bannedOpCodes = new Set([ - 'GASPRICE', - 'GASLIMIT', - 'DIFFICULTY', - 'TIMESTAMP', - 'BASEFEE', - 'BLOCKHASH', - 'NUMBER', - 'SELFBALANCE', - 'BALANCE', - 'ORIGIN', - 'GAS', - 'CREATE', - 'COINBASE' - ]) + const { returnInfo } = errorResult.errorArgs + let { preOpGas, validAfter, validUntil } = returnInfo - const paymaster = - (userOp.paymasterAndData?.length ?? 0) >= 42 - ? userOp.paymasterAndData.toString().slice(0, 42) - : undefined - const validateOpcodes = result.numberLevels['0'].opcodes - const validatePaymasterOpcodes = result.numberLevels['1'].opcodes - // console.log('debug=', result.debug.join('\n- ')) - Object.keys(validateOpcodes).forEach((opcode) => - requireCond( - !bannedOpCodes.has(opcode), - `wallet uses banned opcode: ${opcode}`, - 32501 - ) - ) - Object.keys(validatePaymasterOpcodes).forEach((opcode) => - requireCond( - !bannedOpCodes.has(opcode), - `paymaster uses banned opcode: ${opcode}`, - 32501, - { paymaster } - ) - ) - if (userOp.initCode.length > 2) { - requireCond( - (validateOpcodes.CREATE2 ?? 0) <= 1, - 'initCode with too many CREATE2', - 32501 - ) - } else { - requireCond( - (validateOpcodes.CREATE2 ?? 0) < 1, - 'banned opcode: CREATE2', - 32501 - ) - } - requireCond( - (validatePaymasterOpcodes.CREATE2 ?? 0) < 1, - 'paymaster uses banned opcode: CREATE2', - 32501, - { paymaster } - ) + const callGasLimit = await this.provider + .estimateGas({ + from: this.entryPoint.address, + to: userOp.sender, + data: userOp.callData, + }) + .then((b) => b.toNumber()) + .catch((err) => { + const message = + err.message.match(/reason="(.*?)"/)?.at(1) ?? 'execution reverted' + throw new RpcError(message, ExecutionErrors.UserOperationReverted) + }) + validAfter = BigNumber.from(validAfter) + validUntil = BigNumber.from(validUntil) + if (validUntil === BigNumber.from(0)) { + validUntil = undefined + } + if (validAfter === BigNumber.from(0)) { + validAfter = undefined + } + const preVerificationGas = calcPreVerificationGas(userOp) + const verificationGas = BigNumber.from(preOpGas).toNumber() + return { + preVerificationGas, + verificationGas, + validAfter, + validUntil, + callGasLimit, } } - async sendUserOperation ( + async sendUserOperation( userOp1: UserOperationStruct, entryPointInput: string ): Promise { + + await this._validateParameters(userOp1, entryPointInput) + const userOp = await resolveProperties(userOp1) - if ( - entryPointInput.toLowerCase() !== this.config.entryPoint.toLowerCase() - ) { - throw new Error( - `The EntryPoint at "${entryPointInput}" is not supported. This bundler uses ${this.config.entryPoint}` - ) - } console.log( - `UserOperation: Sender=${ - userOp.sender - } EntryPoint=${entryPointInput} Paymaster=${hexValue( + `UserOperation: Sender=${userOp.sender} Nonce=${tostr( + userOp.nonce + )} EntryPoint=${entryPointInput} Paymaster=${getAddr( userOp.paymasterAndData )}` ) - - // this is doing the geth debug_traceCall that the current ORU doesn't support - // await this.simulateUserOp(userOp1, entryPointInput) - const beneficiary = await this.selectBeneficiary() - const userOpHash = await this.entryPoint.getUserOpHash(userOp) - - // TODO: this is only printing debug info, remove once not necessary - // await this.printGasEstimationDebugInfo(userOp, beneficiary) - - const expectedPreVerificationGas = calcPreVerificationGas(userOp) - const preVerificationGas = BigNumber.from( - await userOp.preVerificationGas - ).toNumber() - if (expectedPreVerificationGas > preVerificationGas) { - throw new Error( - `userOp.preVerificationGas too low: expected ${expectedPreVerificationGas} but got ${preVerificationGas}` - ) - } - - const gasLimit = undefined - debug('using gasLimit=', gasLimit) - await this.entryPoint - .handleOps([userOp], beneficiary, { gasLimit }) - .catch(rethrowError) - - // await postExecutionDump(this.entryPoint, userOpHash) - return userOpHash + await this.execManager.sendUserOperation(userOp, entryPointInput) + return this.entryPoint.getUserOpHash(userOp) } - async _getUserOperationEvent ( + async _getUserOperationEvent( userOpHash: string ): Promise { + // TODO: eth_getLogs is throttled. must be acceptable for finding a UserOperation by hash const event = await this.entryPoint.queryFilter( this.entryPoint.filters.UserOperationEvent(userOpHash) ) return event[0] } - async getUserOperationReceipt (userOpHash: string): Promise { + // filter full bundle logs, and leave only logs for the given userOpHash + // @param userOpEvent - the event of our UserOp (known to exist in the logs) + // @param logs - full bundle logs. after each group of logs there is a single UserOperationEvent with unique hash. + _filterLogs(userOpEvent: UserOperationEventEvent, logs: Log[]): Log[] { + let startIndex = -1 + let endIndex = -1 + logs.forEach((log, index) => { + if (log?.topics[0] === userOpEvent.topics[0]) { + // process UserOperationEvent + if (log.topics[1] === userOpEvent.topics[1]) { + // it's our userOpHash. save as end of logs array + endIndex = index + } else { + // it's a different hash. remember it as beginning index, but only if we didn't find our end index yet. + if (endIndex === -1) { + startIndex = index + } + } + } + }) + if (endIndex === -1) { + throw new Error('fatal: no UserOperationEvent in logs') + } + return logs.slice(startIndex + 1, endIndex) + } + + async getUserOperationByHash( + userOpHash: string + ): Promise { requireCond( userOpHash?.toString()?.match(HEX_REGEX) != null, 'Missing/invalid userOpHash', @@ -285,13 +272,62 @@ export class UserOpMethodHandler { if (event == null) { return null } - const receipt = (await event.getTransactionReceipt()) as any - receipt.status = event.args.success ? 1 : 0 - receipt.userOpHash = userOpHash - return deepHexlify(receipt) + const tx = await event.getTransaction() + if (tx.to !== this.entryPoint.address) { + throw new Error('unable to parse transaction') + } + const parsed = this.entryPoint.interface.parseTransaction(tx) + const ops: UserOperation[] = parsed?.args.ops + if (ops == null) { + throw new Error('failed to parse transaction') + } + const op = ops.find( + (op) => + op.sender === event.args.sender && + BigNumber.from(op.nonce).eq(event.args.nonce) + ) + if (op == null) { + throw new Error('unable to find userOp in transaction') + } + + const { + sender, + nonce, + initCode, + callData, + callGasLimit, + verificationGasLimit, + preVerificationGas, + maxFeePerGas, + maxPriorityFeePerGas, + paymasterAndData, + signature, + } = op + + return deepHexlify({ + userOperation: { + sender, + nonce, + initCode, + callData, + callGasLimit, + verificationGasLimit, + preVerificationGas, + maxFeePerGas, + maxPriorityFeePerGas, + paymasterAndData, + signature, + }, + entryPoint: this.entryPoint.address, + transactionHash: tx.hash, + blockHash: tx.blockHash ?? '', + blockNumber: tx.blockNumber ?? 0, + }) } - async getUserOperationTransactionByHash (userOpHash: string): Promise { + async getUserOperationReceipt( + userOpHash: string + ): Promise { requireCond( userOpHash?.toString()?.match(HEX_REGEX) != null, 'Missing/invalid userOpHash', @@ -301,8 +337,22 @@ export class UserOpMethodHandler { if (event == null) { return null } - const tx = (await event.getTransaction()) as any - tx.userOpHash = userOpHash - return deepHexlify(tx) + const receipt = await event.getTransactionReceipt() + const logs = this._filterLogs(event, receipt.logs) + return deepHexlify({ + userOpHash, + sender: event.args.sender, + nonce: event.args.nonce, + actualGasCost: event.args.actualGasCost, + actualGasUsed: event.args.actualGasUsed, + success: event.args.success, + logs, + receipt, + }) + } + + clientVersion(): string { + // eslint-disable-next-line + return 'aa-bundler/' + erc4337RuntimeVersion + (this.config.unsafe ? '/unsafe' : '') } } diff --git a/packages/boba/bundler/src/exec.ts b/packages/boba/bundler/src/exec.ts index 3ede9a4542..92d55ba01d 100644 --- a/packages/boba/bundler/src/exec.ts +++ b/packages/boba/bundler/src/exec.ts @@ -1,6 +1,7 @@ import { runBundler, showStackTraces } from './runBundler' -void runBundler(process.argv).catch((e) => { - console.error('Aborted:', showStackTraces ? e : e.message) - process.exit(1) -}) +void runBundler(process.argv) + .catch(e => { + console.error('Aborted:', showStackTraces ? e : e.message) + process.exit(1) + }) diff --git a/packages/boba/bundler/src/modules/BundleManager.ts b/packages/boba/bundler/src/modules/BundleManager.ts new file mode 100644 index 0000000000..00c7b08797 --- /dev/null +++ b/packages/boba/bundler/src/modules/BundleManager.ts @@ -0,0 +1,254 @@ +import { EntryPoint, EntryPointWrapper } from '@boba/accountabstraction' +import { MempoolManager } from './MempoolManager' +import { ValidateUserOpResult, ValidationManager } from './ValidationManager' +import { BigNumber, BigNumberish } from 'ethers' +import { JsonRpcProvider, JsonRpcSigner } from '@ethersproject/providers' +import Debug from 'debug' +import { ReputationManager, ReputationStatus } from './ReputationManager' +import { Mutex } from 'async-mutex' +import { StorageMap, UserOperation } from './Types' +import { getAddr, mergeStorageMap, runContractScript, runContractScriptviaWrapper } from './moduleUtils' +import { EventsManager } from './EventsManager' +import { ErrorDescription } from '@ethersproject/abi/lib/interface' + +const debug = Debug('aa.exec.cron') + +export interface SendBundleReturn { + transactionHash: string + userOpHashes: string[] +} + +export class BundleManager { + provider: JsonRpcProvider + signer: JsonRpcSigner + mutex = new Mutex() + + constructor ( + readonly entryPoint: EntryPoint, + readonly eventsManager: EventsManager, + readonly mempoolManager: MempoolManager, + readonly validationManager: ValidationManager, + readonly reputationManager: ReputationManager, + readonly beneficiary: string, + readonly minSignerBalance: BigNumberish, + readonly maxBundleGas: number, + // use eth_sendRawTransactionConditional with storage map + readonly conditionalRpc: boolean, + // in conditionalRpc: always put root hash (not specific storage slots) for "sender" entries + readonly mergeToAccountRootHash: boolean = false, + readonly entryPointWrapper?: EntryPointWrapper + ) { + this.provider = entryPoint.provider as JsonRpcProvider + this.signer = entryPoint.signer as JsonRpcSigner + } + + /** + * attempt to send a bundle: + * collect UserOps from mempool into a bundle + * send this bundle. + */ + async sendNextBundle (): Promise { + return await this.mutex.runExclusive(async () => { + debug('sendNextBundle') + + // first flush mempool from already-included UserOps, by actively scanning past events. + await this.handlePastEvents() + + const [bundle, storageMap] = await this.createBundle() + if (bundle.length === 0) { + debug('sendNextBundle - no bundle to send') + } else { + const beneficiary = await this._selectBeneficiary() + const ret = await this.sendBundle(bundle, beneficiary, storageMap) + debug(`sendNextBundle exit - after sent a bundle of ${bundle.length} `) + return ret + } + }) + } + + async handlePastEvents (): Promise { + await this.eventsManager.handlePastEvents() + } + + /** + * submit a bundle. + * after submitting the bundle, remove all UserOps from the mempool + * @return SendBundleReturn the transaction and UserOp hashes on successful transaction, or null on failed transaction + */ + async sendBundle (userOps: UserOperation[], beneficiary: string, storageMap: StorageMap): Promise { + try { + //const feeData = await this.provider.getFeeData() + const tx = await this.entryPoint.populateTransaction.handleOps(userOps, beneficiary, { + //type: 1, + nonce: await this.signer.getTransactionCount(), + gasLimit: 10e6, + gasPrice: 10e9 + //maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 0, + //maxFeePerGas: feeData.maxFeePerGas ?? 0 + }) + tx.chainId = this.provider._network.chainId + const signedTx = await this.signer.signTransaction(tx) + + let ret: string + if (this.conditionalRpc) { + debug('eth_sendRawTransactionConditional', storageMap) + ret = await this.provider.send('eth_sendRawTransactionConditional', [ + signedTx, { knownAccounts: storageMap } + ]) + debug('eth_sendRawTransactionConditional ret=', ret) + } else { + // ret = await this.signer.sendTransaction(tx) + ret = await this.provider.send('eth_sendRawTransaction', [signedTx]) + debug('eth_sendRawTransaction ret=', ret) + } + // TODO: parse ret, and revert if needed. + debug('ret=', ret) + debug('sent handleOps with', userOps.length, 'ops. removing from mempool') + // hashes are needed for debug rpc only. + const hashes = await this.getUserOpHashes(userOps) + return { + transactionHash: ret, + userOpHashes: hashes + } + } catch (e: any) { + let parsedError: ErrorDescription + try { + parsedError = this.entryPoint.interface.parseError((e.data?.data ?? e.data)) + } catch (e1) { + this.checkFatal(e) + console.warn('Failed handleOps, but non-FailedOp error', e) + return + } + const { + opIndex, + reason + } = parsedError.args + const userOp = userOps[opIndex] + const reasonStr: string = reason.toString() + if (reasonStr.startsWith('AA3')) { + this.reputationManager.crashedHandleOps(getAddr(userOp.paymasterAndData)) + } else if (reasonStr.startsWith('AA2')) { + this.reputationManager.crashedHandleOps(userOp.sender) + } else if (reasonStr.startsWith('AA1')) { + this.reputationManager.crashedHandleOps(getAddr(userOp.initCode)) + } else { + this.mempoolManager.removeUserOp(userOp) + console.warn(`Failed handleOps sender=${userOp.sender} reason=${reasonStr}`) + } + } + } + + // fatal errors we know we can't recover + checkFatal (e: any): void { + // console.log('ex entries=',Object.entries(e)) + if (e.error?.code === -32601) { + throw e + } + } + + async createBundle (): Promise<[UserOperation[], StorageMap]> { + const entries = this.mempoolManager.getSortedForInclusion() + const bundle: UserOperation[] = [] + + // paymaster deposit should be enough for all UserOps in the bundle. + const paymasterDeposit: { [paymaster: string]: BigNumber } = {} + // throttled paymasters and deployers are allowed only small UserOps per bundle. + const stakedEntityCount: { [addr: string]: number } = {} + // each sender is allowed only once per bundle + const senders = new Set() + + const storageMap: StorageMap = {} + let totalGas = BigNumber.from(0) + debug('got mempool of ', entries.length) + for (const entry of entries) { + const paymaster = getAddr(entry.userOp.paymasterAndData) + const factory = getAddr(entry.userOp.initCode) + const paymasterStatus = this.reputationManager.getStatus(paymaster) + const deployerStatus = this.reputationManager.getStatus(factory) + if (paymasterStatus === ReputationStatus.BANNED || deployerStatus === ReputationStatus.BANNED) { + this.mempoolManager.removeUserOp(entry.userOp) + continue + } + if (paymaster != null && (paymasterStatus === ReputationStatus.THROTTLED ?? (stakedEntityCount[paymaster] ?? 0) > 1)) { + debug('skipping throttled paymaster', entry.userOp.sender, entry.userOp.nonce) + continue + } + if (factory != null && (deployerStatus === ReputationStatus.THROTTLED ?? (stakedEntityCount[factory] ?? 0) > 1)) { + debug('skipping throttled factory', entry.userOp.sender, entry.userOp.nonce) + continue + } + if (senders.has(entry.userOp.sender)) { + debug('skipping already included sender', entry.userOp.sender, entry.userOp.nonce) + // allow only a single UserOp per sender per bundle + continue + } + let validationResult: ValidateUserOpResult + try { + // re-validate UserOp. no need to check stake, since it cannot be reduced between first and 2nd validation + validationResult = await this.validationManager.validateUserOp(entry.userOp, entry.referencedContracts, false) + } catch (e: any) { + debug('failed 2nd validation:', e.message) + // failed validation. don't try anymore + this.mempoolManager.removeUserOp(entry.userOp) + continue + } + // todo: we take UserOp's callGasLimit, even though it will probably require less (but we don't + // attempt to estimate it to check) + // which means we could "cram" more UserOps into a bundle. + const userOpGasCost = BigNumber.from(validationResult.returnInfo.preOpGas).add(entry.userOp.callGasLimit) + const newTotalGas = totalGas.add(userOpGasCost) + if (newTotalGas.gt(this.maxBundleGas)) { + break + } + + if (paymaster != null) { + if (paymasterDeposit[paymaster] == null) { + paymasterDeposit[paymaster] = await this.entryPoint.balanceOf(paymaster) + } + if (paymasterDeposit[paymaster].lt(validationResult.returnInfo.prefund)) { + // not enough balance in paymaster to pay for all UserOps + // (but it passed validation, so it can sponsor them separately + continue + } + stakedEntityCount[paymaster] = (stakedEntityCount[paymaster] ?? 0) + 1 + paymasterDeposit[paymaster] = paymasterDeposit[paymaster].sub(validationResult.returnInfo.prefund) + } + if (factory != null) { + stakedEntityCount[factory] = (stakedEntityCount[factory] ?? 0) + 1 + } + + // If sender's account already exist: replace with its storage root hash + if (this.mergeToAccountRootHash && this.conditionalRpc && entry.userOp.initCode.length <= 2) { + const { storageHash } = await this.provider.send('eth_getProof', [entry.userOp.sender, [], 'latest']) + storageMap[entry.userOp.sender.toLowerCase()] = storageHash + } + mergeStorageMap(storageMap, validationResult.storageMap) + + senders.add(entry.userOp.sender) + bundle.push(entry.userOp) + totalGas = newTotalGas + } + return [bundle, storageMap] + } + + /** + * determine who should receive the proceedings of the request. + * if signer's balance is too low, send it to signer. otherwise, send to configured beneficiary. + */ + async _selectBeneficiary (): Promise { + const currentBalance = await this.provider.getBalance(this.signer.getAddress()) + let beneficiary = this.beneficiary + // below min-balance redeem to the signer, to keep it active. + if (currentBalance.lte(this.minSignerBalance)) { + beneficiary = await this.signer.getAddress() + console.log('low balance. using ', beneficiary, 'as beneficiary instead of ', this.beneficiary) + } + return beneficiary + } + + // helper function to get hashes of all UserOps + async getUserOpHashes (userOps: UserOperation[]): Promise { + const userOpHashes = await runContractScriptviaWrapper(this.entryPoint, this.entryPointWrapper, userOps) + return userOpHashes + } +} diff --git a/packages/boba/bundler/src/modules/EventsManager.ts b/packages/boba/bundler/src/modules/EventsManager.ts new file mode 100644 index 0000000000..e19462bfca --- /dev/null +++ b/packages/boba/bundler/src/modules/EventsManager.ts @@ -0,0 +1,110 @@ +import { + AccountDeployedEvent, + UserOperationEventEvent, + SignatureAggregatorChangedEvent, +} from '@boba/accountabstraction/dist/types/EntryPoint' +import { ReputationManager } from './ReputationManager' +import { EntryPoint } from '@boba/accountabstraction' +import Debug from 'debug' +import { TypedEvent } from '@boba/accountabstraction/dist/types/common' +import { MempoolManager } from './MempoolManager' + +const debug = Debug('aa.events') + +/** + * listen to events. trigger ReputationManager's Included + */ +export class EventsManager { + lastBlock = 0 + + constructor( + readonly entryPoint: EntryPoint, + readonly mempoolManager: MempoolManager, + readonly reputationManager: ReputationManager + ) {} + + /** + * automatically listen to all UserOperationEvent events + */ + initEventListener(): void { + this.entryPoint.on( + this.entryPoint.filters.UserOperationEvent(), + (...args) => { + const ev = args.slice(-1)[0] + void this.handleEvent(ev as any) + } + ) + } + + /** + * process all new events since last run + */ + async handlePastEvents(): Promise { + const events = await this.entryPoint.queryFilter( + { address: this.entryPoint.address }, + this.lastBlock + ) + for (const ev of events) { + this.handleEvent(ev) + } + } + + handleEvent( + ev: + | UserOperationEventEvent + | AccountDeployedEvent + | SignatureAggregatorChangedEvent + ): void { + switch (ev.event) { + case 'UserOperationEvent': + this.handleUserOperationEvent(ev as any) + break + case 'AccountDeployed': + this.handleAccountDeployedEvent(ev as any) + break + case 'SignatureAggregatorForUserOperations': + this.handleAggregatorChangedEvent(ev as any) + break + } + this.lastBlock = ev.blockNumber + 1 + } + + handleAggregatorChangedEvent(ev: SignatureAggregatorChangedEvent): void { + debug('handle ', ev.event, ev.args.aggregator) + this.eventAggregator = ev.args.aggregator + this.eventAggregatorTxHash = ev.transactionHash + } + + eventAggregator: string | null = null + eventAggregatorTxHash: string | null = null + + // aggregator event is sent once per events bundle for all UserOperationEvents in this bundle. + // it is not sent at all if the transaction is handleOps + getEventAggregator(ev: TypedEvent): string | null { + if (ev.transactionHash !== this.eventAggregatorTxHash) { + this.eventAggregator = null + this.eventAggregatorTxHash = ev.transactionHash + } + return this.eventAggregator + } + + // AccountDeployed event is sent before each UserOperationEvent that deploys a contract. + handleAccountDeployedEvent(ev: AccountDeployedEvent): void { + this._includedAddress(ev.args.factory) + } + + handleUserOperationEvent(ev: UserOperationEventEvent): void { + const hash = ev.args.userOpHash + this.mempoolManager.removeUserOp(hash) + this._includedAddress(ev.args.sender) + this._includedAddress(ev.args.paymaster) + this._includedAddress(this.getEventAggregator(ev)) + } + + _includedAddress(data: string | null): void { + if (data != null && data.length > 42) { + const addr = data.slice(0, 42) + this.reputationManager.updateIncludedStatus(addr) + } + } +} diff --git a/packages/boba/bundler/src/modules/ExecutionManager.ts b/packages/boba/bundler/src/modules/ExecutionManager.ts new file mode 100644 index 0000000000..3a46102d2e --- /dev/null +++ b/packages/boba/bundler/src/modules/ExecutionManager.ts @@ -0,0 +1,93 @@ +import { ReputationManager } from './ReputationManager' +import { clearInterval } from 'timers' +import { MempoolManager } from './MempoolManager' +import { BundleManager, SendBundleReturn } from './BundleManager' +import Debug from 'debug' +import { ValidationManager } from './ValidationManager' +import { Mutex } from 'async-mutex' +import { UserOperation } from './Types' + +const debug = Debug('aa.exec') + +/** + * execute userOps manually or using background timer. + * This is the top-level interface to send UserOperation + */ +export class ExecutionManager { + private reputationCron: any + private autoBundleInterval: any + private maxMempoolSize = 0 // default to auto-mining + private autoInterval = 0 + private readonly mutex = new Mutex() + + constructor (private readonly reputationManager: ReputationManager, + private readonly mempoolManager: MempoolManager, + private readonly bundleManager: BundleManager, + private readonly validationManager: ValidationManager + ) { + } + + /** + * send a user operation through the bundler. + * @param userOp the UserOp to send. + */ + async sendUserOperation (userOp: UserOperation, entryPointInput: string): Promise { + await this.mutex.runExclusive(async () => { + debug('sendUserOperation') + this.validationManager.validateInputParameters(userOp, entryPointInput) + const validationResult = await this.validationManager.validateUserOp(userOp, undefined) + const userOpHash = await this.validationManager.entryPoint.getUserOpHash(userOp) + this.mempoolManager.addUserOp(userOp, + userOpHash, + validationResult.returnInfo.prefund, + validationResult.senderInfo, + validationResult.referencedContracts, + validationResult.aggregatorInfo?.addr) + await this.attemptBundle(false) + }) + } + + setReputationCron (interval: number): void { + debug('set reputation interval to', interval) + clearInterval(this.reputationCron) + if (interval !== 0) { + this.reputationCron = setInterval(() => this.reputationManager.hourlyCron(), interval) + } + } + + /** + * set automatic bundle creation + * @param autoBundleInterval autoBundleInterval to check. send bundle anyway after this time is elapsed. zero for manual mode + * @param maxMempoolSize maximum # of pending mempool entities. send immediately when there are that many entities in the mempool. + * set to zero (or 1) to automatically send each UserOp. + * (note: there is a chance that the sent bundle will contain less than this number, in case only some mempool entities can be sent. + * e.g. throttled paymaster) + */ + setAutoBundler (autoBundleInterval: number, maxMempoolSize: number): void { + debug('set auto-bundle autoBundleInterval=', autoBundleInterval, 'maxMempoolSize=', maxMempoolSize) + clearInterval(this.autoBundleInterval) + this.autoInterval = autoBundleInterval + if (autoBundleInterval !== 0) { + this.autoBundleInterval = setInterval(() => { + void this.attemptBundle(true) + }, autoBundleInterval * 1000) + } + this.maxMempoolSize = maxMempoolSize + } + + /** + * attempt to send a bundle now. + * @param force + */ + async attemptBundle (force = true): Promise { + debug('attemptBundle force=', force, 'count=', this.mempoolManager.count(), 'max=', this.maxMempoolSize) + if (force || this.mempoolManager.count() >= this.maxMempoolSize) { + const ret = await this.bundleManager.sendNextBundle() + if (this.maxMempoolSize === 0) { + // in "auto-bundling" mode (which implies auto-mining) also flush mempool from included UserOps + await this.bundleManager.handlePastEvents() + } + return ret + } + } +} diff --git a/packages/boba/bundler/src/modules/MempoolManager.ts b/packages/boba/bundler/src/modules/MempoolManager.ts new file mode 100644 index 0000000000..1adafaf8dd --- /dev/null +++ b/packages/boba/bundler/src/modules/MempoolManager.ts @@ -0,0 +1,157 @@ +import { BigNumber, BigNumberish } from 'ethers' +import { getAddr } from './moduleUtils' +import { requireCond } from '../utils' +import { ReputationManager } from './ReputationManager' +import Debug from 'debug' +import { ReferencedCodeHashes, StakeInfo, UserOperation, ValidationErrors } from './Types' + +const debug = Debug('aa.mempool') + +export interface MempoolEntry { + userOp: UserOperation + userOpHash: string + prefund: BigNumberish + referencedContracts: ReferencedCodeHashes + // aggregator, if one was found during simulation + aggregator?: string +} + +type MempoolDump = UserOperation[] + +const MAX_MEMPOOL_USEROPS_PER_SENDER = 4 + +export class MempoolManager { + private mempool: MempoolEntry[] = [] + + // count entities in mempool. + private entryCount: { [addr: string]: number | undefined } = {} + + constructor ( + readonly reputationManager: ReputationManager) { + } + + count (): number { + return this.mempool.length + } + + // add userOp into the mempool, after initial validation. + // replace existing, if any (and if new gas is higher) + // revets if unable to add UserOp to mempool (too many UserOps with this sender) + addUserOp (userOp: UserOperation, userOpHash: string, prefund: BigNumberish, senderInfo: StakeInfo, referencedContracts: ReferencedCodeHashes, aggregator?: string): void { + const entry: MempoolEntry = { + userOp, + userOpHash, + prefund, + referencedContracts, + aggregator + } + const index = this._findBySenderNonce(userOp.sender, userOp.nonce) + if (index !== -1) { + const oldEntry = this.mempool[index] + this.checkReplaceUserOp(oldEntry, entry) + debug('replace userOp', userOp.sender, userOp.nonce) + this.mempool[index] = entry + } else { + debug('add userOp', userOp.sender, userOp.nonce) + this.entryCount[userOp.sender] = (this.entryCount[userOp.sender] ?? 0) + 1 + this.checkSenderCountInMempool(userOp, senderInfo) + this.mempool.push(entry) + } + this.updateSeenStatus(aggregator, userOp) + } + + private updateSeenStatus (aggregator: string | undefined, userOp: UserOperation): void { + this.reputationManager.updateSeenStatus(aggregator) + this.reputationManager.updateSeenStatus(getAddr(userOp.paymasterAndData)) + this.reputationManager.updateSeenStatus(getAddr(userOp.initCode)) + } + + // check if there are already too many entries in mempool for that sender. + // (allow 4 entities if unstaked, or any number if staked) + private checkSenderCountInMempool (userOp: UserOperation, senderInfo: StakeInfo): void { + if ((this.entryCount[userOp.sender] ?? 0) > MAX_MEMPOOL_USEROPS_PER_SENDER) { + // already enough entities with this sender in mempool. + // check that it is staked + this.reputationManager.checkStake('account', senderInfo) + } + } + + private checkReplaceUserOp (oldEntry: MempoolEntry, entry: MempoolEntry): void { + const oldGas = BigNumber.from(oldEntry.userOp.maxPriorityFeePerGas).toNumber() + const newGas = BigNumber.from(entry.userOp.maxPriorityFeePerGas).toNumber() + // the error is "invalid fields", even though it is detected only after validation + requireCond(newGas > oldGas * 1.1, + `Replacement UserOperation must have higher gas (old=${oldGas} new=${newGas}) `, ValidationErrors.InvalidFields) + } + + getSortedForInclusion (): MempoolEntry[] { + const copy = Array.from(this.mempool) + + function cost (op: UserOperation): number { + // TODO: need to consult basefee and maxFeePerGas + return BigNumber.from(op.maxPriorityFeePerGas).toNumber() + } + + copy.sort((a, b) => cost(a.userOp) - cost(b.userOp)) + return copy + } + + _findBySenderNonce (sender: string, nonce: BigNumberish): number { + for (let i = 0; i < this.mempool.length; i++) { + const curOp = this.mempool[i].userOp + if (curOp.sender === sender && curOp.nonce === nonce) { + return i + } + } + return -1 + } + + _findByHash (hash: string): number { + for (let i = 0; i < this.mempool.length; i++) { + const curOp = this.mempool[i] + if (curOp.userOpHash === hash) { + return i + } + } + return -1 + } + + /** + * remove UserOp from mempool. either it is invalid, or was included in a block + * @param userOpOrHash + */ + removeUserOp (userOpOrHash: UserOperation | string): void { + let index: number + if (typeof userOpOrHash === 'string') { + index = this._findByHash(userOpOrHash) + } else { + index = this._findBySenderNonce(userOpOrHash.sender, userOpOrHash.nonce) + } + if (index !== -1) { + const userOp = this.mempool[index].userOp + debug('removeUserOp', userOp.sender, userOp.nonce) + this.mempool.splice(index, 1) + const count = (this.entryCount[userOp.sender] ?? 0) - 1 + if (count <= 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.entryCount[userOp.sender] + } else { + this.entryCount[userOp.sender] = count + } + } + } + + /** + * debug: dump mempool content + */ + dump (): MempoolDump { + return this.mempool.map(entry => entry.userOp) + } + + /** + * for debugging: clear current in-memory state + */ + clearState (): void { + this.mempool = [] + } +} diff --git a/packages/boba/bundler/src/modules/ReputationManager.ts b/packages/boba/bundler/src/modules/ReputationManager.ts new file mode 100644 index 0000000000..6c21092fca --- /dev/null +++ b/packages/boba/bundler/src/modules/ReputationManager.ts @@ -0,0 +1,208 @@ +import Debug from 'debug' +import { requireCond, tostr } from '../utils' +import { BigNumber } from 'ethers' +import { StakeInfo, ValidationErrors } from './Types' + +const debug = Debug('aa.rep') + +/** + * throttled entities are allowed minimal number of entries per bundle. banned entities are allowed none + */ + +export enum ReputationStatus { + OK, THROTTLED, BANNED +} + +export interface ReputationParams { + minInclusionDenominator: number + throttlingSlack: number + banSlack: number +} + +export const BundlerReputationParams: ReputationParams = { + minInclusionDenominator: 10, + throttlingSlack: 10, + banSlack: 10 +} + +export const NonBundlerReputationParams: ReputationParams = { + minInclusionDenominator: 100, + throttlingSlack: 10, + banSlack: 10 +} + +interface ReputationEntry { + address: string + opsSeen: number + opsIncluded: number + status?: ReputationStatus +} + +export type ReputationDump = ReputationEntry[] + +export class ReputationManager { + constructor ( + readonly params: ReputationParams, + readonly minStake: BigNumber, + readonly minUnstakeDelay: number) { + } + + private entries: { [address: string]: ReputationEntry } = {} + // black-listed entities - always banned + readonly blackList = new Set() + + // white-listed entities - always OK. + readonly whitelist = new Set() + + /** + * debug: dump reputation map (with updated "status" for each entry) + */ + dump (): ReputationDump { + return Object.values(this.entries) + } + + /** + * exponential backoff of opsSeen and opsIncluded values + */ + hourlyCron (): void { + Object.keys(this.entries).forEach(addr => { + const entry = this.entries[addr] + entry.opsSeen = Math.floor(entry.opsSeen * 23 / 24) + entry.opsIncluded = Math.floor(entry.opsSeen * 23 / 24) + if (entry.opsIncluded === 0 && entry.opsSeen === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.entries[addr] + } + }) + } + + addWhitelist (...params: string[]): void { + params.forEach(item => this.whitelist.add(item)) + } + + addBlacklist (...params: string[]): void { + params.forEach(item => this.blackList.add(item)) + } + + _getOrCreate (addr: string): ReputationEntry { + let entry = this.entries[addr] + if (entry == null) { + this.entries[addr] = entry = { + address: addr, + opsSeen: 0, + opsIncluded: 0 + } + } + return entry + } + + /** + * address seen in the mempool triggered by the + * @param addr + */ + updateSeenStatus (addr?: string): void { + if (addr == null) { + return + } + const entry = this._getOrCreate(addr) + entry.opsSeen++ + debug('after seen++', addr, entry) + } + + /** + * found paymaster/deployer/agregator on-chain. + * triggered by the EventsManager. + * @param addr + */ + updateIncludedStatus (addr: string): void { + const entry = this._getOrCreate(addr) + entry.opsIncluded++ + debug('after Included++', addr, entry) + } + + isWhitelisted (addr: string): boolean { + return this.whitelist.has(addr) + } + + // https://github.com/eth-infinitism/account-abstraction/blob/develop/eip/EIPS/eip-4337.md#reputation-scoring-and-throttlingbanning-for-paymasters + getStatus (addr?: string): ReputationStatus { + if (addr == null || this.whitelist.has(addr)) { + return ReputationStatus.OK + } + if (this.blackList.has(addr)) { + return ReputationStatus.BANNED + } + const entry = this.entries[addr] + if (entry == null) { + return ReputationStatus.OK + } + const minExpectedIncluded = Math.min(entry.opsSeen / this.params.minInclusionDenominator) + if (minExpectedIncluded <= entry.opsIncluded + this.params.throttlingSlack) { + return ReputationStatus.OK + } else if (minExpectedIncluded <= entry.opsIncluded + this.params.banSlack) { + return ReputationStatus.THROTTLED + } else { + return ReputationStatus.BANNED + } + } + + /** + * an entity that caused handleOps to revert, which requires re-building the bundle from scratch. + * should be banned immediately, by increasing its opSeen counter + * @param addr + */ + crashedHandleOps (addr: string | undefined): void { + if (addr == null) { + return + } + // todo: what value to put? how long do we want this banning to hold? + const entry = this._getOrCreate(addr) + entry.opsSeen = 100 + entry.opsIncluded = 0 + debug('crashedHandleOps', addr, entry) + } + + /** + * for debugging: clear in-memory state + */ + clearState (): void { + this.entries = {} + } + + /** + * for debugging: put in the given reputation entries + * @param entries + */ + setReputation (reputations: ReputationDump): ReputationDump { + reputations.forEach(rep => { + this.entries[rep.address] = { + address: rep.address, + opsSeen: rep.opsSeen, + opsIncluded: rep.opsIncluded + } + }) + return this.dump() + } + + /** + * check the given address (account/paymaster/deployer/aggregator) is staked + * @param title the address title (field name to put into the "data" element) + * @param raddr the address to check the stake of. null is "ok" + * @param info stake info from verification. if not given, then read from entryPoint + */ + checkStake (title: 'account' | 'paymaster' | 'aggregator' | 'deployer', info?: StakeInfo): void { + if (info?.addr == null || this.isWhitelisted(info.addr)) { + return + } + requireCond(this.getStatus(info.addr) !== ReputationStatus.BANNED, + `${title} ${info.addr} is banned`, + ValidationErrors.Reputation, { [title]: info.addr }) + + requireCond(BigNumber.from(info.stake).gte(this.minStake), + `${title} ${info.addr} stake ${tostr(info.stake)} is too low (min=${tostr(this.minStake)})`, + ValidationErrors.InsufficientStake) + requireCond(BigNumber.from(info.unstakeDelaySec).gte(this.minUnstakeDelay), + `${title} ${info.addr} unstake delay ${tostr(info.unstakeDelaySec)} is too low (min=${tostr(this.minUnstakeDelay)})`, + ValidationErrors.InsufficientStake) + } +} diff --git a/packages/boba/bundler/src/modules/Types.ts b/packages/boba/bundler/src/modules/Types.ts new file mode 100644 index 0000000000..d97c4d8340 --- /dev/null +++ b/packages/boba/bundler/src/modules/Types.ts @@ -0,0 +1,47 @@ +import { BigNumberish } from 'ethers' +import { NotPromise } from '@boba/bundler_utils' +import { UserOperationStruct } from '@boba/accountabstraction' + +export enum ValidationErrors { + InvalidFields = -32602, + SimulateValidation = -32500, + SimulatePaymasterValidation = -32501, + OpcodeValidation = -32502, + ExpiresShortly = -32503, + Reputation = -32504, + InsufficientStake = -32505, + UnsupportedSignatureAggregator = -32506, + InvalidSignature = -32507, +} + +export enum ExecutionErrors { + UserOperationReverted = -32521 +} + +export interface StakeInfo { + addr: string + stake: BigNumberish + unstakeDelaySec: BigNumberish +} + +export interface ReferencedCodeHashes { + // addresses accessed during this user operation + addresses: string[] + + // keccak over the code of all referenced addresses + hash: string +} + +export type UserOperation = NotPromise + +export interface SlotMap { + [slot: string]: string +} + +/** + * map of storage + * for each address, either a root hash, or a map of slot:value + */ +export interface StorageMap { + [address: string]: string | SlotMap +} diff --git a/packages/boba/bundler/src/modules/ValidationManager.ts b/packages/boba/bundler/src/modules/ValidationManager.ts new file mode 100644 index 0000000000..79706e394e --- /dev/null +++ b/packages/boba/bundler/src/modules/ValidationManager.ts @@ -0,0 +1,307 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ +import { EntryPoint, EntryPointWrapper } from '@boba/accountabstraction' +import { ReputationManager } from './ReputationManager' +import { BigNumber, BigNumberish, BytesLike, ethers } from 'ethers' +import { requireCond, RpcError } from '../utils' +import { AddressZero } from '@boba/bundler_utils' +import { calcPreVerificationGas } from '@boba/bundler_sdk/dist/calcPreVerificationGas' + +import Debug from 'debug' +import { GetCodeHashes__factory } from '../../dist/src/types' +import { + ReferencedCodeHashes, + StakeInfo, + StorageMap, + UserOperation, + ValidationErrors, +} from './Types' +import { getAddr, runContractScript } from './moduleUtils' +import { hexlify } from 'ethers/lib/utils' + +/** + * result from successful simulateValidation + */ +export interface ValidationResult { + returnInfo: { + preOpGas: BigNumberish + prefund: BigNumberish + sigFailed: boolean + validAfter: number + validUntil: number + paymasterContext: string + } + + senderInfo: StakeInfo + factoryInfo?: StakeInfo + paymasterInfo?: StakeInfo + aggregatorInfo?: StakeInfo +} + +export interface ValidateUserOpResult extends ValidationResult { + referencedContracts: ReferencedCodeHashes + storageMap: StorageMap +} + +const HEX_REGEX = /^0x[a-fA-F\d]*$/i + +export class ValidationManager { + constructor( + readonly entryPoint: EntryPoint, + readonly reputationManager: ReputationManager, + readonly unsafe: boolean, + readonly entryPointWrapper?: EntryPointWrapper + ) {} + + // standard eth_call to simulateValidation + async _callSimulateValidation( + userOp: UserOperation + ): Promise { + const simulateValidation = + await this.entryPointWrapper.callStatic.simulateValidation(userOp, { + gasLimit: 10e6, + }) + return this._parseResult(userOp, simulateValidation) + } + + _parseResult( + userOp: UserOperation, + simulateValidation: any + ): ValidationResult { + + let failedOpStatus: EntryPointWrapper.FailedOpStatusStructOutput + let response: EntryPointWrapper.ResponseStructOutput + ;[failedOpStatus, response] = simulateValidation + + if (!response.selectorType.startsWith('ValidationResult')) { + // parse it as FailedOp + // if its FailedOp, then we have the paymaster param... otherwise its an Error(string) + let paymaster = hexlify(userOp.paymasterAndData)?.slice(0, 42) + if (paymaster === AddressZero) { + paymaster = undefined + } + + if (paymaster == null) { + throw new RpcError( + `account validation failed: ${failedOpStatus.reason}`, + ValidationErrors.SimulateValidation + ) + } else { + throw new RpcError( + `paymaster validation failed: ${failedOpStatus.reason}`, + ValidationErrors.SimulatePaymasterValidation, + { paymaster } + ) + } + } + + // extract address from "data" (first 20 bytes) + // add it as "addr" member to the "stakeinfo" struct + // if no address, then return "undefined" instead of struct. + function fillEntity( + data: BytesLike, + info: StakeInfo | EntryPointWrapper.StakeInfoStructOutput + ): StakeInfo | undefined { + const addr = getAddr(data) + return addr == null + ? undefined + : { + ...info, + addr, + } + } + + return { + returnInfo: response.returnInfo, + senderInfo: { + ...response.senderInfo, + addr: userOp.sender, + }, + factoryInfo: fillEntity(userOp.initCode, response.factoryInfo), + paymasterInfo: fillEntity(userOp.paymasterAndData, response.paymasterInfo), + aggregatorInfo: fillEntity( + response.aggregatorInfo?.aggregator, + response.aggregatorInfo?.stakeInfo + ), + } + } + + /** + * validate UserOperation. + * should also handle unmodified memory (e.g. by referencing cached storage in the mempool + * one item to check that was un-modified is the aggregator.. + * + * @param userOp + */ + async validateUserOp( + userOp: UserOperation, + previousCodeHashes?: ReferencedCodeHashes, + checkStakes = true + ): Promise { + if (previousCodeHashes != null && previousCodeHashes.addresses.length > 0) { + const CodeHashesWrapper = await this.getCodeHashesWrapper( + previousCodeHashes.addresses + ) + const codeHashes = CodeHashesWrapper.hash + requireCond( + codeHashes === previousCodeHashes.hash, + 'modified code after first validation', + ValidationErrors.OpcodeValidation + ) + } + let codeHashes: ReferencedCodeHashes = { + addresses: [], + hash: '', + } + const storageMap: StorageMap = {} + + // NOTE: this mode doesn't do any opcode checking and no stake checking! + const res = await this._callSimulateValidation(userOp) + + requireCond( + !res.returnInfo.sigFailed, + 'Invalid UserOp signature or paymaster signature', + ValidationErrors.InvalidSignature + ) +//TODO +// requireCond( +// res.returnInfo.validUntil == null || +// res.returnInfo.validUntil + 30 < Date.now() / 1000, +// 'expires too soon', +// ValidationErrors.ExpiresShortly +// ) +console.log(`res.aggregatorInfo ${res.aggregatorInfo}`) +console.log(res.aggregatorInfo.addr) +console.log(res.aggregatorInfo.stake) +console.log(res.aggregatorInfo.unstakeDelaySec) + if ( + res.aggregatorInfo.addr !== AddressZero && + !BigNumber.from(0).eq(res.aggregatorInfo.stake) && + !BigNumber.from(0).eq(res.aggregatorInfo.unstakeDelaySec) + ) { + this.reputationManager.checkStake('aggregator', res.aggregatorInfo) + } + + requireCond( + res.aggregatorInfo.addr === AddressZero && + BigNumber.from(0).eq(res.aggregatorInfo.stake) && + BigNumber.from(0).eq(res.aggregatorInfo.unstakeDelaySec), + 'Currently not supporting aggregator', + ValidationErrors.UnsupportedSignatureAggregator + ) + + return { + ...res, + referencedContracts: codeHashes, + storageMap, + } + } + + async getCodeHashes(addresses: string[]): Promise { + const { hash } = await runContractScript( + this.entryPoint.provider, + new GetCodeHashes__factory(), + [addresses] + ) + + return { + hash, + addresses, + } + } + + async getCodeHashesWrapper(addresses: string[]): Promise { + const hash = await this.entryPointWrapper.getCodeHashes(addresses) + + return { + hash, + addresses, + } + } + + /** + * perform static checking on input parameters. + * + * @param userOp + * @param entryPointInput + * @param requireSignature + * @param requireGasParams + */ + validateInputParameters( + userOp: UserOperation, + entryPointInput: string, + requireSignature = true, + requireGasParams = true + ): void { + requireCond( + entryPointInput != null, + 'No entryPoint param', + ValidationErrors.InvalidFields + ) + requireCond( + entryPointInput.toLowerCase() === this.entryPoint.address.toLowerCase(), + `The EntryPoint at "${entryPointInput}" is not supported. This bundler uses ${this.entryPoint.address}`, + ValidationErrors.InvalidFields + ) + + // minimal sanity check: userOp exists, and all members are hex + requireCond( + userOp != null, + 'No UserOperation param', + ValidationErrors.InvalidFields + ) + + const fields = [ + 'sender', + 'nonce', + 'initCode', + 'callData', + 'paymasterAndData', + ] + if (requireSignature) { + fields.push('signature') + } + if (requireGasParams) { + fields.push( + 'preVerificationGas', + 'verificationGasLimit', + 'callGasLimit', + 'maxFeePerGas', + 'maxPriorityFeePerGas' + ) + } + fields.forEach((key) => { + const value: string = (userOp as any)[key]?.toString() + requireCond( + value != null, + 'Missing userOp field: ' + key + ' ' + JSON.stringify(userOp), + ValidationErrors.InvalidFields + ) + requireCond( + value.match(HEX_REGEX) != null, + `Invalid hex value for property ${key}:${value} in UserOp`, + ValidationErrors.InvalidFields + ) + }) + + requireCond( + userOp.paymasterAndData.length === 2 || + userOp.paymasterAndData.length >= 42, + 'paymasterAndData: must contain at least an address', + ValidationErrors.InvalidFields + ) + + // syntactically, initCode can be only the deployer address. but in reality, it must have calldata to uniquely identify the account + requireCond( + userOp.initCode.length === 2 || userOp.initCode.length >= 42, + 'initCode: must contain at least an address', + ValidationErrors.InvalidFields + ) + + const calcPreVerificationGas1 = calcPreVerificationGas(userOp) + requireCond( + userOp.preVerificationGas >= calcPreVerificationGas1, + `preVerificationGas too low: expected at least ${calcPreVerificationGas1}`, + ValidationErrors.InvalidFields + ) + } +} diff --git a/packages/boba/bundler/src/modules/initServer.ts b/packages/boba/bundler/src/modules/initServer.ts new file mode 100644 index 0000000000..12cf99f512 --- /dev/null +++ b/packages/boba/bundler/src/modules/initServer.ts @@ -0,0 +1,36 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ +/* eslint-disable prettier/prettier */ +import { ExecutionManager } from './ExecutionManager' +import { BundlerReputationParams, ReputationManager } from './ReputationManager' +import { MempoolManager } from './MempoolManager' +import { BundleManager } from './BundleManager' +import { ValidationManager } from './ValidationManager' +import { EntryPoint__factory, EntryPointWrapper__factory } from '@boba/accountabstraction' +import { parseEther } from 'ethers/lib/utils' +import { Signer } from 'ethers' +import { BundlerConfig } from '../BundlerConfig' +import { EventsManager } from './EventsManager' + +/** + * initialize server modules. + * returns the ExecutionManager and EventsManager (for handling events, to update reputation) + * @param config + * @param signer + */ +export function initServer (config: BundlerConfig, signer: Signer): [ExecutionManager, EventsManager, ReputationManager, MempoolManager] { + const entryPoint = EntryPoint__factory.connect(config.entryPoint, signer) + const entryPointWrapper = EntryPointWrapper__factory.connect(config.entryPointWrapper, signer) + const reputationManager = new ReputationManager(BundlerReputationParams, parseEther(config.minStake), config.minUnstakeDelay) + const mempoolManager = new MempoolManager(reputationManager) + const validationManager = new ValidationManager(entryPoint, reputationManager, config.unsafe, entryPointWrapper) + const eventsManager = new EventsManager(entryPoint, mempoolManager, reputationManager) + const bundleManager = new BundleManager(entryPoint, eventsManager, mempoolManager, validationManager, reputationManager, + config.beneficiary, parseEther(config.minBalance), config.maxBundleGas, config.conditionalRpc, false, entryPointWrapper) + const executionManager = new ExecutionManager(reputationManager, mempoolManager, bundleManager, validationManager) + + reputationManager.addWhitelist(...config.whitelist ?? []) + reputationManager.addBlacklist(...config.blacklist ?? []) + executionManager.setAutoBundler(config.autoBundleInterval, config.autoBundleMempoolSize) + + return [executionManager, eventsManager, reputationManager, mempoolManager] +} diff --git a/packages/boba/bundler/src/modules/moduleUtils.ts b/packages/boba/bundler/src/modules/moduleUtils.ts new file mode 100644 index 0000000000..deb2a897ed --- /dev/null +++ b/packages/boba/bundler/src/modules/moduleUtils.ts @@ -0,0 +1,93 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ +// misc utilities for the various modules. + +import { BytesLike, ContractFactory } from 'ethers' +import { hexlify, hexZeroPad, Result } from 'ethers/lib/utils' +import { SlotMap, StorageMap, UserOperation } from './Types' +import { Provider } from '@ethersproject/providers' +import { EntryPoint, EntryPointWrapper } from '@boba/accountabstraction' + +// extract address from initCode or paymasterAndData +export function getAddr(data?: BytesLike): string | undefined { + if (data == null) { + return undefined + } + const str = hexlify(data) + if (str.length >= 42) { + return str.slice(0, 42) + } + return undefined +} + +/** + * merge all validationStorageMap objects into merged map + * - entry with "root" (string) is always preferred over entry with slot-map + * - merge slot entries + * NOTE: slot values are supposed to be the value before the transaction started. + * so same address/slot in different validations should carry the same value + * + * @param mergedStorageMap + * @param validationStorageMap + */ +export function mergeStorageMap( + mergedStorageMap: StorageMap, + validationStorageMap: StorageMap +): StorageMap { + Object.entries(validationStorageMap).forEach(([addr, validationEntry]) => { + if (typeof validationEntry === 'string') { + // it's a root. override specific slots, if any + mergedStorageMap[addr] = validationEntry + } else if (typeof mergedStorageMap[addr] === 'string') { + // merged address already contains a root. ignore specific slot values + } else { + let slots: SlotMap + if (mergedStorageMap[addr] == null) { + slots = mergedStorageMap[addr] = {} + } else { + slots = mergedStorageMap[addr] as SlotMap + } + + Object.entries(validationEntry).forEach(([slot, val]) => { + slots[slot] = val + }) + } + }) + return mergedStorageMap +} + +export function toBytes32(b: BytesLike | number): string { + return hexZeroPad(hexlify(b).toLowerCase(), 32) +} + +/** + * run the constructor of the given type as a script: it is expected to revert with the script's return values. + * + * @param provider provider to use fo rthe call + * @param c - contract factory of the script class + * @param ctrParams constructor parameters + * @return an array of arguments of the error + * example usasge: + * hashes = await runContractScript(provider, new GetUserOpHashes__factory(), [entryPoint.address, userOps]).then(ret => ret.userOpHashes) + */ +export async function runContractScript( + provider: Provider, + c: T, + ctrParams: Parameters +): Promise { + const tx = c.getDeployTransaction(...ctrParams) + const ret = await provider.call(tx) + + const parsed = ContractFactory.getInterface(c.interface).parseError(ret) + if (parsed == null) { + throw new Error('unable to parse script (error) response: ' + ret) + } + return parsed.args +} + +export async function runContractScriptviaWrapper( + entryPoint: EntryPoint, + entryPointWrapper: EntryPointWrapper, + userOps: UserOperation[] +): Promise { + return entryPointWrapper.getUserOpHashes(entryPoint.address, userOps) +} diff --git a/packages/boba/bundler/src/parseScannerResult.ts b/packages/boba/bundler/src/parseScannerResult.ts new file mode 100644 index 0000000000..7d58bbc037 --- /dev/null +++ b/packages/boba/bundler/src/parseScannerResult.ts @@ -0,0 +1,468 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ + +import { + EntryPoint, + IEntryPoint__factory, + IPaymaster__factory, + SenderCreator__factory, +} from '@boba/accountabstraction' +import { hexZeroPad, Interface, keccak256 } from 'ethers/lib/utils' +import { BundlerCollectorReturn } from './BundlerCollectorTracer' +import { mapOf, requireCond } from './utils' +import { inspect } from 'util' + +import Debug from 'debug' +import { toBytes32 } from './modules/moduleUtils' +import { ValidationResult } from './modules/ValidationManager' +import { BigNumber, BigNumberish } from 'ethers' +import { + TestOpcodesAccountFactory__factory, + TestOpcodesAccount__factory, + TestStorageAccount__factory, +} from '../dist/src/types' +import { + StakeInfo, + StorageMap, + UserOperation, + ValidationErrors, +} from './modules/Types' + +const debug = Debug('aa.handler.opcodes') + +interface CallEntry { + to: string + type: string // call opcode + method: string // parsed method, or signash if unparsed + revert?: any // parsed output from REVERT + return?: any // parsed method output. + value?: BigNumberish +} + +/** + * parse all call operation in the trace. + * notes: + * - entries are ordered by the return (so nested call appears before its outer call + * - last entry is top-level return from "simulateValidation". it as ret and rettype, but no type or address + * + * @param tracerResults + */ +function parseCallStack(tracerResults: BundlerCollectorReturn): CallEntry[] { + const abi = Object.values( + [ + ...TestOpcodesAccount__factory.abi, + ...TestOpcodesAccountFactory__factory.abi, + ...TestStorageAccount__factory.abi, + ...SenderCreator__factory.abi, + ...IEntryPoint__factory.abi, + ...IPaymaster__factory.abi, + ].reduce((set, entry) => { + const key = `${entry.name}(${entry.inputs.map((i) => i.type).join(',')})` + // console.log('key=', key, keccak256(Buffer.from(key)).slice(0,10)) + return { + ...set, + [key]: entry, + } + }, {}) + ) as any + + const xfaces = new Interface(abi) + + function callCatch(x: () => T, def: T1): T | T1 { + try { + return x() + } catch { + return def + } + } + + const out: CallEntry[] = [] + const stack: any[] = [] + tracerResults.calls + .filter((x) => !x.type.startsWith('depth')) + .forEach((c) => { + if (c.type.match(/REVERT|RETURN/) != null) { + const top = stack.splice(-1)[0] ?? { + type: 'top', + method: 'validateUserOp', + } + const returnData: string = (c as any).data + if (top.type.match(/CREATE/) != null) { + out.push({ + to: top.to, + type: top.type, + method: '', + return: `len=${returnData.length}`, + }) + } else { + const method = callCatch( + () => xfaces.getFunction(top.method), + top.method + ) + if (c.type === 'REVERT') { + const parsedError = callCatch( + () => xfaces.parseError(returnData), + returnData + ) + out.push({ + to: top.to, + type: top.type, + method: method.name, + value: top.value, + revert: parsedError, + }) + } else { + const ret = callCatch( + () => xfaces.decodeFunctionResult(method, returnData), + returnData + ) + out.push({ + to: top.to, + type: top.type, + method: method.name ?? method, + return: ret, + }) + } + } + } else { + stack.push(c) + } + }) + + // TODO: verify that stack is empty at the end. + + return out +} + +/** + * slots associated with each entity. + * keccak( A || ...) is associated with "A" + * removed rule: keccak( ... || ASSOC ) (for a previously associated hash) is also associated with "A" + * + * @param stakeInfoEntities stake info for (factory, account, paymaster). factory and paymaster can be null. + * @param keccak array of buffers that were given to keccak in the transaction + */ +function parseEntitySlots( + stakeInfoEntities: { [addr: string]: StakeInfo | undefined }, + keccak: string[] +): { [addr: string]: Set } { + // for each entity (sender, factory, paymaster), hold the valid slot addresses + // valid: the slot was generated by keccak(entity || ...) + const entitySlots: { [addr: string]: Set } = {} + + keccak.forEach((k) => { + Object.values(stakeInfoEntities).forEach((info) => { + const addr = info?.addr?.toLowerCase() + if (addr == null) { + return + } + const addrPadded = toBytes32(addr) + if (entitySlots[addr] == null) { + entitySlots[addr] = new Set() + } + + const currentEntitySlots = entitySlots[addr] + + // valid slot: the slot was generated by keccak(entityAddr || ...) + if (k.startsWith(addrPadded)) { + // console.log('added mapping (balance) slot', value) + currentEntitySlots.add(keccak256(k)) + } + // disabled 2nd rule: .. or by keccak( ... || OWN) where OWN is previous allowed slot + // if (k.length === 130 && currentEntitySlots.has(k.slice(-64))) { + // // console.log('added double-mapping (allowance) slot', value) + // currentEntitySlots.add(value) + // } + }) + }) + + return entitySlots +} + +/** + * parse collected simulation traces and revert if they break our rules + * + * @param userOp the userOperation that was used in this simulation + * @param tracerResults the tracer return value + * @param validationResult output from simulateValidation + * @param entryPoint the entryPoint that hosted the "simulatedValidation" traced call. + * @return list of contract addresses referenced by this UserOp + */ +export function parseScannerResult( + userOp: UserOperation, + tracerResults: BundlerCollectorReturn, + validationResult: ValidationResult, + entryPoint: EntryPoint +): [string[], StorageMap] { + debug('=== simulation result:', inspect(tracerResults, true, 10, true)) + // todo: block access to no-code addresses (might need update to tracer) + + const entryPointAddress = entryPoint.address.toLowerCase() + + const bannedOpCodes = new Set([ + 'GASPRICE', + 'GASLIMIT', + 'DIFFICULTY', + 'TIMESTAMP', + 'BASEFEE', + 'BLOCKHASH', + 'NUMBER', + 'SELFBALANCE', + 'BALANCE', + 'ORIGIN', + 'GAS', + 'CREATE', + 'COINBASE', + 'SELFDESTRUCT', + ]) + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + if (Object.values(tracerResults.numberLevels).length < 2) { + // console.log('calls=', result.calls.map(x=>JSON.stringify(x)).join('\n')) + // console.log('debug=', result.debug) + throw new Error( + 'Unexpected traceCall result: no NUMBER opcodes, and not REVERT' + ) + } + const callStack = parseCallStack(tracerResults) + + const callInfoEntryPoint = callStack.find( + (call) => + call.to === entryPointAddress && + call.method !== '0x' && + call.method !== 'depositTo' + ) + requireCond( + callInfoEntryPoint == null, + `illegal call into EntryPoint during validation ${callInfoEntryPoint?.method}`, + ValidationErrors.OpcodeValidation + ) + + requireCond( + callStack.find( + (call) => + call.to !== entryPointAddress && + BigNumber.from(call.value ?? 0) !== BigNumber.from(0) + ) != null, + 'May not may CALL with value', + ValidationErrors.OpcodeValidation + ) + + const sender = userOp.sender.toLowerCase() + // stake info per "number" level (factory, sender, paymaster) + // we only use stake info if we notice a memory reference that require stake + const stakeInfoEntities = { + factory: validationResult.factoryInfo, + account: validationResult.senderInfo, + paymaster: validationResult.paymasterInfo, + } + + const entitySlots: { [addr: string]: Set } = parseEntitySlots( + stakeInfoEntities, + tracerResults.keccak + ) + + Object.entries(stakeInfoEntities).forEach( + ([entityTitle, entStakes], index) => { + const entityAddr = entStakes?.addr ?? '' + const currentNumLevel = tracerResults.numberLevels[index] + const opcodes = currentNumLevel.opcodes + const access = currentNumLevel.access + + requireCond( + !(currentNumLevel.oog ?? false), + `${entityTitle} internally reverts on oog`, + ValidationErrors.OpcodeValidation + ) + Object.keys(opcodes).forEach((opcode) => + requireCond( + !bannedOpCodes.has(opcode), + `${entityTitle} uses banned opcode: ${opcode}`, + ValidationErrors.OpcodeValidation + ) + ) + if (entityTitle === 'factory') { + requireCond( + (opcodes.CREATE2 ?? 0) <= 1, + `${entityTitle} with too many CREATE2`, + ValidationErrors.OpcodeValidation + ) + } else { + requireCond( + opcodes.CREATE2 == null, + `${entityTitle} uses banned opcode: CREATE2`, + ValidationErrors.OpcodeValidation + ) + } + + Object.entries(access).forEach(([addr, { reads, writes }]) => { + // testing read/write access on contract "addr" + if (addr === sender) { + // allowed to access sender's storage + return + } + if (addr === entryPointAddress) { + // ignore storage access on entryPoint (balance/deposit of entities. + // we block them on method calls: only allowed to deposit, never to read + return + } + + // return true if the given slot is associated with the given address, given the known keccak operations: + // @param slot the SLOAD/SSTORE slot address we're testing + // @param addr - the address we try to check for association with + // @param reverseKeccak - a mapping we built for keccak values that contained the address + function associatedWith( + slot: string, + address: string, + eSlots: { [addr: string]: Set } + ): boolean { + const addrPadded = hexZeroPad(address, 32).toLowerCase() + if (slot === addrPadded) { + return true + } + const k = eSlots[addr] + if (k == null) { + return false + } + const slotN = BigNumber.from(slot) + // scan all slot entries to check of the given slot is within a structure, starting at that offset. + // assume a maximum size on a (static) structure size. + for (const k1 of k.keys()) { + const kn = BigNumber.from(k1) + if (slotN.gte(kn) && slotN.lt(kn.add(128))) { + return true + } + } + return false + } + + debug('dump keccak calculations and reads', { + entityTitle, + entityAddr, + k: mapOf(tracerResults.keccak, (k) => keccak256(k)), + reads, + }) + + // scan all slots. find a referenced slot + // at the end of the scan, we will check if the entity has stake, and report that slot if not. + let requireStakeSlot: string | undefined + ;[...Object.keys(writes), ...Object.keys(reads)].forEach((slot) => { + // slot associated with sender is allowed (e.g. token.balanceOf(sender) + // but during initial UserOp (where there is an initCode), it is allowed only for staked entity + if (associatedWith(slot, sender, entitySlots)) { + if (userOp.initCode.length > 2) { + requireStakeSlot = slot + } + } else if (associatedWith(slot, entityAddr, entitySlots)) { + // accessing a slot associated with entityAddr (e.g. token.balanceOf(paymaster) + requireStakeSlot = slot + } else if (addr === entityAddr) { + // accessing storage member of entity itself requires stake. + requireStakeSlot = slot + } else { + // accessing arbitrary storage of another contract is not allowed + const readWrite = Object.keys(writes).includes(addr) + ? 'write to' + : 'read from' + requireCond( + false, + `${entityTitle} has forbidden ${readWrite} ${nameAddr( + addr, + entityTitle + )} slot ${slot}`, + ValidationErrors.OpcodeValidation, + { [entityTitle]: entStakes?.addr } + ) + } + }) + + // if addr is current account/paymaster/factory, then return that title + // otherwise, return addr as-is + function nameAddr(address: string, currentEntity: string): string { + const [title] = + Object.entries(stakeInfoEntities).find( + // eslint-disable-next-line @typescript-eslint/no-shadow + ([title, info]) => + info?.addr.toLowerCase() === address.toLowerCase() + ) ?? [] + + return title ?? address + } + + requireCondAndStake( + requireStakeSlot != null, + entStakes, + `unstaked ${entityTitle} accessed ${nameAddr( + addr, + entityTitle + )} slot ${requireStakeSlot}` + ) + }) + + if (entityTitle === 'paymaster') { + const validatePaymasterUserOp = callStack.find( + (call) => + call.method === 'validatePaymasterUserOp' && call.to === entityAddr + ) + const context = validatePaymasterUserOp?.return?.context + requireCondAndStake( + context != null && context !== '0x', + entStakes, + 'unstaked paymaster must not return context' + ) + } + + // helper method: if condition is true, then entity must be staked. + function requireCondAndStake( + cond: boolean, + entStake: StakeInfo | undefined, + failureMessage: string + ): void { + if (!cond) { + return + } + if (entStakes == null) { + throw new Error( + `internal: ${entityTitle} not in userOp, but has storage accesses in ${JSON.stringify( + access + )}` + ) + } + requireCond( + BigNumber.from(1).lt(entStakes.stake) && + BigNumber.from(1).lt(entStakes.unstakeDelaySec), + failureMessage, + ValidationErrors.OpcodeValidation, + { [entityTitle]: entStakes?.addr } + ) + + // TODO: check real minimum stake values + } + + requireCond( + Object.keys(currentNumLevel.contractSize).find( + (addr) => currentNumLevel.contractSize[addr] <= 2 + ) == null, + `${entityTitle} accesses un-deployed contract ${JSON.stringify( + currentNumLevel.contractSize + )}`, + ValidationErrors.OpcodeValidation + ) + } + ) + + // return list of contract addresses by this UserOp. already known not to contain zero-sized addresses. + const addresses = tracerResults.numberLevels.reduce( + (acc, val) => acc.concat(Object.keys(val.contractSize)), + [] + ) + // const addresses = tracerResults.numberLevels.flatMap((level) => + // Object.keys(level.contractSize) + // ) + const storageMap: StorageMap = {} + tracerResults.numberLevels.forEach((level) => { + Object.keys(level.access).forEach((addr) => { + storageMap[addr] = storageMap[addr] ?? level.access[addr].reads + }) + }) + return [addresses, storageMap] +} diff --git a/packages/boba/bundler/src/runBundler.ts b/packages/boba/bundler/src/runBundler.ts index 810ca2c208..77d54df3d0 100644 --- a/packages/boba/bundler/src/runBundler.ts +++ b/packages/boba/bundler/src/runBundler.ts @@ -1,93 +1,54 @@ -import ow from 'ow' +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ import fs from 'fs' import { Command } from 'commander' import { erc4337RuntimeVersion } from '@boba/bundler_utils' import { ethers, Wallet } from 'ethers' -import { BaseProvider } from '@ethersproject/providers' import { getContractFactory } from '@eth-optimism/contracts' -import { - BundlerConfig, - bundlerConfigDefault, - BundlerConfigShape, -} from './BundlerConfig' import { BundlerServer } from './BundlerServer' import { UserOpMethodHandler } from './UserOpMethodHandler' import { - BundlerHelper, - BundlerHelper__factory, EntryPoint, EntryPoint__factory, + EntryPointWrapper, + EntryPointWrapper__factory, } from '@boba/accountabstraction' +import { BaseProvider } from '@ethersproject/providers' +import { initServer } from './modules/initServer' +import { DebugMethodHandler } from './DebugMethodHandler' +import { isGeth, supportsRpcMethod } from './utils' +import { resolveConfiguration } from './Config' // this is done so that console.log outputs BigNumber as hex string instead of unreadable object export const inspectCustomSymbol = Symbol.for('nodejs.util.inspect.custom') // @ts-ignore ethers.BigNumber.prototype[inspectCustomSymbol] = function () { - return `BigNumber ${parseInt(this._hex)}` + return `BigNumber ${parseInt(this._hex, 10)}` } const CONFIG_FILE_NAME = 'workdir/bundler.config.json' export let showStackTraces = false -export function resolveConfiguration(programOpts: any): BundlerConfig { - let fileConfig: Partial = {} - const commandLineParams = getCommandLineParams(programOpts) - const configFileName = programOpts.config - if (fs.existsSync(configFileName)) { - fileConfig = JSON.parse(fs.readFileSync(configFileName, 'ascii')) - } - const mergedConfig = { ...bundlerConfigDefault, ...fileConfig, ...commandLineParams} - //console.log('Merged configuration:', JSON.stringify(mergedConfig)) - ow(mergedConfig, ow.object.exactShape(BundlerConfigShape)) - return mergedConfig -} - -function getCommandLineParams(programOpts: any): Partial { - const params: any = {} - for (const bundlerConfigShapeKey in BundlerConfigShape) { - const optionValue = programOpts[bundlerConfigShapeKey] - if (optionValue != null) { - params[bundlerConfigShapeKey] = optionValue - } - } - return params as BundlerConfig +export async function connectContracts( + wallet: Wallet, + entryPointAddress: string +): Promise { + const entryPoint = EntryPoint__factory.connect(entryPointAddress, wallet) + return entryPoint } export async function connectContractsViaAddressManager ( providerL1: BaseProvider, wallet: Wallet, - addressManagerAddress: string): Promise<{ entryPoint: EntryPoint, bundlerHelper: BundlerHelper }> { + addressManagerAddress: string): Promise<{ entryPoint: EntryPoint, entryPointWrapper: EntryPointWrapper }> { const addressManager = getAddressManager(providerL1, addressManagerAddress) - - const bundlerHelperAddress = await addressManager.getAddress('L2_Boba_BundlerHelper') const entryPointAddress = await addressManager.getAddress('L2_Boba_EntryPoint') - + const entryPointWrapperAddress = await addressManager.getAddress('L2_EntryPointWrapper') const entryPoint = EntryPoint__factory.connect(entryPointAddress, wallet) - - const bundlerHelper = BundlerHelper__factory.connect(bundlerHelperAddress, wallet) - - return { - entryPoint, - bundlerHelper - } -} - -export async function connectContracts ( - wallet: Wallet, - entryPointAddress: string, - bundlerHelperAddress: string -): Promise<{ entryPoint: EntryPoint; bundlerHelper: BundlerHelper }> { - const entryPoint = EntryPoint__factory.connect(entryPointAddress, wallet) - const bundlerHelper = BundlerHelper__factory.connect( - bundlerHelperAddress, - wallet - ) - return { - entryPoint, - bundlerHelper, - } + const entryPointWrapper = EntryPointWrapper__factory.connect(entryPointWrapperAddress, wallet) + return { entryPoint: entryPoint, entryPointWrapper } } function getAddressManager (provider: any, addressManagerAddress: any): ethers.Contract { @@ -120,6 +81,7 @@ export async function runBundler( super(message) } } + throw new CommandError(message, code, exitCode) } } @@ -133,23 +95,35 @@ export async function runBundler( 'below this signer balance, keep fee for itself, ignoring "beneficiary" address ' ) .option('--network ', 'network name or url') - .option('--mnemonic ', 'mnemonic/private-key file of signer account') - .option('--helper ', 'address of the BundlerHelper contract') + .option('--mnemonic ', 'mnemonic/private-key file of signer account') .option( '--entryPoint ', 'address of the supported EntryPoint contract' ) .option('--port ', 'server listening port', '3000') - .option('--config ', 'path to config file)', CONFIG_FILE_NAME) + .option('--config ', 'path to config file', CONFIG_FILE_NAME) + .option( + '--auto', + 'automatic bundling (bypass config.autoBundleMempoolSize)', + false + ) + .option( + '--unsafe', + 'UNSAFE mode: no storage or opcode checks (safe mode requires geth)' + ) + .option('--conditionalRpc', 'Use eth_sendRawTransactionConditional RPC)') .option('--show-stack-traces', 'Show stack traces.') .option('--createMnemonic', 'create the mnemonic file') .option('--addressManager ', 'address of the Address Manager', '') .option('--l1NodeWeb3Url ', 'L1 network url for Address Manager', '') + .option('--maxBundleGas ', 'Max Bundle Gas available to use', '5000000') const programOpts = program.parse(argv).opts() showStackTraces = programOpts.showStackTraces - const config = resolveConfiguration(programOpts) + //console.log('command-line arguments: ', program.opts()) + + const { config, provider, wallet } = await resolveConfiguration(programOpts) if (programOpts.createMnemonic != null) { const mnemonicFile = config.mnemonic console.log('Creating mnemonic in file', mnemonicFile) @@ -160,46 +134,93 @@ export async function runBundler( } const newMnemonic = Wallet.createRandom().mnemonic.phrase fs.writeFileSync(mnemonicFile, newMnemonic) - console.log('creaed mnemonic file', mnemonicFile) + console.log('created mnemonic file', mnemonicFile) process.exit(1) } - const provider: BaseProvider = - // eslint-disable-next-line - config.network === 'hardhat' ? require('hardhat').ethers.provider : - ethers.getDefaultProvider(config.network) - - const providerL1: BaseProvider = new ethers.providers.JsonRpcProvider(config.l1NodeWeb3Url) - let mnemonic: string - let wallet: Wallet - try { - if (fs.existsSync(config.mnemonic)) { - mnemonic = fs.readFileSync(config.mnemonic, 'ascii').trim() - wallet = Wallet.fromMnemonic(mnemonic).connect(provider) - } else { - wallet = new Wallet(config.mnemonic, provider) - } - } catch (e: any) { - throw new Error( - `Unable to read --mnemonic ${config.mnemonic}: ${e.message as string}` + + if ( + config.conditionalRpc && + !(await supportsRpcMethod( + provider as any, + 'eth_sendRawTransactionConditional' + )) + ) { + console.error( + 'FATAL: --conditionalRpc requires a node that support eth_sendRawTransactionConditional' ) + process.exit(1) + } + if (!config.unsafe && !(await isGeth(provider as any))) { + console.error( + 'FATAL: full validation requires GETH. for local UNSAFE mode: use --unsafe' + ) + process.exit(1) } - let methodHandler: UserOpMethodHandler + + //todo this could need a cleanup + let entryPoint: EntryPoint + let entryPointWrapper: EntryPointWrapper if (config.addressManager.length > 0) { - const { entryPoint } = await connectContractsViaAddressManager(providerL1, wallet, config.addressManager) - config.entryPoint = entryPoint.address - methodHandler = new UserOpMethodHandler(provider, wallet, config, entryPoint) + console.log('Getting entrypoint from address manager') + const providerL1: BaseProvider = new ethers.providers.JsonRpcProvider(config.l1NodeWeb3Url) + const { entryPoint: eP, entryPointWrapper: epW } = + await connectContractsViaAddressManager( + providerL1, + wallet, + config.addressManager + ) + console.log(eP.address) + config.entryPoint = eP.address + config.entryPointWrapper = epW.address + console.log(config.entryPoint) + entryPoint = eP + entryPointWrapper = epW } else { - const { entryPoint } = await connectContracts(wallet, config.entryPoint, config.helper) - methodHandler = new UserOpMethodHandler(provider, wallet, config, entryPoint) + const eP = await connectContracts(wallet, config.entryPoint) + entryPoint = eP } + // bundleSize=1 replicate current immediate bundling mode + const execManagerConfig = { + ...config, + // autoBundleMempoolSize: 0 + } + if (programOpts.auto === true) { + execManagerConfig.autoBundleMempoolSize = 0 + execManagerConfig.autoBundleInterval = 0 + } + const [execManager, eventsManager, reputationManager, mempoolManager] = + initServer(execManagerConfig, entryPoint.signer) + + console.log('initServer done') + const methodHandler = new UserOpMethodHandler( + execManager, + provider, + wallet, + config, + entryPoint, + entryPointWrapper + ) + eventsManager.initEventListener() + const debugHandler = new DebugMethodHandler( + execManager, + eventsManager, + reputationManager, + mempoolManager + ) + const bundlerServer = new BundlerServer( methodHandler, + debugHandler, config, provider, wallet ) - + console.log('bundlerServer...') void bundlerServer.asyncStart().then(async () => { + console.log( + 'Bundle interval (seconds)', + execManagerConfig.autoBundleInterval + ) console.log( 'connected to network', await provider.getNetwork().then((net) => { diff --git a/packages/boba/bundler/src/runner/runop.ts b/packages/boba/bundler/src/runner/runop.ts index 2058ed03fd..5d1fa8b151 100644 --- a/packages/boba/bundler/src/runner/runop.ts +++ b/packages/boba/bundler/src/runner/runop.ts @@ -2,85 +2,91 @@ /** * a simple script runner, to test the bundler and API. - * for a simple target method, we just call the "nonce" method of the wallet itself. + * for a simple target method, we just call the "nonce" method of the account itself. */ import { BigNumber, getDefaultProvider, Signer, Wallet } from 'ethers' import { JsonRpcProvider } from '@ethersproject/providers' -import { SimpleAccountDeployer__factory } from '@boba/accountabstraction' +import { SimpleAccountFactory__factory } from '@boba/accountabstraction' import { formatEther, keccak256, parseEther } from 'ethers/lib/utils' import { Command } from 'commander' import { erc4337RuntimeVersion } from '@boba/bundler_utils' import fs from 'fs' -import { HttpRpcClient } from '@boba/bundler_sdk/dist/src/HttpRpcClient' -import { SimpleAccountAPI } from '@boba/bundler_sdk' -import { DeterministicDeployer } from '@boba/bundler_sdk/dist/src/DeterministicDeployer' +import { + DeterministicDeployer, + HttpRpcClient, + SimpleAccountAPI, +} from '@boba/bundler_sdk' import { runBundler } from '../runBundler' import { BundlerServer } from '../BundlerServer' -const ENTRY_POINT = '0x2DF1592238420ecFe7f2431360e224707e77fA0E' +const ENTRY_POINT = '0x1306b01bc3e4ad202612d3843387e94737673f53' class Runner { bundlerProvider!: HttpRpcClient - walletApi!: SimpleAccountAPI + accountApi!: SimpleAccountAPI /** * - * @param provider - a provider for initialization. This account is used to fund the created wallet, but it is not the wallet or its owner. + * @param provider - a provider for initialization. This account is used to fund the created account contract, but it is not the account or its owner. * @param bundlerUrl - a URL to a running bundler. must point to the same network the provider is. - * @param walletOwner - the wallet signer account. used only as signer (not as transaction sender) + * @param accountOwner - the wallet signer account. used only as signer (not as transaction sender) * @param entryPointAddress - the entrypoint address to use. - * @param index - unique salt, to allow multiple wallets with the same owner + * @param index - unique salt, to allow multiple accounts with the same owner */ - constructor ( + constructor( readonly provider: JsonRpcProvider, readonly bundlerUrl: string, - readonly walletOwner: Signer, + readonly accountOwner: Signer, readonly entryPointAddress = ENTRY_POINT, readonly index = 0 ) {} - async getAddress (): Promise { - return await this.walletApi.getCounterFactualAddress() + async getAddress(): Promise { + return await this.accountApi.getCounterFactualAddress() } - async init (deploymentSigner?: Signer): Promise { + async init(deploymentSigner?: Signer): Promise { const net = await this.provider.getNetwork() const chainId = net.chainId const dep = new DeterministicDeployer(this.provider) - const walletDeployer = await dep.getDeterministicDeployAddress( - SimpleAccountDeployer__factory.bytecode + const accountDeployer = await DeterministicDeployer.getAddress( + new SimpleAccountFactory__factory(), + 0, + [this.entryPointAddress] ) - // const walletDeployer = await new SimpleAccountDeployer__factory(this.provider.getSigner()).deploy().then(d=>d.address) - if (!(await dep.isContractDeployed(walletDeployer))) { + // const accountDeployer = await new SimpleAccountFactory__factory(this.provider.getSigner()).deploy().then(d=>d.address) + if (!(await dep.isContractDeployed(accountDeployer))) { if (deploymentSigner == null) { console.log( - `WalletDeployer not deployed at ${walletDeployer}. run with --deployDeployer` + `AccountDeployer not deployed at ${accountDeployer}. run with --deployFactory` ) process.exit(1) } const dep1 = new DeterministicDeployer(deploymentSigner.provider as any) - await dep1.deterministicDeploy(SimpleAccountDeployer__factory.bytecode) + await dep1.deterministicDeploy(new SimpleAccountFactory__factory(), 0, [ + this.entryPointAddress, + ]) } this.bundlerProvider = new HttpRpcClient( this.bundlerUrl, this.entryPointAddress, chainId ) - this.walletApi = new SimpleAccountAPI({ + this.accountApi = new SimpleAccountAPI({ provider: this.provider, entryPointAddress: this.entryPointAddress, - factoryAddress: walletDeployer, - owner: this.walletOwner, + factoryAddress: accountDeployer, + owner: this.accountOwner, index: this.index, overheads: { // perUserOp: 100000 - } + }, }) return this } - parseExpectedGas (e: Error): Error { + parseExpectedGas(e: Error): Error { // parse a custom error generated by the BundlerHelper, which gives a hint of how much payment is missing const match = e.message?.match(/paid (\d+) expected (\d+)/) if (match != null) { @@ -95,14 +101,14 @@ class Runner { return e } - async runUserOp (target: string, data: string): Promise { - const userOp = await this.walletApi.createSignedUserOp({ + async runUserOp(target: string, data: string): Promise { + const userOp = await this.accountApi.createSignedUserOp({ target, - data + data, }) try { const userOpHash = await this.bundlerProvider.sendUserOpToBundler(userOp) - const txid = await this.walletApi.getUserOpReceipt(userOpHash) + const txid = await this.accountApi.getUserOpReceipt(userOpHash) console.log('reqId', userOpHash, 'txid=', txid) } catch (e: any) { throw this.parseExpectedGas(e) @@ -110,7 +116,7 @@ class Runner { } } -async function main (): Promise { +async function main(): Promise { const program = new Command() .version(erc4337RuntimeVersion) .option( @@ -120,7 +126,7 @@ async function main (): Promise { ) .option( '--mnemonic ', - 'mnemonic/private-key file of signer account (to fund wallet)' + 'mnemonic/private-key file of signer account (to fund account)' ) .option('--bundlerUrl ', 'bundler URL', 'http://localhost:3000/rpc') .option( @@ -129,8 +135,8 @@ async function main (): Promise { ENTRY_POINT ) .option( - '--deployDeployer', - 'Deploy the "wallet deployer" on this network (default for testnet)' + '--deployFactory', + 'Deploy the "account deployer" on this network (default for testnet)' ) .option('--show-stack-traces', 'Show stack traces.') .option( @@ -141,9 +147,8 @@ async function main (): Promise { const opts = program.parse().opts() const provider = getDefaultProvider(opts.network) as JsonRpcProvider let signer: Signer - const deployDeployer: boolean = opts.deployDeployer + const deployFactory: boolean = opts.deployFactory let bundler: BundlerServer | undefined - if (opts.selfBundler != null) { // todo: if node is geth, we need to fund our bundler's account: const signer = provider.getSigner() @@ -155,7 +160,7 @@ async function main (): Promise { console.log('funding hardhat account', account) await signer.sendTransaction({ to: account, - value: parseEther('1').sub(bal) + value: parseEther('1').sub(bal), }) } @@ -163,7 +168,8 @@ async function main (): Promise { 'node', 'exec', '--config', - './localconfig/bundler.config.json' + './localconfig/bundler.config.json', + '--unsafe', ] if (opts.entryPoint != null) { argv.push('--entryPoint', opts.entryPoint) @@ -179,40 +185,42 @@ async function main (): Promise { try { const accounts = await provider.listAccounts() if (accounts.length === 0) { - console.log('fatal: no account. use --mnemonic (needed to fund wallet)') + console.log( + 'fatal: no account. use --mnemonic (needed to fund account)' + ) process.exit(1) } // for hardhat/node, use account[0] signer = provider.getSigner() - // deployDeployer = true + // deployFactory = true } catch (e) { throw new Error('must specify --mnemonic') } } - const walletOwner = new Wallet('0x'.padEnd(66, '7')) + const accountOwner = new Wallet('0x'.padEnd(66, '7')) const index = Date.now() const client = await new Runner( provider, opts.bundlerUrl, - walletOwner, + accountOwner, opts.entryPoint, index - ).init(deployDeployer ? signer : undefined) + ).init(deployFactory ? signer : undefined) const addr = await client.getAddress() - async function isDeployed (addr: string): Promise { + async function isDeployed(addr: string): Promise { return await provider.getCode(addr).then((code) => code !== '0x') } - async function getBalance (addr: string): Promise { + async function getBalance(addr: string): Promise { return await provider.getBalance(addr) } const bal = await getBalance(addr) console.log( - 'wallet address', + 'account address', addr, 'deployed=', await isDeployed(addr), @@ -222,13 +230,13 @@ async function main (): Promise { // TODO: actual required val const requiredBalance = parseEther('0.5') if (bal.lt(requiredBalance.div(2))) { - console.log('funding wallet to', requiredBalance) + console.log('funding account to', requiredBalance) await signer.sendTransaction({ to: addr, - value: requiredBalance.sub(bal) + value: requiredBalance.sub(bal), }) } else { - console.log('not funding wallet. balance is enough') + console.log('not funding account. balance is enough') } const dest = addr @@ -236,7 +244,7 @@ async function main (): Promise { console.log('data=', data) await client.runUserOp(dest, data) console.log('after run1') - // client.walletApi.overheads!.perUserOp = 30000 + // client.accountApi.overheads!.perUserOp = 30000 await client.runUserOp(dest, data) console.log('after run2') await bundler?.stop() diff --git a/packages/boba/bundler/src/utils.ts b/packages/boba/bundler/src/utils.ts index dc070b437a..5f8c24474d 100644 --- a/packages/boba/bundler/src/utils.ts +++ b/packages/boba/bundler/src/utils.ts @@ -1,46 +1,73 @@ -import { hexlify } from 'ethers/lib/utils' +import { JsonRpcProvider } from '@ethersproject/providers' +import { BigNumberish } from 'ethers/lib/ethers' +import { BigNumber } from 'ethers' -/** - * hexlify all members of object, recursively - * - * @param obj - */ -export function deepHexlify (obj: any): any { - if (typeof obj === 'function') { - return undefined +export class RpcError extends Error { + // error codes from: https://eips.ethereum.org/EIPS/eip-1474 + constructor (msg: string, readonly code?: number, readonly data: any = undefined) { + super(msg) } - if (obj == null || typeof obj === 'string' || typeof obj === 'boolean') { - return obj - } else if (obj._isBigNumber != null || typeof obj !== 'object') { - return hexlify(obj) +} + +export function tostr (s: BigNumberish): string { + return BigNumber.from(s).toString() +} + +export function requireCond (cond: boolean, msg: string, code?: number, data: any = undefined): void { + if (!cond) { + throw new RpcError(msg, code, data) } - if (Array.isArray(obj)) { - return obj.map((member) => deepHexlify(member)) +} + +/** + * create a dictionary object with given keys + * @param keys the property names of the returned object + * @param mapper mapper from key to property value + * @param filter if exists, must return true to add keys + */ +export function mapOf (keys: Iterable, mapper: (key: string) => T, filter?: (key: string) => boolean): { [key: string]: T } { + const ret: { [key: string]: T } = {} + for (const key of keys) { + if (filter == null || filter(key)) { + ret[key] = mapper(key) + } } - return Object.keys(obj).reduce( - (set, key) => ({ - ...set, - [key]: deepHexlify(obj[key]) - }), - {} - ) + return ret } -export class RpcError extends Error { - constructor ( - msg: string, - readonly code?: number, - readonly data: any = undefined - ) { - super(msg) +export async function sleep (sleepTime: number): Promise { + await new Promise(resolve => setTimeout(resolve, sleepTime)) +} + +export async function waitFor (func: () => T | undefined, timeout = 10000, interval = 500): Promise { + const endTime = Date.now() + timeout + while (true) { + const ret = await func() + if (ret != null) { + return ret + } + if (Date.now() > endTime) { + throw new Error(`Timed out waiting for ${func as unknown as string}`) + } + await sleep(interval) } } -export function requireCond ( - cond: boolean, - msg: string, - code?: number, - data: any = undefined -): void { - if (!cond) throw new RpcError(msg, code, data) +export async function supportsRpcMethod (provider: JsonRpcProvider, method: string): Promise { + const ret = await provider.send(method, []).catch(e => e) + const code = ret.error?.code ?? ret.code + return code === -32602 // wrong params (meaning, method exists) +} + +export async function isGeth (provider: JsonRpcProvider): Promise { + const p = provider.send as any + if (p._clientVersion == null) { + p._clientVersion = await provider.send('web3_clientVersion', []) + } + + // check if we have traceCall + // its GETH if it has debug_traceCall method. + return await supportsRpcMethod(provider, 'debug_traceCall') + // debug('client version', p._clientVersion) + // return p._clientVersion?.match('go1') != null } diff --git a/packages/boba/bundler/test/BundlerManager.test.ts b/packages/boba/bundler/test/BundlerManager.test.ts new file mode 100644 index 0000000000..25b0e245e6 --- /dev/null +++ b/packages/boba/bundler/test/BundlerManager.test.ts @@ -0,0 +1,74 @@ +import { EntryPoint, EntryPoint__factory, EntryPointWrapper, EntryPointWrapper__factory } from '@boba/accountabstraction' +import { parseEther } from 'ethers/lib/utils' +import { expect } from 'chai' +import { BundlerReputationParams, ReputationManager } from '../src/modules/ReputationManager' +import { UserOperation } from '../src/modules/moduleUtils' +import { AddressZero } from '@boba/bundler_utils' +import { isGeth } from '../src/utils' +import { DeterministicDeployer } from '@boba/bundler_sdk' +import { MempoolManager } from '../src/modules/MempoolManager' +import { BundleManager } from '../src/modules/BundleManager' +import { ethers } from 'hardhat' +import { BundlerConfig } from '../src/BundlerConfig' +import { ValidationManager } from '../src/modules/ValidationManager' + +describe('#BundlerManager', () => { + let bm: BundleManager + + let entryPoint: EntryPoint + let entryPointWrapper: EntryPointWrapper + + const provider = ethers.provider + const signer = provider.getSigner() + + before(async function () { + entryPoint = await new EntryPoint__factory(signer).deploy() + entryPointWrapper = await new EntryPointWrapper__factory(signer).deploy(entryPoint.address) + DeterministicDeployer.init(provider) + + const config: BundlerConfig = { + beneficiary: await signer.getAddress(), + entryPoint: entryPoint.address, + gasFactor: '0.2', + minBalance: '0', + mnemonic: '', + network: '', + port: '3000', + unsafe: !await isGeth(provider as any), + autoBundleInterval: 0, + autoBundleMempoolSize: 0, + maxBundleGas: 5e6, + // minstake zero, since we don't fund deployer. + minStake: '0', + minUnstakeDelay: 0, + entryPointWrapper: entryPointWrapper.address + } + + const repMgr = new ReputationManager(BundlerReputationParams, parseEther(config.minStake), config.minUnstakeDelay) + const mempoolMgr = new MempoolManager(repMgr) + const validMgr = new ValidationManager(entryPoint, repMgr, config.unsafe) + bm = new BundleManager(entryPoint, undefined, mempoolMgr, validMgr, repMgr, config.beneficiary, parseEther(config.minBalance), config.maxBundleGas, false, false, entryPointWrapper) + }) + + it('#getUserOpHashes', async () => { + const userOp: UserOperation = { + sender: AddressZero, + nonce: 1, + paymasterAndData: '0x02', + signature: '0x03', + initCode: '0x04', + callData: '0x05', + callGasLimit: 6, + verificationGasLimit: 7, + maxFeePerGas: 8, + maxPriorityFeePerGas: 9, + preVerificationGas: 10 + } + + const hash1 = await entryPointWrapper.getUserOpHashes(entryPoint.address, [userOp]) + const hash2 = await entryPoint.getUserOpHash(userOp) + const bmHash = await bm.getUserOpHashes([userOp]) + expect(bmHash).to.eql([hash2]) + expect(bmHash).to.eql(hash1) + }) +}) diff --git a/packages/boba/bundler/test/BundlerServer.test.ts b/packages/boba/bundler/test/BundlerServer.test.ts index b9b909cfe2..f2f7cc6097 100644 --- a/packages/boba/bundler/test/BundlerServer.test.ts +++ b/packages/boba/bundler/test/BundlerServer.test.ts @@ -1,14 +1,3 @@ describe('BundleServer', function () { - describe('preflightCheck', function () { - it('') - }) - describe('', function () { - it('') - }) - describe('', function () { - it('') - }) - describe('', function () { - it('') - }) + // it('preflightCheck') }) diff --git a/packages/boba/bundler/test/DebugMethodHandler.test.ts b/packages/boba/bundler/test/DebugMethodHandler.test.ts new file mode 100644 index 0000000000..772bad9f4f --- /dev/null +++ b/packages/boba/bundler/test/DebugMethodHandler.test.ts @@ -0,0 +1,109 @@ +import { DebugMethodHandler } from '../src/DebugMethodHandler' +import { ExecutionManager } from '../src/modules/ExecutionManager' +import { BundlerReputationParams, ReputationManager } from '../src/modules/ReputationManager' +import { BundlerConfig } from '../src/BundlerConfig' +import { isGeth } from '../src/utils' +import { parseEther } from 'ethers/lib/utils' +import { MempoolManager } from '../src/modules/MempoolManager' +import { ValidationManager } from '../src/modules/ValidationManager' +import { BundleManager, SendBundleReturn } from '../src/modules/BundleManager' +import { UserOpMethodHandler } from '../src/UserOpMethodHandler' +import { ethers } from 'hardhat' +import { EntryPoint, EntryPoint__factory, EntryPointWrapper, EntryPointWrapper__factory, SimpleAccountFactory__factory } from '@boba/accountabstraction' +import { DeterministicDeployer, SimpleAccountAPI } from '@boba/bundler_sdk' +import { Signer, Wallet } from 'ethers' +import { resolveHexlify } from '@boba/bundler_utils' +import { expect } from 'chai' +import { createSigner } from './testUtils' +import { EventsManager } from '../src/modules/EventsManager' + +const provider = ethers.provider + +describe('#DebugMethodHandler', () => { + let debugMethodHandler: DebugMethodHandler + let entryPoint: EntryPoint + let entryPointWrapper: EntryPointWrapper + let methodHandler: UserOpMethodHandler + let smartAccountAPI: SimpleAccountAPI + let signer: Signer + const accountSigner = Wallet.createRandom() + + before(async () => { + signer = await createSigner() + + entryPoint = await new EntryPoint__factory(signer).deploy() + entryPointWrapper = await new EntryPointWrapper__factory(signer).deploy(entryPoint.address) + DeterministicDeployer.init(provider) + + const config: BundlerConfig = { + beneficiary: await signer.getAddress(), + entryPoint: entryPoint.address, + gasFactor: '0.2', + minBalance: '0', + mnemonic: '', + network: '', + port: '3000', + unsafe: !await isGeth(provider as any), + conditionalRpc: false, + autoBundleInterval: 0, + autoBundleMempoolSize: 0, + maxBundleGas: 5e6, + // minstake zero, since we don't fund deployer. + minStake: '0', + minUnstakeDelay: 0, + addressManager: '', + l1NodeWeb3Url: '', + } + + + const repMgr = new ReputationManager(BundlerReputationParams, parseEther(config.minStake), config.minUnstakeDelay) + const mempoolMgr = new MempoolManager(repMgr) + const validMgr = new ValidationManager(entryPoint, repMgr, config.unsafe, entryPointWrapper) + const eventsManager = new EventsManager(entryPoint, mempoolMgr, repMgr) + const bundleMgr = new BundleManager(entryPoint, eventsManager, mempoolMgr, validMgr, repMgr, + config.beneficiary, parseEther(config.minBalance), config.maxBundleGas, false, false, entryPointWrapper) + const execManager = new ExecutionManager(repMgr, mempoolMgr, bundleMgr, validMgr) + + methodHandler = new UserOpMethodHandler( + execManager, + provider, + signer, + config, + entryPoint + ) + + debugMethodHandler = new DebugMethodHandler(execManager, eventsManager, repMgr, mempoolMgr) + DeterministicDeployer.init(ethers.provider) + console.log([entryPoint.address]) + const accountDeployerAddress = await DeterministicDeployer.deploy(new SimpleAccountFactory__factory(), 0, [entryPoint.address]) + smartAccountAPI = new SimpleAccountAPI({ + provider, + entryPointAddress: entryPoint.address, + owner: accountSigner, + factoryAddress: accountDeployerAddress + }) + const accountAddress = await smartAccountAPI.getAccountAddress() + console.log(accountAddress) + await signer.sendTransaction({ + to: accountAddress, + value: parseEther('1') + }) + }) + + it('should return sendBundleNow hashes', async () => { + debugMethodHandler.setBundlingMode('manual') + const addr = await smartAccountAPI.getAccountAddress() + const op1 = await smartAccountAPI.createSignedUserOp({ + target: addr, + data: '0x' + }) + const userOpHash = await methodHandler.sendUserOperation(await resolveHexlify(op1), entryPoint.address) + const { + transactionHash, + userOpHashes + } = await debugMethodHandler.sendBundleNow() as SendBundleReturn + expect(userOpHashes).eql([userOpHash]) + const txRcpt = await provider.getTransactionReceipt(transactionHash) + expect(txRcpt.to).to.eq(entryPoint.address) + }) +}) diff --git a/packages/boba/bundler/test/Flow.test.ts b/packages/boba/bundler/test/Flow.test.ts deleted file mode 100644 index 2d7a4be0c0..0000000000 --- a/packages/boba/bundler/test/Flow.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import chai from 'chai' -import chaiAsPromised from 'chai-as-promised' -import hre, { ethers } from 'hardhat' -import sinon from 'sinon' - -import * as SampleRecipientArtifact - from '@boba/bundler_utils/artifacts/contracts/test/SampleRecipient.sol/SampleRecipient.json' - -import { BundlerConfig } from '../src/BundlerConfig' -import { ERC4337EthersProvider, ERC4337EthersSigner, ClientConfig, wrapProvider } from '@boba/bundler_sdk' -import { Signer, Wallet } from 'ethers' -import { runBundler } from '../src/runBundler' -import { BundlerServer } from '../src/BundlerServer' -import fs from 'fs' -import { getContractFactory } from '@eth-optimism/contracts' -const { expect } = chai.use(chaiAsPromised) - -export async function startBundler (options: BundlerConfig): Promise { - const args: any[] = [] - args.push('--beneficiary', options.beneficiary) - args.push('--entryPoint', options.entryPoint) - args.push('--gasFactor', options.gasFactor) - args.push('--helper', options.helper) - args.push('--minBalance', options.minBalance) - args.push('--mnemonic', options.mnemonic) - args.push('--network', options.network) - args.push('--port', options.port) - - return await runBundler(['node', 'cmd', ...args], true) -} - -describe('Flow', function () { - let bundlerServer: BundlerServer - let entryPointAddress: string - let sampleRecipientAddress: string - let signer: Signer - before(async function () { - signer = await hre.ethers.provider.getSigner() - const beneficiary = await signer.getAddress() - - const addressManagerFactory = await getContractFactory('Lib_AddressManager', signer) - const addressManager = await addressManagerFactory.deploy() - - const sampleRecipientFactory = await ethers.getContractFactory('SampleRecipient') - const sampleRecipient = await sampleRecipientFactory.deploy() - sampleRecipientAddress = sampleRecipient.address - - const EntryPointFactory = await ethers.getContractFactory('EntryPoint') - const entryPoint = await EntryPointFactory.deploy() - entryPointAddress = entryPoint.address - - const bundleHelperFactory = await ethers.getContractFactory('BundlerHelper') - const bundleHelper = await bundleHelperFactory.deploy() - await signer.sendTransaction({ - to: '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1', - value: 10e18.toString() - }) - - const mnemonic = 'myth like bonus scare over problem client lizard pioneer submit female collect' - const mnemonicFile = '/tmp/mnemonic.tmp' - fs.writeFileSync(mnemonicFile, mnemonic) - bundlerServer = await startBundler({ - beneficiary, - entryPoint: entryPoint.address, - helper: bundleHelper.address, - gasFactor: '0.2', - minBalance: '0', - mnemonic: mnemonicFile, - network: 'http://localhost:8545/', - port: '5555', - addressManager: addressManager.address, - l1NodeWeb3Url: 'http://localhost:8545/' - }) - }) - - after(async function () { - await bundlerServer?.stop() - }) - - let erc4337Signer: ERC4337EthersSigner - let erc4337Provider: ERC4337EthersProvider - - it('should send transaction and make profit', async function () { - const config: ClientConfig = { - entryPointAddress, - bundlerUrl: 'http://localhost:5555/rpc' - } - - // use this as signer (instead of node's first account) - const ownerAccount = Wallet.createRandom() - erc4337Provider = await wrapProvider( - ethers.provider, - // new JsonRpcProvider('http://localhost:8545/'), - config, - ownerAccount - ) - erc4337Signer = erc4337Provider.getSigner() - const simpleWalletPhantomAddress = await erc4337Signer.getAddress() - - await signer.sendTransaction({ - to: simpleWalletPhantomAddress, - value: 10e18.toString() - }) - - const sampleRecipientContract = - new ethers.Contract(sampleRecipientAddress, SampleRecipientArtifact.abi, erc4337Signer) - console.log(sampleRecipientContract.address) - - const result = await sampleRecipientContract.something('hello world') - console.log(result) - const receipt = await result.wait() - console.log(receipt) - }) - - it.skip('should refuse transaction that does not make profit', async function () { - sinon.stub(erc4337Signer, 'signUserOperation').returns(Promise.resolve('0x' + '01'.repeat(65))) - const sampleRecipientContract = - new ethers.Contract(sampleRecipientAddress, SampleRecipientArtifact.abi, erc4337Signer) - console.log(sampleRecipientContract.address) - await expect(sampleRecipientContract.something('hello world')).to.be.eventually - .rejectedWith( - 'The bundler has failed to include UserOperation in a batch: "ECDSA: invalid signature \'v\' value"') - }) -}) diff --git a/packages/boba/bundler/test/UserOpMethodHandler.test.ts b/packages/boba/bundler/test/UserOpMethodHandler.test.ts index af779e4be3..ced96aadbf 100644 --- a/packages/boba/bundler/test/UserOpMethodHandler.test.ts +++ b/packages/boba/bundler/test/UserOpMethodHandler.test.ts @@ -1,50 +1,61 @@ - -import 'source-map-support/register' -import { BaseProvider, JsonRpcSigner } from '@ethersproject/providers' +import { BaseProvider } from '@ethersproject/providers' import { assert, expect } from 'chai' -import { ethers } from 'hardhat' -import { parseEther } from 'ethers/lib/utils' - -import { getContractFactory } from '@eth-optimism/contracts' -import { UserOpMethodHandler } from '../src/UserOpMethodHandler' +import { formatEther, parseEther, resolveProperties } from 'ethers/lib/utils' import { BundlerConfig } from '../src/BundlerConfig' import { EntryPoint, - SimpleAccountDeployer__factory, + EntryPoint__factory, + EntryPointWrapper, + EntryPointWrapper__factory, + SimpleAccountFactory__factory, UserOperationStruct } from '@boba/accountabstraction' -import { DeterministicDeployer } from '@boba/bundler_sdk/src/DeterministicDeployer' -import { Wallet } from 'ethers' -import { SimpleAccountAPI } from '@boba/bundler_sdk' -import { postExecutionDump } from '@boba/bundler_utils/dist/src/postExecCheck' -import { BundlerHelper, SampleRecipient } from '../src/types' +import { Signer, Wallet } from 'ethers' +import { DeterministicDeployer, SimpleAccountAPI } from '@boba/bundler_sdk' +import { postExecutionDump } from '@boba/bundler_utils/dist/postExecCheck' +import { + SampleRecipient, TestRuleAccount, TestOpcodesAccount__factory +} from '../dist/src/types' +import { resolveHexlify } from '@boba/bundler_utils' +import { UserOperationEventEvent } from '@boba/accountabstraction' +import { UserOperationReceipt } from '../src/RpcTypes' +import { ExecutionManager } from '../src/modules/ExecutionManager' +import { BundlerReputationParams, ReputationManager } from '../src/modules/ReputationManager' +import { MempoolManager } from '../src/modules/MempoolManager' +import { ValidationManager } from '../src/modules/ValidationManager' +import { BundleManager } from '../src/modules/BundleManager' +import { isGeth, waitFor } from '../src/utils' +import { UserOpMethodHandler } from '../src/UserOpMethodHandler' +import { ethers } from 'hardhat' +import { createSigner } from './testUtils' +import { EventsManager } from '../src/modules/EventsManager' describe('UserOpMethodHandler', function () { const helloWorld = 'hello world' + let accountDeployerAddress: string let methodHandler: UserOpMethodHandler let provider: BaseProvider - let signer: JsonRpcSigner - const walletSigner = Wallet.createRandom() + let signer: Signer + const accountSigner = Wallet.createRandom() + let mempoolMgr: MempoolManager let entryPoint: EntryPoint - let bundleHelper: BundlerHelper + let entryPointWrapper: EntryPointWrapper let sampleRecipient: SampleRecipient before(async function () { provider = ethers.provider - signer = ethers.provider.getSigner() - const addressManagerFactory = await getContractFactory('Lib_AddressManager', signer) - const addressManager = await addressManagerFactory.deploy() - const EntryPointFactory = await ethers.getContractFactory('EntryPoint') - entryPoint = await EntryPointFactory.deploy() + signer = await createSigner() + entryPoint = await new EntryPoint__factory(signer).deploy() + entryPointWrapper = await new EntryPointWrapper__factory(signer).deploy(entryPoint.address) + + DeterministicDeployer.init(ethers.provider) + accountDeployerAddress = await DeterministicDeployer.deploy(new SimpleAccountFactory__factory(), 0, [entryPoint.address]) - const bundleHelperFactory = await ethers.getContractFactory('BundlerHelper') - bundleHelper = await bundleHelperFactory.deploy() - console.log('bundler from=', await bundleHelper.signer.getAddress()) const sampleRecipientFactory = await ethers.getContractFactory('SampleRecipient') sampleRecipient = await sampleRecipientFactory.deploy() @@ -52,16 +63,29 @@ describe('UserOpMethodHandler', function () { beneficiary: await signer.getAddress(), entryPoint: entryPoint.address, gasFactor: '0.2', - helper: bundleHelper.address, minBalance: '0', mnemonic: '', network: '', port: '3000', - addressManager: addressManager.address, - l1NodeWeb3Url: '' + unsafe: !await isGeth(provider as any), + conditionalRpc: false, + autoBundleInterval: 0, + autoBundleMempoolSize: 0, + maxBundleGas: 5e6, + // minstake zero, since we don't fund deployer. + minStake: '0', + minUnstakeDelay: 0, + entryPointWrapper: entryPointWrapper.address } + const repMgr = new ReputationManager(BundlerReputationParams, parseEther(config.minStake), config.minUnstakeDelay) + mempoolMgr = new MempoolManager(repMgr) + const validMgr = new ValidationManager(entryPoint, repMgr, config.unsafe, entryPointWrapper) + const evMgr = new EventsManager(entryPoint, mempoolMgr, repMgr) + const bundleMgr = new BundleManager(entryPoint, evMgr, mempoolMgr, validMgr, repMgr, config.beneficiary, parseEther(config.minBalance), config.maxBundleGas, false, false, entryPointWrapper) + const execManager = new ExecutionManager(repMgr, mempoolMgr, bundleMgr, validMgr) methodHandler = new UserOpMethodHandler( + execManager, provider, signer, config, @@ -75,71 +99,120 @@ describe('UserOpMethodHandler', function () { }) }) + describe('query rpc calls: eth_estimateUserOperationGas, eth_callUserOperation', function () { + let owner: Wallet + let smartAccountAPI: SimpleAccountAPI + let target: string + before('init', async () => { + owner = Wallet.createRandom() + target = await Wallet.createRandom().getAddress() + smartAccountAPI = new SimpleAccountAPI({ + provider, + entryPointAddress: entryPoint.address, + owner, + factoryAddress: accountDeployerAddress + }) + }) + it('estimateUserOperationGas should estimate even without eth', async () => { + const op = await smartAccountAPI.createSignedUserOp({ + target, + data: '0xdeadface' + }) + const ret = await methodHandler.estimateUserOperationGas(await resolveHexlify(op), entryPoint.address) + // verification gas should be high - it creates this wallet + expect(ret.verificationGas).to.be.closeTo(300000, 100000) + // execution should be quite low. + // (NOTE: actual execution should revert: it only succeeds because the wallet is NOT deployed yet, + // and estimation doesn't perform full deploy-validate-execute cycle) + expect(ret.callGasLimit).to.be.closeTo(25000, 10000) + }) + }) + describe('sendUserOperation', function () { let userOperation: UserOperationStruct - let walletAddress: string + let accountAddress: string - let walletDeployerAddress: string + let accountDeployerAddress: string + let userOpHash: string before(async function () { DeterministicDeployer.init(ethers.provider) - walletDeployerAddress = await DeterministicDeployer.deploy(SimpleAccountDeployer__factory.bytecode) + accountDeployerAddress = await DeterministicDeployer.deploy(new SimpleAccountFactory__factory(), 0, [entryPoint.address]) - const smartWalletAPI = new SimpleAccountAPI({ + const smartAccountAPI = new SimpleAccountAPI({ provider, entryPointAddress: entryPoint.address, - owner: walletSigner, - factoryAddress: walletDeployerAddress + owner: accountSigner, + factoryAddress: accountDeployerAddress }) - walletAddress = await smartWalletAPI.getWalletAddress() + accountAddress = await smartAccountAPI.getAccountAddress() + console.log('signer addr=', await signer.getAddress()) + console.log('signer bal=', formatEther(await signer.getBalance())) await signer.sendTransaction({ - to: walletAddress, + to: accountAddress, value: parseEther('1') }) - userOperation = await smartWalletAPI.createSignedUserOp({ + userOperation = await resolveProperties(await smartAccountAPI.createSignedUserOp({ data: sampleRecipient.interface.encodeFunctionData('something', [helloWorld]), target: sampleRecipient.address - }) + })) + userOpHash = await methodHandler.sendUserOperation(await resolveHexlify(userOperation), entryPoint.address) }) - it('should send UserOperation transaction to BundlerHelper', async function () { - const userOpHash = await methodHandler.sendUserOperation(userOperation, entryPoint.address) - const req = await entryPoint.queryFilter(entryPoint.filters.UserOperationEvent(userOpHash)) - const transactionReceipt = await req[0].getTransactionReceipt() + it('should send UserOperation transaction to entryPoint', async function () { + // sendUserOperation is async, even in auto-mining. need to wait for it. + const event = await waitFor(async () => await entryPoint.queryFilter(entryPoint.filters.UserOperationEvent(userOpHash)).then(ret => ret?.[0])) + const transactionReceipt = await event!.getTransactionReceipt() assert.isNotNull(transactionReceipt) - const depositedEvent = entryPoint.interface.parseLog(transactionReceipt.logs[0]) - const senderEvent = sampleRecipient.interface.parseLog(transactionReceipt.logs[1]) - const userOperationEvent = entryPoint.interface.parseLog(transactionReceipt.logs[2]) + const logs = transactionReceipt.logs.filter(log => log.address === entryPoint.address) + const deployedEvent = entryPoint.interface.parseLog(logs[0]) + const depositedEvent = entryPoint.interface.parseLog(logs[1]) + const [senderEvent] = await sampleRecipient.queryFilter(sampleRecipient.filters.Sender(), transactionReceipt.blockHash) + const userOperationEvent = entryPoint.interface.parseLog(logs[2]) + + assert.equal(deployedEvent.args.sender, userOperation.sender) assert.equal(userOperationEvent.name, 'UserOperationEvent') assert.equal(userOperationEvent.args.success, true) - assert.equal(senderEvent.name, 'Sender') const expectedTxOrigin = await methodHandler.signer.getAddress() assert.equal(senderEvent.args.txOrigin, expectedTxOrigin, 'sample origin should be bundler') - assert.equal(senderEvent.args.msgSender, walletAddress, 'sample msgsender should be wallet') + assert.equal(senderEvent.args.msgSender, accountAddress, 'sample msgsender should be account address') assert.equal(depositedEvent.name, 'Deposited') }) + it('getUserOperationByHash should return submitted UserOp', async () => { + const ret = await methodHandler.getUserOperationByHash(userOpHash) + expect(ret?.entryPoint === entryPoint.address) + expect(ret?.userOperation.sender).to.eql(userOperation.sender) + expect(ret?.userOperation.callData).to.eql(userOperation.callData) + }) + + it('getUserOperationReceipt should return receipt', async () => { + const rcpt = await methodHandler.getUserOperationReceipt(userOpHash) + expect(rcpt?.sender === userOperation.sender) + expect(rcpt?.success).to.be.true + }) + it('should expose FailedOp errors as text messages', async () => { - const smartWalletAPI = new SimpleAccountAPI({ + const smartAccountAPI = new SimpleAccountAPI({ provider, entryPointAddress: entryPoint.address, - owner: walletSigner, - factoryAddress: walletDeployerAddress, + owner: accountSigner, + factoryAddress: accountDeployerAddress, index: 1 }) - const op = await smartWalletAPI.createSignedUserOp({ + const op = await smartAccountAPI.createSignedUserOp({ data: sampleRecipient.interface.encodeFunctionData('something', [helloWorld]), target: sampleRecipient.address }) try { - await methodHandler.sendUserOperation(op, entryPoint.address) + await methodHandler.sendUserOperation(await resolveHexlify(op), entryPoint.address) throw Error('expected fail') } catch (e: any) { - expect(e.message).to.match(/account didn't pay prefund/) + expect(e.message).to.match(/AA21 didn't pay prefund/) } }) @@ -148,36 +221,15 @@ describe('UserOpMethodHandler', function () { const api = new SimpleAccountAPI({ provider, entryPointAddress: entryPoint.address, - walletAddress, - owner: walletSigner + accountAddress, + owner: accountSigner }) const op = await api.createSignedUserOp({ data: sampleRecipient.interface.encodeFunctionData('something', [helloWorld]), target: sampleRecipient.address, gasLimit: 1e6 }) - const id = await methodHandler.sendUserOperation(op, entryPoint.address) - - // { - // console.log('wrong method') - // await methodHandler.sendUserOperation(await api.createSignedUserOp({ - // data: sampleRecipient.interface.encodeFunctionData('something', [helloWorld + helloWorld + helloWorld + helloWorld + helloWorld]).padEnd(2000, '1'), - // target: walletAddress, - // gasLimit: 1e6 - // - // }), entryPoint.address) - // } - // - // { - // console.log('self nonce') - // const data = keccak256(Buffer.from('nonce()')).slice(0, 10) - // await methodHandler.sendUserOperation(await api.createSignedUserOp({ - // data: data, - // target: walletAddress, - // gasLimit: 1e6 - // - // }), entryPoint.address) - // } + const id = await methodHandler.sendUserOperation(await resolveHexlify(op), entryPoint.address) await postExecutionDump(entryPoint, id) }) @@ -185,8 +237,8 @@ describe('UserOpMethodHandler', function () { const api = new SimpleAccountAPI({ provider, entryPointAddress: entryPoint.address, - walletAddress, - owner: walletSigner, + accountAddress, + owner: accountSigner, overheads: { perUserOp: 0 } }) const op = await api.createSignedUserOp({ @@ -194,7 +246,7 @@ describe('UserOpMethodHandler', function () { target: sampleRecipient.address }) try { - await methodHandler.sendUserOperation(op, entryPoint.address) + await methodHandler.sendUserOperation(await resolveHexlify(op), entryPoint.address) throw new Error('expected to revert') } catch (e: any) { expect(e.message).to.match(/preVerificationGas too low/) @@ -202,4 +254,91 @@ describe('UserOpMethodHandler', function () { }) }) }) + + describe('#_filterLogs', function () { + // test events, good enough for _filterLogs + function userOpEv (hash: any): any { + return { + topics: ['userOpTopic', hash] + } as any + } + + function ev (topic: any): UserOperationEventEvent { + return { + topics: [topic] + } as any + } + + const ev1 = ev(1) + const ev2 = ev(2) + const ev3 = ev(3) + const u1 = userOpEv(10) + const u2 = userOpEv(20) + const u3 = userOpEv(30) + it('should fail if no UserOperationEvent', async () => { + expect(() => methodHandler._filterLogs(u1, [ev1])).to.throw('no UserOperationEvent in logs') + }) + it('should return empty array for single-op bundle with no events', async () => { + expect(methodHandler._filterLogs(u1, [u1])).to.eql([]) + }) + it('should return events for single-op bundle', async () => { + expect(methodHandler._filterLogs(u1, [ev1, ev2, u1])).to.eql([ev1, ev2]) + }) + it('should return events for middle userOp in a bundle', async () => { + expect(methodHandler._filterLogs(u1, [ev2, u2, ev1, u1, ev3, u3])).to.eql([ev1]) + }) + }) + + describe('#getUserOperationReceipt', function () { + let userOpHash: string + let receipt: UserOperationReceipt + let acc: TestRuleAccount + before(async () => { + acc = await new TestOpcodesAccount__factory(signer).deploy() + const op: UserOperationStruct = { + sender: acc.address, + initCode: '0x', + nonce: 0, + callData: '0x', + callGasLimit: 1e6, + verificationGasLimit: 1e6, + preVerificationGas: 50000, + maxFeePerGas: 1e6, + maxPriorityFeePerGas: 1e6, + paymasterAndData: '0x', + signature: Buffer.from('emit-msg') + } + await entryPoint.depositTo(acc.address, { value: parseEther('1') }) + // await signer.sendTransaction({to:acc.address, value: parseEther('1')}) + userOpHash = await entryPoint.getUserOpHash(op) + const beneficiary = signer.getAddress() + await entryPoint.handleOps([op], beneficiary).then(async ret => await ret.wait()) + const rcpt = await methodHandler.getUserOperationReceipt(userOpHash) + if (rcpt == null) { + throw new Error('getUserOperationReceipt returns null') + } + receipt = rcpt + }) + + it('should return null for nonexistent hash', async () => { + expect(await methodHandler.getUserOperationReceipt(ethers.constants.HashZero)).to.equal(null) + }) + + it('receipt should contain only userOp-specific events..', async () => { + expect(receipt.logs.length).to.equal(1) + const evParams = acc.interface.decodeEventLog('TestMessage', receipt.logs[0].data, receipt.logs[0].topics) + expect(evParams.eventSender).to.equal(acc.address) + }) + it('general receipt fields', () => { + expect(receipt.success).to.equal(true) + expect(receipt.sender).to.equal(acc.address) + }) + it('receipt should carry transaction receipt', () => { + // filter out BOR-specific events.. + const logs = receipt.receipt.logs + .filter(log => log.address !== '0x0000000000000000000000000000000000001010') + // one UserOperationEvent, and one op-specific event. + expect(logs.length).to.equal(2) + }) + }) }) diff --git a/packages/boba/bundler/test/ValidateManager.test.ts b/packages/boba/bundler/test/ValidateManager.test.ts new file mode 100644 index 0000000000..142938d2d8 --- /dev/null +++ b/packages/boba/bundler/test/ValidateManager.test.ts @@ -0,0 +1,317 @@ +import { EntryPoint, EntryPoint__factory } from '@boba/accountabstraction' +import { defaultAbiCoder, hexConcat, hexlify, keccak256, parseEther } from 'ethers/lib/utils' +import { ethers } from 'hardhat' +import { expect } from 'chai' +import { + TestOpcodesAccount, + TestOpcodesAccount__factory, + TestOpcodesAccountFactory, + TestOpcodesAccountFactory__factory, + TestRulesAccount, + TestRulesAccount__factory, + TestRulesAccountFactory__factory, + TestStorageAccount__factory, + TestStorageAccountFactory, + TestStorageAccountFactory__factory, + TestStorageAccount, + TestCoin, + TestCoin__factory +} from '../dist/src/types' +import { ValidateUserOpResult, ValidationManager } from '../src/modules/ValidationManager' +import { ReputationManager } from '../src/modules/ReputationManager' +import { toBytes32 } from '../src/modules/moduleUtils' +import { AddressZero, decodeErrorReason } from '@boba/accountabstraction' +import { isGeth } from '../src/utils' +import { TestRecursionAccount__factory } from '../dist/src/types/factories/contracts/tests/TestRecursionAccount__factory' +// import { resolveNames } from './testUtils' +import { UserOperation } from '../src/modules/Types' + +const cEmptyUserOp: UserOperation = { + sender: AddressZero, + nonce: 0, + paymasterAndData: '0x', + signature: '0x', + initCode: '0x', + callData: '0x', + callGasLimit: 0, + verificationGasLimit: 50000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + preVerificationGas: 0 +} + +describe('#ValidationManager', () => { + let vm: ValidationManager + let opcodeFactory: TestOpcodesAccountFactory + let storageFactory: TestStorageAccountFactory + let testcoin: TestCoin + + let paymaster: TestOpcodesAccount + let entryPoint: EntryPoint + let rulesAccount: TestRulesAccount + let storageAccount: TestStorageAccount + + async function testUserOp (validateRule: string = '', pmRule?: string, initFunc?: string, factoryAddress = opcodeFactory.address): Promise { + const userOp = await createTestUserOp(validateRule, pmRule, initFunc, factoryAddress) + return { userOp, ...await vm.validateUserOp(userOp) } + } + + async function testExistingUserOp (validateRule: string = '', pmRule = ''): Promise { + const userOp = await existingStorageAccountUserOp(validateRule, pmRule) + return { userOp, ...await vm.validateUserOp(userOp) } + } + + async function existingStorageAccountUserOp (validateRule = '', pmRule = ''): Promise { + const paymasterAndData = pmRule === '' ? '0x' : hexConcat([paymaster.address, Buffer.from(pmRule)]) + const signature = hexlify(Buffer.from(validateRule)) + return { + ...cEmptyUserOp, + sender: storageAccount.address, + signature, + paymasterAndData, + callGasLimit: 1e6, + verificationGasLimit: 1e6, + preVerificationGas: 50000 + } + } + + async function createTestUserOp (validateRule: string = '', pmRule?: string, initFunc?: string, factoryAddress = opcodeFactory.address): Promise { + if (initFunc === undefined) { + initFunc = opcodeFactory.interface.encodeFunctionData('create', ['']) + } + + const initCode = hexConcat([ + factoryAddress, + initFunc + ]) + const paymasterAndData = pmRule == null ? '0x' : hexConcat([paymaster.address, Buffer.from(pmRule)]) + const signature = hexlify(Buffer.from(validateRule)) + const callinitCodeForAddr = await provider.call({ + to: factoryAddress, + data: initFunc + }) + // todo: why "call" above doesn't throw on error ?!?! + if (decodeErrorReason(callinitCodeForAddr)?.message != null) { + throw new Error(decodeErrorReason(callinitCodeForAddr)?.message) + } + const [sender] = defaultAbiCoder.decode(['address'], callinitCodeForAddr) + return { + ...cEmptyUserOp, + sender, + initCode, + signature, + paymasterAndData, + callGasLimit: 1e6, + verificationGasLimit: 1e6, + preVerificationGas: 50000 + } + } + + const provider = ethers.provider + const ethersSigner = provider.getSigner() + + before(async function () { + entryPoint = await new EntryPoint__factory(ethersSigner).deploy() + paymaster = await new TestOpcodesAccount__factory(ethersSigner).deploy() + await entryPoint.depositTo(paymaster.address, { value: parseEther('0.1') }) + await paymaster.addStake(entryPoint.address, { value: parseEther('0.1') }) + opcodeFactory = await new TestOpcodesAccountFactory__factory(ethersSigner).deploy() + testcoin = await new TestCoin__factory(ethersSigner).deploy() + storageFactory = await new TestStorageAccountFactory__factory(ethersSigner).deploy(testcoin.address) + + storageAccount = TestStorageAccount__factory.connect(await storageFactory.callStatic.create(1, ''), provider) + await storageFactory.create(1, '') + + const rulesFactory = await new TestRulesAccountFactory__factory(ethersSigner).deploy() + rulesAccount = TestRulesAccount__factory.connect(await rulesFactory.callStatic.create(''), provider) + await rulesFactory.create('') + await entryPoint.depositTo(rulesAccount.address, { value: parseEther('1') }) + + const reputationManager = new ReputationManager({ + minInclusionDenominator: 1, + throttlingSlack: 1, + banSlack: 1 + }, + parseEther('0'), 0) + const unsafe = !await isGeth(provider) + vm = new ValidationManager(entryPoint, reputationManager, unsafe) + + if (!await isGeth(ethers.provider)) { + console.log('WARNING: opcode banning tests can only run with geth') + this.skip() + } + }) + + it('#getCodeHashes', async () => { + const epHash = keccak256(await provider.getCode(entryPoint.address)) + const pmHash = keccak256(await provider.getCode(paymaster.address)) + const addresses = [entryPoint.address, paymaster.address] + const packed = defaultAbiCoder.encode(['bytes32[]'], [[epHash, pmHash]]) + const packedHash = keccak256(packed) + expect(await vm.getCodeHashes(addresses)).to.eql({ + addresses, + hash: packedHash + }) + }) + + it('should accept plain request', async () => { + await testUserOp() + }) + it('test sanity: reject unknown rule', async () => { + expect(await testUserOp('') + .catch(e => e.message)).to.match(/unknown rule/) + }) + it('should fail with bad opcode in ctr', async () => { + expect( + await testUserOp('', undefined, opcodeFactory.interface.encodeFunctionData('create', ['coinbase'])) + .catch(e => e.message)).to.match(/factory uses banned opcode: COINBASE/) + }) + it('should fail with bad opcode in paymaster', async () => { + expect(await testUserOp('', 'coinbase', undefined) + .catch(e => e.message)).to.match(/paymaster uses banned opcode: COINBASE/) + }) + it('should fail with bad opcode in validation', async () => { + expect(await testUserOp('blockhash') + .catch(e => e.message)).to.match(/account uses banned opcode: BLOCKHASH/) + }) + it('should fail if creating too many', async () => { + expect(await testUserOp('create2') + .catch(e => e.message)).to.match(/account uses banned opcode: CREATE2/) + }) + // TODO: add a test with existing wallet, which should succeed (there is one in the "bundler spec" + it('should fail referencing self token balance (during wallet creation)', async () => { + expect(await testUserOp('balance-self', undefined, storageFactory.interface.encodeFunctionData('create', [0, '']), storageFactory.address) + .catch(e => e) + ).to.match(/unstaked account accessed/) + }) + + it('account succeeds referencing its own balance (after wallet creation)', async () => { + await testExistingUserOp('balance-self') + }) + + describe('access allowance (existing wallet)', () => { + it('account fails to read allowance of other address (even if account is token owner)', async () => { + expect(await testExistingUserOp('allowance-self-1') + .catch(e => e.message)) + .to.match(/account has forbidden read/) + }) + it('account can reference its own allowance on other contract balance', async () => { + await testExistingUserOp('allowance-1-self') + }) + }) + + describe('access struct (existing wallet)', () => { + it('should access self struct data', async () => { + await testExistingUserOp('struct-self') + }) + it('should fail to access other address struct data', async () => { + expect(await testExistingUserOp('struct-1') + .catch(e => e.message) + ).match(/account has forbidden read/) + }) + }) + + describe('validate storageMap', () => { + // let names: { [name: string]: string } + before(async () => { + // names = { + // pm: paymaster.address, + // ep: entryPoint.address, + // opf: opcodeFactory.address, + // stf: storageFactory.address, + // acc: rulesAccount.address, + // tok: await rulesAccount.coin() + // } + }) + + it('should return nothing during account creation', async () => { + const ret = await testUserOp('read-self', undefined, storageFactory.interface.encodeFunctionData('create', [0, '']), storageFactory.address) + // console.log('resolved=', resolveNames(ret, names, true)) + expect(ret.storageMap[ret.userOp.sender.toLowerCase()]).to.eql({ + [toBytes32(1)]: toBytes32(0) + }) + }) + + it('should return self storage on existing account', async () => { + const ret = await testExistingUserOp('read-self') + // console.log('resolved=', resolveNames(ret, names, true)) + const account = ret.userOp.sender.toLowerCase() + expect(ret.storageMap[account]).to.eql({ + [toBytes32(1)]: toBytes32(testcoin.address) + }) + }) + + it('should return nothing with no storage access', async () => { + const ret = await testExistingUserOp('') + expect(ret.storageMap).to.eql({}) + }) + + it('should return referenced storage', async () => { + const ret = await testExistingUserOp('balance-self') + // console.log('resolved=', resolveNames(ret, names, true)) + + const account = ret.userOp.sender.toLowerCase() + + // account's token at slot 1 of account + expect(ret.storageMap[account]).to.eql({ + [toBytes32(1)]: toBytes32(testcoin.address) + }) + // token.balances[account] - balances uses slot 0 of token + const hashRef = keccak256(hexConcat([toBytes32(account), toBytes32(0)])) + expect(ret.storageMap[testcoin.address.toLowerCase()]).to.eql({ + [hashRef]: toBytes32(0) + }) + }) + }) + + it('should fail if referencing other token balance', async () => { + expect(await testUserOp('balance-1', undefined, storageFactory.interface.encodeFunctionData('create', [0, '']), storageFactory.address) + .catch(e => e.message)) + .to.match(/account has forbidden read/) + }) + + it('should succeed referencing self token balance after wallet creation', async () => { + await testExistingUserOp('balance-self', undefined) + }) + + it('should fail with unstaked paymaster returning context', async () => { + const pm = await new TestStorageAccount__factory(ethersSigner).deploy() + // await entryPoint.depositTo(pm.address, { value: parseEther('0.1') }) + // await pm.addStake(entryPoint.address, { value: parseEther('0.1') }) + const acct = await new TestRecursionAccount__factory(ethersSigner).deploy(entryPoint.address) + + const userOp = { + ...cEmptyUserOp, + sender: acct.address, + paymasterAndData: hexConcat([ + pm.address, + Buffer.from('postOp-context') + ]) + } + expect(await vm.validateUserOp(userOp) + .then(() => 'should fail', e => e.message)) + .to.match(/unstaked paymaster must not return context/) + }) + + it('should fail if validation recursively calls handleOps', async () => { + const acct = await new TestRecursionAccount__factory(ethersSigner).deploy(entryPoint.address) + const op: UserOperation = { + ...cEmptyUserOp, + sender: acct.address, + signature: hexlify(Buffer.from('handleOps')), + preVerificationGas: 50000 + } + expect( + await vm.validateUserOp(op) + .catch(e => e.message) + ).to.match(/illegal call into EntryPoint/) + }) + it('should succeed with inner revert', async () => { + expect(await testUserOp('inner-revert', undefined, storageFactory.interface.encodeFunctionData('create', [0, '']), storageFactory.address)) + }) + it('should fail with inner oog revert', async () => { + expect(await testUserOp('oog', undefined, storageFactory.interface.encodeFunctionData('create', [0, '']), storageFactory.address) + .catch(e => e.message) + ).to.match(/account internally reverts on oog/) + }) +}) diff --git a/packages/boba/bundler/test/moduleUtils.test.ts b/packages/boba/bundler/test/moduleUtils.test.ts new file mode 100644 index 0000000000..faf1814090 --- /dev/null +++ b/packages/boba/bundler/test/moduleUtils.test.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai' +import { mergeStorageMap } from '../src/modules/moduleUtils' + +describe('#moduleUtils', () => { + describe('#mergeStorageMap', () => { + it('merge item into empty map', () => { + expect(mergeStorageMap({}, { a: 'val' })) + .to.eql({ a: 'val' }) + }) + it('merge items', () => { + expect(mergeStorageMap({ a: 'vala', b: 'valb' }, { a: 'val', c: 'valc' })) + .to.eql({ a: 'val', b: 'valb', c: 'valc' }) + }) + it('merge storage cells', () => { + expect(mergeStorageMap({ a: { s1: 's1', s2: 'v2' } }, { a: { s1: 's1', s3: 'v3' } })) + .to.eql({ a: { s1: 's1', s2: 'v2', s3: 'v3' } }) + }) + it('should prefer root over slots in merged', async () => { + expect(mergeStorageMap({ a: 'aa1' }, { a: { s1: 's1', s3: 'v3' } })) + .to.eql({ a: 'aa1' }) + }) + it('should prefer root over slots in validateStorage', async () => { + expect(mergeStorageMap({ a: { s1: 's1', s3: 'v3' } }, { a: 'aa1' })) + .to.eql({ a: 'aa1' }) + }) + }) +}) diff --git a/packages/boba/bundler/test/opcodes.test.ts b/packages/boba/bundler/test/opcodes.test.ts new file mode 100644 index 0000000000..29a074e56a --- /dev/null +++ b/packages/boba/bundler/test/opcodes.test.ts @@ -0,0 +1,3 @@ +describe('opcode banning', () => { + +}) diff --git a/packages/boba/bundler/test/runBundler.test.ts b/packages/boba/bundler/test/runBundler.test.ts index 22031fecb8..d9b9897e76 100644 --- a/packages/boba/bundler/test/runBundler.test.ts +++ b/packages/boba/bundler/test/runBundler.test.ts @@ -1,5 +1,3 @@ describe('runBundler', function () { - describe('resolveConfiguration', function () { - it('') - }) + // it('resolveConfiguration') }) diff --git a/packages/boba/bundler/test/testUtils.ts b/packages/boba/bundler/test/testUtils.ts new file mode 100644 index 0000000000..7d2cca8879 --- /dev/null +++ b/packages/boba/bundler/test/testUtils.ts @@ -0,0 +1,43 @@ +import { BigNumber, Signer, Wallet } from 'ethers' +import { HDNode, parseEther } from 'ethers/lib/utils' +import { ethers } from 'hardhat' + +// create an hdkey signer, and fund it, if needed. +export async function createSigner (): Promise { + const provider = ethers.provider + const privateKey = HDNode.fromMnemonic('test '.repeat(11) + 'junk') + const signer = new Wallet(privateKey, provider) + const signerAddress = await signer.getAddress() + const signerBalance = await signer.getBalance() + if (signerBalance.lt(parseEther('10'))) { + await ethers.provider.getSigner().sendTransaction({ + to: signerAddress, + value: parseEther('10') + }) + } + return signer +} + +// debugging helper: +// process json object, and convert any key or value that is a hex address into its name +// +export function resolveNames (json: T, nameToAddress: { [name: string]: string }, onlyNames = false): T { + const addressToNameMap: { [addr: string]: string } = Object.entries(nameToAddress) + .reduce((set, [name, addr]) => ({ + ...set, + [addr.toLowerCase().replace(/0x0*/, '')]: name + }), {}) + const s = JSON.stringify(json) + const s1 = s + .replace(/[{]"type":"BigNumber","hex":"(.*?)"[}]/g, (_, hex) => BigNumber.from(hex).toString()) + .replace(/(0x0*)([0-9a-fA-F]+)+/g, (_, prefix: string, hex: string) => { + const hexToName = addressToNameMap[hex.toLowerCase()] + if (hexToName == null) return `${prefix}${hex}` // not found in map: leave as-is + if (onlyNames) { + return hexToName + } else { + return `${prefix}<${hexToName}>${hex}` + } + }) + return JSON.parse(s1) +} diff --git a/packages/boba/bundler/test/tracer.test.ts b/packages/boba/bundler/test/tracer.test.ts index c70c0cfcc3..543a925255 100644 --- a/packages/boba/bundler/test/tracer.test.ts +++ b/packages/boba/bundler/test/tracer.test.ts @@ -11,9 +11,9 @@ const signer = provider.getSigner() describe('#bundlerCollectorTracer', () => { let tester: TracerTest before(async function () { - const ver = await (provider as any).send('web3_clientVersion') - if (ver.match('Geth') == null) { - console.warn('\t==WARNING: test requires debug_traceCall on Geth (go-ethereum) node') + const ver: string = await (provider as any).send('web3_clientVersion') + if (ver.match('go1') == null) { + console.warn('\t==WARNING: test requires debug_traceCall on Geth (go-ethereum) node. ver=' + ver) this.skip() return } @@ -23,7 +23,6 @@ describe('#bundlerCollectorTracer', () => { it('should count opcodes on depth>1', async () => { const ret = await traceExecSelf(tester.interface.encodeFunctionData('callTimeStamp'), false, true) - console.log('ret=', ret, ret.numberLevels) const execEvent = tester.interface.decodeEventLog('ExecSelfResult', ret.logs[0].data, ret.logs[0].topics) expect(execEvent.success).to.equal(true) expect(ret.numberLevels[0].opcodes.TIMESTAMP).to.equal(1) @@ -77,7 +76,7 @@ describe('#bundlerCollectorTracer', () => { }) }) }) - 4 + it('should report direct use of GAS opcode', async () => { const ret = await traceExecSelf(tester.interface.encodeFunctionData('testCallGas'), false) expect(ret.numberLevels['0'].opcodes.GAS).to.eq(1) @@ -90,19 +89,4 @@ describe('#bundlerCollectorTracer', () => { const ret = await traceExecSelf(callDoNothing, false) expect(ret.numberLevels['0'].opcodes.GAS).to.be.undefined }) - - it.skip('should collect reverted call info', async () => { - const revertingCallData = tester.interface.encodeFunctionData('callRevertingFunction', [true]) - - const tracer = bundlerCollectorTracer - const ret = await debug_traceCall(provider, { - to: tester.address, - data: revertingCallData - }, { - tracer - }) as BundlerCollectorReturn - - expect(ret.debug[0]).to.include(['fault']) - // todo: tests for failures. (e.g. detect oog) - }) }) diff --git a/packages/boba/bundler/test/utils.test.ts b/packages/boba/bundler/test/utils.test.ts index 9d6762e038..c894aec96e 100644 --- a/packages/boba/bundler/test/utils.test.ts +++ b/packages/boba/bundler/test/utils.test.ts @@ -1,13 +1,13 @@ import { expect } from 'chai' import { BigNumber } from 'ethers' -import { deepHexlify } from '../src/utils' +import { deepHexlify } from '@boba/bundler_utils' describe('#deepHexlify', function () { it('empty', () => { expect(deepHexlify({})).to.eql({}) }) it('flat', () => { - expect(deepHexlify({ a: 1 })).to.eql({ a: '0x01' }) + expect(deepHexlify({ a: 1 })).to.eql({ a: '0x1' }) }) it('no-modify for strings', () => { expect(deepHexlify({ a: 'hello' })).to.eql({ a: 'hello' }) @@ -16,7 +16,7 @@ describe('#deepHexlify', function () { expect(deepHexlify({ a: false })).to.eql({ a: false }) }) it('bignum', () => { - expect(deepHexlify({ a: BigNumber.from(3) })).to.eql({ a: '0x03' }) + expect(deepHexlify({ a: BigNumber.from(3) })).to.eql({ a: '0x3' }) }) it('deep object ', () => { expect(deepHexlify({ @@ -31,15 +31,15 @@ describe('#deepHexlify', function () { }, 2, 3] } })).to.eql({ - a: '0x01', + a: '0x1', b: { - c: '0x04', + c: '0x4', d: false, e: [{ - f: '0x05', + f: '0x5', g: 'nothing', h: true - }, '0x02', '0x03'] + }, '0x2', '0x3'] } }) }) diff --git a/packages/boba/bundler_sdk/hardhat.config.ts b/packages/boba/bundler_sdk/hardhat.config.ts index a292aa8f38..e309890433 100644 --- a/packages/boba/bundler_sdk/hardhat.config.ts +++ b/packages/boba/bundler_sdk/hardhat.config.ts @@ -1,4 +1,4 @@ -// import '@nomiclabs/hardhat-ethers' +import '@nomiclabs/hardhat-ethers' import '@nomicfoundation/hardhat-toolbox' import { HardhatUserConfig } from 'hardhat/config' diff --git a/packages/boba/bundler_sdk/package.json b/packages/boba/bundler_sdk/package.json index 8217aa72a6..17af2d8ed8 100644 --- a/packages/boba/bundler_sdk/package.json +++ b/packages/boba/bundler_sdk/package.json @@ -1,24 +1,25 @@ { "name": "@boba/bundler_sdk", "version": "0.2.3", - "main": "./dist/src/index.js", + "main": "./dist/index.js", "license": "MIT", "files": [ "dist/src", "README.md" ], "scripts": { - "build:stopp": "tsc -p ./tsconfig.json", + "build": "hardhat compile && tsc", "clean": "rimraf dist/ cache node_modules ./tsconfig.tsbuildinfo", "lint": "eslint -f unix .", "lint-fix": "eslint -f unix . --fix", "test": "hardhat test", - "test:coverage:stop": "COVERAGE=1 hardhat coverage", + "test:coverage": "COVERAGE=1 hardhat coverage", "hardhat-test": "hardhat test", "tsc": "tsc", "watch-tsc": "tsc -w --preserveWatchOutput" }, "dependencies": { + "@ethersproject/abi": "^5.7.0", "@boba/accountabstraction": "^1.0.0", "@boba/bundler_utils": "^0.2.3", "@ethersproject/abstract-provider": "^5.7.0", @@ -28,14 +29,22 @@ "@ethersproject/providers": "^5.7.0", "@types/debug": "^4.1.7", "debug": "^4.3.4", - "ethers": "^5.7.0" + "ethers": "^5.7.0", + "hardhat-gas-reporter": "^1.0.8" }, "devDependencies": { + "@typechain/ethers-v5": "^10.1.0", + "@typechain/hardhat": "^6.1.2", + "@types/mocha": "^9.1.0", + "@nomiclabs/hardhat-etherscan": "^3.0.0", "@nomicfoundation/hardhat-chai-matchers": "^1.0.3", "@nomicfoundation/hardhat-network-helpers": "^1.0.8", "@nomicfoundation/hardhat-toolbox": "^1.0.2", "@nomiclabs/hardhat-ethers": "^2.0.2", "chai": "^4.3.6", - "hardhat": "^2.12.5" + "hardhat": "^2.12.5", + "solidity-coverage": "^0.7.21", + "ts-node": ">=8.0.0", + "typechain": "^8.1.0" } } diff --git a/packages/boba/bundler_sdk/src/BaseWalletAPI.ts b/packages/boba/bundler_sdk/src/BaseAccountAPI.ts similarity index 74% rename from packages/boba/bundler_sdk/src/BaseWalletAPI.ts rename to packages/boba/bundler_sdk/src/BaseAccountAPI.ts index abe36b840a..b5ccf47d45 100644 --- a/packages/boba/bundler_sdk/src/BaseWalletAPI.ts +++ b/packages/boba/bundler_sdk/src/BaseAccountAPI.ts @@ -16,7 +16,7 @@ export interface BaseApiParams { provider: Provider entryPointAddress: string senderCreatorAddress?: string - walletAddress?: string + accountAddress?: string overheads?: Partial paymasterAPI?: PaymasterAPI } @@ -30,16 +30,16 @@ export interface UserOpResult { * Base class for all Smart Wallet ERC-4337 Clients to implement. * Subclass should inherit 5 methods to support a specific wallet contract: * - * - getWalletInitCode - return the value to put into the "initCode" field, if the wallet is not yet deployed. should create the wallet instance using a factory contract. - * - getNonce - return current wallet's nonce value - * - encodeExecute - encode the call from entryPoint through our wallet to the target contract. + * - getAccountInitCode - return the value to put into the "initCode" field, if the account is not yet deployed. should create the account instance using a factory contract. + * - getNonce - return current account's nonce value + * - encodeExecute - encode the call from entryPoint through our account to the target contract. * - signUserOpHash - sign the hash of a UserOp. * * The user can use the following APIs: - * - createUnsignedUserOp - given "target" and "calldata", fill userOp to perform that operation from the wallet. + * - createUnsignedUserOp - given "target" and "calldata", fill userOp to perform that operation from the account. * - createSignedUserOp - helper to call the above createUnsignedUserOp, and then extract the userOpHash and sign it */ -export abstract class BaseWalletAPI { +export abstract class BaseAccountAPI { private senderAddress!: string private isPhantom = true // entryPoint connected to "zero" address. allowed to make static calls (e.g. to getSenderAddress) @@ -49,7 +49,7 @@ export abstract class BaseWalletAPI { overheads?: Partial entryPointAddress: string senderCreatorAddress?: string - walletAddress?: string + accountAddress?: string paymasterAPI?: PaymasterAPI /** @@ -61,7 +61,7 @@ export abstract class BaseWalletAPI { this.overheads = params.overheads this.entryPointAddress = params.entryPointAddress this.senderCreatorAddress = params.senderCreatorAddress - this.walletAddress = params.walletAddress + this.accountAddress = params.accountAddress this.paymasterAPI = params.paymasterAPI // factory "connect" define the contract address. the contract "connect" defines the "from" address. @@ -73,23 +73,23 @@ export abstract class BaseWalletAPI { throw new Error(`entryPoint not deployed at ${this.entryPointAddress}`) } - await this.getWalletAddress() + await this.getAccountAddress() return this } /** - * return the value to put into the "initCode" field, if the wallet is not yet deployed. - * this value holds the "factory" address, followed by this wallet's information + * return the value to put into the "initCode" field, if the contract is not yet deployed. + * this value holds the "factory" address, followed by this account's information */ - abstract getWalletInitCode (): Promise + abstract getAccountInitCode (): Promise /** - * return current wallet's nonce. + * return current account's nonce. */ abstract getNonce (): Promise /** - * encode the call from entryPoint through our wallet to the target contract. + * encode the call from entryPoint through our account to the target contract. * @param target * @param value * @param data @@ -103,41 +103,41 @@ export abstract class BaseWalletAPI { abstract signUserOpHash (userOpHash: string): Promise /** - * check if the wallet is already deployed. + * check if the contract is already deployed. */ - async checkWalletPhantom (): Promise { + async checkAccountPhantom (): Promise { if (!this.isPhantom) { // already deployed. no need to check anymore. return this.isPhantom } - const senderAddressCode = await this.provider.getCode(this.getWalletAddress()) + const senderAddressCode = await this.provider.getCode(this.getAccountAddress()) if (senderAddressCode.length > 2) { // console.log(`SimpleAccount Contract already deployed at ${this.senderAddress}`) this.isPhantom = false } else { - // console.log(`SimpleAccount Contract is NOT YET deployed at ${this.senderAddress} - working in "phantom wallet" mode.`) + // console.log(`SimpleAccount Contract is NOT YET deployed at ${this.senderAddress} - working in "phantom account" mode.`) } return this.isPhantom } /** - * calculate the wallet address even before it is deployed + * calculate the account address even before it is deployed */ async getCounterFactualAddress (): Promise { - const initCode = this.getWalletInitCode() - // use entryPoint to query wallet address (factory can provide a helper method to do the same, but + const initCode = this.getAccountInitCode() + // use entryPoint to query account address (factory can provide a helper method to do the same, but // this method attempts to be generic if (this.senderCreatorAddress != null) { - const senderCreator = new Contract(this.senderCreatorAddress, SenderCreator__factory.abi, this.provider) - return senderCreator.callStatic.createSender(initCode) - } else { - try { - await this.entryPointView.callStatic.getSenderAddress(initCode) - } catch (e: any) { - return e.errorArgs.sender - } - throw new Error('must handle revert') - } + const senderCreator = new Contract(this.senderCreatorAddress, SenderCreator__factory.abi, this.provider) + return senderCreator.callStatic.createSender(initCode) + } else { + try { + await this.entryPointView.callStatic.getSenderAddress(initCode) + } catch (e: any) { + return e.errorArgs.sender + } + throw new Error('must handle revert') + } } /** @@ -145,15 +145,15 @@ export abstract class BaseWalletAPI { * (either deployment code, or empty hex if contract already deployed) */ async getInitCode (): Promise { - if (await this.checkWalletPhantom()) { - return await this.getWalletInitCode() + if (await this.checkAccountPhantom()) { + return await this.getAccountInitCode() } return '0x' } /** * return maximum gas used for verification. - * NOTE: createUnsignedUserOp will add to this value the cost of creation, if the wallet is not yet created. + * NOTE: createUnsignedUserOp will add to this value the cost of creation, if the contract is not yet created. */ async getVerificationGasLimit (): Promise { return 100000 @@ -180,16 +180,15 @@ export abstract class BaseWalletAPI { if (a == null || a === '') return null return BigNumber.from(a.toString()) } - const value = parseNumber(detailsForUserOp.value) ?? BigNumber.from(0) + const callData = await this.encodeExecute(detailsForUserOp.target, value, detailsForUserOp.data) const callGasLimit = parseNumber(detailsForUserOp.gasLimit) ?? await this.provider.estimateGas({ from: this.entryPointAddress, - to: this.getWalletAddress(), + to: this.getAccountAddress(), data: callData }) - return { callData, callGasLimit @@ -208,13 +207,13 @@ export abstract class BaseWalletAPI { } /** - * return the wallet's address. - * this value is valid even before deploying the wallet. + * return the account's address. + * this value is valid even before deploying the contract. */ - async getWalletAddress (): Promise { + async getAccountAddress (): Promise { if (this.senderAddress == null) { - if (this.walletAddress != null) { - this.senderAddress = this.walletAddress + if (this.accountAddress != null) { + this.senderAddress = this.accountAddress } else { this.senderAddress = await this.getCounterFactualAddress() } @@ -231,8 +230,8 @@ export abstract class BaseWalletAPI { /** * create a UserOperation, filling all details (except signature) - * - if wallet is not yet created, add initCode to deploy it. - * - if gas or nonce are missing, read them from the chain (note that we can't fill gaslimit before the wallet is created) + * - if account is not yet created, add initCode to deploy it. + * - if gas or nonce are missing, read them from the chain (note that we can't fill gaslimit before the account is created) * @param info */ async createUnsignedUserOp (info: TransactionDetailsForUserOp): Promise { @@ -241,34 +240,31 @@ export abstract class BaseWalletAPI { callGasLimit } = await this.encodeUserOpCallDataAndGasLimit(info) const initCode = await this.getInitCode() - const initGas = await this.estimateCreationGas(initCode) const verificationGasLimit = BigNumber.from(await this.getVerificationGasLimit()) .add(initGas) - let { - maxFeePerGas, - maxPriorityFeePerGas - } = info - if (maxFeePerGas == null || maxPriorityFeePerGas == null) { - const feeData = await this.provider.getGasPrice() - if (maxFeePerGas == null) { - maxFeePerGas = feeData ?? undefined - } - if (maxPriorityFeePerGas == null) { - maxPriorityFeePerGas = feeData.lt(ethers.utils.parseUnits('1', 'gwei')) ? feeData : ethers.utils.parseUnits('1', 'gwei') ?? undefined - } - } + let { maxFeePerGas, maxPriorityFeePerGas } = info + if (maxFeePerGas == null || maxPriorityFeePerGas == null) { + const feeData = await this.provider.getGasPrice() + if (maxFeePerGas == null) { + maxFeePerGas = feeData ?? undefined + } + if (maxPriorityFeePerGas == null) { + maxPriorityFeePerGas = feeData.lt(ethers.utils.parseUnits('1', 'gwei')) ? feeData : ethers.utils.parseUnits('1', 'gwei') ?? undefined + } + } const partialUserOp: any = { - sender: this.getWalletAddress(), + sender: this.getAccountAddress(), nonce: this.getNonce(), initCode, callData, callGasLimit, verificationGasLimit, maxFeePerGas, - maxPriorityFeePerGas + maxPriorityFeePerGas, + paymasterAndData: '0x' } let paymasterAndData: string | undefined @@ -276,11 +272,12 @@ export abstract class BaseWalletAPI { // fill (partial) preVerificationGas (all except the cost of the generated paymasterAndData) const userOpForPm = { ...partialUserOp, - preVerificationGas: this.getPreVerificationGas(partialUserOp) + preVerificationGas: await this.getPreVerificationGas(partialUserOp) } paymasterAndData = await this.paymasterAPI.getPaymasterAndData(userOpForPm) } partialUserOp.paymasterAndData = paymasterAndData ?? '0x' + return { ...partialUserOp, preVerificationGas: this.getPreVerificationGas(partialUserOp), @@ -306,7 +303,9 @@ export abstract class BaseWalletAPI { * @param info transaction details for the userOp */ async createSignedUserOp (info: TransactionDetailsForUserOp): Promise { - return await this.signUserOp(await this.createUnsignedUserOp(info)) + const unsignedUserOp = await this.createUnsignedUserOp(info) + const singUserOp = await this.signUserOp(unsignedUserOp) + return singUserOp } /** diff --git a/packages/boba/bundler_sdk/src/DeterministicDeployer.ts b/packages/boba/bundler_sdk/src/DeterministicDeployer.ts index 9407cbb6f9..092edc5806 100644 --- a/packages/boba/bundler_sdk/src/DeterministicDeployer.ts +++ b/packages/boba/bundler_sdk/src/DeterministicDeployer.ts @@ -1,4 +1,4 @@ -import { BigNumber, BigNumberish, Wallet } from 'ethers' +import { BigNumber, BigNumberish, ContractFactory, Wallet } from 'ethers' import { hexConcat, hexlify, hexZeroPad, keccak256 } from 'ethers/lib/utils' import { TransactionRequest } from '@ethersproject/abstract-provider' import { JsonRpcProvider } from '@ethersproject/providers' @@ -10,115 +10,183 @@ import { JsonRpcProvider } from '@ethersproject/providers' export class DeterministicDeployer { /** * return the address this code will get deployed to. - * @param ctrCode constructor code to pass to CREATE2 + * + * @param ctrCode constructor code to pass to CREATE2, or ContractFactory * @param salt optional salt. defaults to zero */ - static async getAddress (ctrCode: string, salt: BigNumberish = 0): Promise { - return await DeterministicDeployer.instance.getDeterministicDeployAddress(ctrCode, salt) + static async getAddress(ctrCode: string, salt: BigNumberish): Promise + static async getAddress(ctrCode: string): Promise + static async getAddress( + ctrCode: ContractFactory, + salt: BigNumberish, + params: any[] + ): Promise + static async getAddress( + ctrCode: string | ContractFactory, + salt: BigNumberish = 0, + params: any[] = [] + ): Promise { + return await DeterministicDeployer.instance.getDeterministicDeployAddress( + ctrCode, + salt, + params + ) } /** * deploy the contract, unless already deployed - * @param ctrCode constructor code to pass to CREATE2 + * + * @param ctrCode constructor code to pass to CREATE2 or ContractFactory * @param salt optional salt. defaults to zero * @return the deployed address */ - static async deploy (ctrCode: string, salt: BigNumberish = 0): Promise { - return await DeterministicDeployer.instance.deterministicDeploy(ctrCode, salt) + static async deploy(ctrCode: string, salt?: BigNumberish): Promise + static async deploy( + ctrCode: ContractFactory, + salt: BigNumberish, + params: any[] + ): Promise + static async deploy( + ctrCode: string | ContractFactory, + salt: BigNumberish = 0, + params: any[] = [] + ): Promise { + return DeterministicDeployer.instance.deterministicDeploy( + ctrCode, + salt, + params + ) } // from: https://github.com/Arachnid/deterministic-deployment-proxy proxyAddress = '0x4e59b44847b379578588920ca78fbf26c0b4956c' - deploymentTransaction = '0xf8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222' + deploymentTransaction = + '0xf8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222' + deploymentSignerAddress = '0x3fab184622dc19b6109349b94811493bf2a45362' deploymentGasPrice = 100e9 deploymentGasLimit = 100000 - constructor (readonly provider: JsonRpcProvider, readonly wallet?: Wallet, network?: string) { + constructor( + readonly provider: JsonRpcProvider, + readonly wallet?: Wallet, + network?: string + ) { if (network === 'boba_mainnet') { this.proxyAddress = '0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7' - this.deploymentTransaction = '0xf8a7808504a817c800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3820264a02836f16b67fdf74d02d4d9548495cffd739f509b9bc4b8fdffd2611c38489642a07864709b3f830a661897f4d60d98efc26754f44be447cf35a65ff92a06cb7bd0' - this.deploymentSignerAddress = '0xE1CB04A0fA36DdD16a06ea828007E35e1a3cBC37' + this.deploymentTransaction = + '0xf8a7808504a817c800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3820264a02836f16b67fdf74d02d4d9548495cffd739f509b9bc4b8fdffd2611c38489642a07864709b3f830a661897f4d60d98efc26754f44be447cf35a65ff92a06cb7bd0' + this.deploymentSignerAddress = + '0xE1CB04A0fA36DdD16a06ea828007E35e1a3cBC37' this.deploymentGasPrice = 20000000000 this.deploymentGasLimit = 100000 } else if (network === 'boba_goerli') { this.proxyAddress = '0x4e59b44847b379578588920ca78fbf26c0b4956c' - this.deploymentTransaction = '0xf8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222' - this.deploymentSignerAddress = '0x3fab184622dc19b6109349b94811493bf2a45362' + this.deploymentTransaction = + '0xf8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222' + this.deploymentSignerAddress = + '0x3fab184622dc19b6109349b94811493bf2a45362' this.deploymentGasPrice = 100e9 this.deploymentGasLimit = 100000 } } - async isContractDeployed (address: string): Promise { - return await this.provider.getCode(address).then(code => code.length > 2) + async isContractDeployed(address: string): Promise { + return await this.provider.getCode(address).then((code) => code.length > 2) } - async isDeployerDeployed (): Promise { + async isDeployerDeployed(): Promise { return await this.isContractDeployed(this.proxyAddress) } - async deployDeployer (): Promise { + async deployFactory(): Promise { if (await this.isContractDeployed(this.proxyAddress)) { return } const bal = await this.provider.getBalance(this.deploymentSignerAddress) - const neededBalance = BigNumber.from(this.deploymentGasLimit).mul(this.deploymentGasPrice) + const neededBalance = BigNumber.from(this.deploymentGasLimit).mul( + this.deploymentGasPrice + ) const signer = this.provider.getSigner() if (bal.lt(neededBalance)) { - if (this.wallet != null) { - await this.wallet.sendTransaction({ - to: this.deploymentSignerAddress, - value: neededBalance, - gasLimit: this.deploymentGasLimit - }) - } else { - await signer.sendTransaction({ - to: this.deploymentSignerAddress, - value: neededBalance, - gasLimit: this.deploymentGasLimit - }) - } + await signer.sendTransaction({ + to: this.deploymentSignerAddress, + value: neededBalance, + gasLimit: this.deploymentGasLimit, + }) } - await this.provider.send('eth_sendRawTransaction', [this.deploymentTransaction]) - if (!await this.isContractDeployed(this.proxyAddress)) { - throw new Error('raw TX didn\'t deploy deployer!') + await this.provider.send('eth_sendRawTransaction', [ + this.deploymentTransaction, + ]) + if (!(await this.isContractDeployed(this.proxyAddress))) { + throw new Error("raw TX didn't deploy deployer!") } } - async getDeployTransaction (ctrCode: string, salt: BigNumberish = 0): Promise { - await this.deployDeployer() + async getDeployTransaction( + ctrCode: string | ContractFactory, + salt: BigNumberish = 0, + params: any[] = [] + ): Promise { + await this.deployFactory() const saltEncoded = hexZeroPad(hexlify(salt), 32) + const ctrEncoded = await this.getCtrCode(ctrCode, params) return { to: this.proxyAddress, - data: hexConcat([ - saltEncoded, - ctrCode]) + data: hexConcat([saltEncoded, ctrEncoded]), } } - async getDeterministicDeployAddress (ctrCode: string, salt: BigNumberish = 0): Promise { + getCtrCode(ctrCode: string | ContractFactory, params: any[]): string { + if (typeof ctrCode !== 'string') { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return hexlify(ctrCode.getDeployTransaction(...params).data!) + } else { + if (params.length !== 0) { + throw new Error( + 'constructor params can only be passed to ContractFactory' + ) + } + return ctrCode + } + } + + async getDeterministicDeployAddress( + ctrCode: string | ContractFactory, + salt: BigNumberish = 0, + params: any[] = [] + ): Promise { // this method works only before the contract is already deployed: // return await this.provider.call(await this.getDeployTransaction(ctrCode, salt)) const saltEncoded = hexZeroPad(hexlify(salt), 32) - - return '0x' + keccak256(hexConcat([ - '0xff', - this.proxyAddress, - saltEncoded, - keccak256(ctrCode) - ])).slice(-40) + const ctrCode1 = this.getCtrCode(ctrCode, params) + return ( + '0x' + + keccak256( + hexConcat(['0xff', this.proxyAddress, saltEncoded, keccak256(ctrCode1)]) + ).slice(-40) + ) } - async deterministicDeploy (ctrCode: string, salt: BigNumberish = 0): Promise { - const addr = await this.getDeterministicDeployAddress(ctrCode, salt) - if (!await this.isContractDeployed(addr)) { + async deterministicDeploy( + ctrCode: string | ContractFactory, + salt: BigNumberish = 0, + params: any[] = [] + ): Promise { + const addr = await this.getDeterministicDeployAddress(ctrCode, salt, params) + console.log('deterministicDeploy') + console.log(addr) + if (!(await this.isContractDeployed(addr))) { if (this.wallet != null) { await this.wallet.sendTransaction( - await this.getDeployTransaction(ctrCode, salt)) + await this.getDeployTransaction(ctrCode, salt, params) + ) } else { - await this.provider.getSigner().sendTransaction( - await this.getDeployTransaction(ctrCode, salt)) + await this.provider + .getSigner() + .sendTransaction( + await this.getDeployTransaction(ctrCode, salt, params) + ) } } return addr @@ -126,13 +194,19 @@ export class DeterministicDeployer { private static _instance?: DeterministicDeployer - static init (provider: JsonRpcProvider, wallet?: Wallet, network?: string): void { + static init( + provider: JsonRpcProvider, + wallet?: Wallet, + network?: string + ): void { this._instance = new DeterministicDeployer(provider, wallet, network) } - static get instance (): DeterministicDeployer { + static get instance(): DeterministicDeployer { if (this._instance == null) { - throw new Error('must call "DeterministicDeployer.init(ethers.provider)" first') + throw new Error( + 'must call "DeterministicDeployer.init(ethers.provider)" first' + ) } return this._instance } diff --git a/packages/boba/bundler_sdk/src/ERC4337EthersProvider.ts b/packages/boba/bundler_sdk/src/ERC4337EthersProvider.ts index eba4fe5fd5..e4d485a727 100644 --- a/packages/boba/bundler_sdk/src/ERC4337EthersProvider.ts +++ b/packages/boba/bundler_sdk/src/ERC4337EthersProvider.ts @@ -1,4 +1,8 @@ -import { BaseProvider, TransactionReceipt, TransactionResponse } from '@ethersproject/providers' +import { + BaseProvider, + TransactionReceipt, + TransactionResponse, +} from '@ethersproject/providers' import { BigNumber, Signer } from 'ethers' import { Network } from '@ethersproject/networks' import { hexValue, resolveProperties } from 'ethers/lib/utils' @@ -9,7 +13,7 @@ import { UserOperationEventListener } from './UserOperationEventListener' import { HttpRpcClient } from './HttpRpcClient' import { EntryPoint, UserOperationStruct } from '@boba/accountabstraction' import { getUserOpHash } from '@boba/bundler_utils' -import { BaseWalletAPI } from './BaseWalletAPI' +import { BaseAccountAPI } from './BaseAccountAPI' import Debug from 'debug' const debug = Debug('aa.provider') @@ -18,39 +22,45 @@ export class ERC4337EthersProvider extends BaseProvider { readonly signer: ERC4337EthersSigner - constructor ( + constructor( readonly chainId: number, readonly config: ClientConfig, readonly originalSigner: Signer, readonly originalProvider: BaseProvider, readonly httpRpcClient: HttpRpcClient, readonly entryPoint: EntryPoint, - readonly smartWalletAPI: BaseWalletAPI + readonly smartAccountAPI: BaseAccountAPI ) { super({ name: 'ERC-4337 Custom Network', - chainId + chainId, }) - this.signer = new ERC4337EthersSigner(config, originalSigner, this, httpRpcClient, smartWalletAPI) + this.signer = new ERC4337EthersSigner( + config, + originalSigner, + this, + httpRpcClient, + smartAccountAPI + ) } /** * finish intializing the provider. * MUST be called after construction, before using the provider. */ - async init (): Promise { + async init(): Promise { // await this.httpRpcClient.validateChainId() this.initializedBlockNumber = await this.originalProvider.getBlockNumber() - await this.smartWalletAPI.init() + await this.smartAccountAPI.init() // await this.signer.init() return this } - getSigner (): ERC4337EthersSigner { + getSigner(): ERC4337EthersSigner { return this.signer } - async perform (method: string, params: any): Promise { + async perform(method: string, params: any): Promise { debug('perform', method, params) if (method === 'sendTransaction' || method === 'getTransactionReceipt') { // TODO: do we need 'perform' method to be available at all? @@ -60,41 +70,72 @@ export class ERC4337EthersProvider extends BaseProvider { return await this.originalProvider.perform(method, params) } - async getTransaction (transactionHash: string | Promise): Promise { + async getTransaction( + transactionHash: string | Promise + ): Promise { // TODO return await super.getTransaction(transactionHash) } - async getTransactionReceipt (transactionHash: string | Promise): Promise { + async getTransactionReceipt( + transactionHash: string | Promise + ): Promise { const userOpHash = await transactionHash - const sender = await this.getSenderWalletAddress() + const sender = await this.getSenderAccountAddress() return await new Promise((resolve, reject) => { new UserOperationEventListener( - resolve, reject, this.entryPoint, sender, userOpHash + resolve, + reject, + this.entryPoint, + sender, + userOpHash ).start() }) } - async getSenderWalletAddress (): Promise { - return await this.smartWalletAPI.getWalletAddress() + async getSenderAccountAddress(): Promise { + return await this.smartAccountAPI.getAccountAddress() } - async waitForTransaction (transactionHash: string, confirmations?: number, timeout?: number): Promise { - const sender = await this.getSenderWalletAddress() + async waitForTransaction( + transactionHash: string, + confirmations?: number, + timeout?: number + ): Promise { + const sender = await this.getSenderAccountAddress() return await new Promise((resolve, reject) => { - const listener = new UserOperationEventListener(resolve, reject, this.entryPoint, sender, transactionHash, undefined, timeout) + const listener = new UserOperationEventListener( + resolve, + reject, + this.entryPoint, + sender, + transactionHash, + undefined, + timeout + ) listener.start() }) } // fabricate a response in a format usable by ethers users... - async constructUserOpTransactionResponse (userOp1: UserOperationStruct): Promise { + async constructUserOpTransactionResponse( + userOp1: UserOperationStruct + ): Promise { const userOp = await resolveProperties(userOp1) - const userOpHash = getUserOpHash(userOp, this.config.entryPointAddress, this.chainId) + const userOpHash = getUserOpHash( + userOp, + this.config.entryPointAddress, + this.chainId + ) const waitPromise = new Promise((resolve, reject) => { new UserOperationEventListener( - resolve, reject, this.entryPoint, userOp.sender, userOpHash, userOp.nonce + resolve, + reject, + this.entryPoint, + userOp.sender, + userOpHash, + userOp.nonce ).start() }) return { @@ -110,14 +151,14 @@ export class ERC4337EthersProvider extends BaseProvider { const transactionReceipt = await waitPromise if (userOp.initCode.length !== 0) { // checking if the wallet has been deployed by the transaction; it must be if we are here - await this.smartWalletAPI.checkWalletPhantom() + await this.smartAccountAPI.checkAccountPhantom() } return transactionReceipt - } + }, } } - async detectNetwork (): Promise { + async detectNetwork(): Promise { return (this.originalProvider as any).detectNetwork() } } diff --git a/packages/boba/bundler_sdk/src/ERC4337EthersSigner.ts b/packages/boba/bundler_sdk/src/ERC4337EthersSigner.ts index 9ed9c12253..7633d91254 100644 --- a/packages/boba/bundler_sdk/src/ERC4337EthersSigner.ts +++ b/packages/boba/bundler_sdk/src/ERC4337EthersSigner.ts @@ -1,5 +1,9 @@ import { Deferrable, defineReadOnly } from '@ethersproject/properties' -import { Provider, TransactionRequest, TransactionResponse } from '@ethersproject/providers' +import { + Provider, + TransactionRequest, + TransactionResponse, +} from '@ethersproject/providers' import { Signer } from '@ethersproject/abstract-signer' import { Bytes } from 'ethers' @@ -7,16 +11,17 @@ import { ERC4337EthersProvider } from './ERC4337EthersProvider' import { ClientConfig } from './ClientConfig' import { HttpRpcClient } from './HttpRpcClient' import { UserOperationStruct } from '@boba/accountabstraction' -import { BaseWalletAPI } from './BaseWalletAPI' +import { BaseAccountAPI } from './BaseAccountAPI' export class ERC4337EthersSigner extends Signer { // TODO: we have 'erc4337provider', remove shared dependencies or avoid two-way reference - constructor ( + constructor( readonly config: ClientConfig, readonly originalSigner: Signer, readonly erc4337provider: ERC4337EthersProvider, readonly httpRpcClient: HttpRpcClient, - readonly smartWalletAPI: BaseWalletAPI) { + readonly smartAccountAPI: BaseAccountAPI + ) { super() defineReadOnly(this, 'provider', erc4337provider) } @@ -24,16 +29,21 @@ export class ERC4337EthersSigner extends Signer { address?: string // This one is called by Contract. It signs the request and passes in to Provider to be sent. - async sendTransaction (transaction: Deferrable): Promise { + async sendTransaction( + transaction: Deferrable + ): Promise { const tx: TransactionRequest = await this.populateTransaction(transaction) await this.verifyAllNecessaryFields(tx) - const userOperation = await this.smartWalletAPI.createSignedUserOp({ + const userOperation = await this.smartAccountAPI.createSignedUserOp({ target: tx.to ?? '', data: tx.data?.toString() ?? '', value: tx.value, - gasLimit: tx.gasLimit + gasLimit: tx.gasLimit, }) - const transactionResponse = await this.erc4337provider.constructUserOpTransactionResponse(userOperation) + const transactionResponse = + await this.erc4337provider.constructUserOpTransactionResponse( + userOperation + ) try { await this.httpRpcClient.sendUserOpToBundler(userOperation) } catch (error: any) { @@ -44,7 +54,7 @@ export class ERC4337EthersSigner extends Signer { return transactionResponse } - unwrapError (errorIn: any): Error { + unwrapError(errorIn: any): Error { if (errorIn.body != null) { const errorBody = JSON.parse(errorIn.body) let paymasterInfo: string = '' @@ -58,14 +68,18 @@ export class ERC4337EthersSigner extends Signer { failedOpMessage = split[2] } } - const error = new Error(`The bundler has failed to include UserOperation in a batch: ${failedOpMessage} ${paymasterInfo})`) + const error = new Error( + `The bundler has failed to include UserOperation in a batch: ${failedOpMessage} ${paymasterInfo})` + ) error.stack = errorIn.stack return error } return errorIn } - async verifyAllNecessaryFields (transactionRequest: TransactionRequest): Promise { + async verifyAllNecessaryFields( + transactionRequest: TransactionRequest + ): Promise { if (transactionRequest.to == null) { throw new Error('Missing call target') } @@ -75,27 +89,29 @@ export class ERC4337EthersSigner extends Signer { } } - connect (provider: Provider): Signer { + connect(provider: Provider): Signer { throw new Error('changing providers is not supported') } - async getAddress (): Promise { + async getAddress(): Promise { if (this.address == null) { - this.address = await this.erc4337provider.getSenderWalletAddress() + this.address = await this.erc4337provider.getSenderAccountAddress() } return this.address } - async signMessage (message: Bytes | string): Promise { + async signMessage(message: Bytes | string): Promise { return await this.originalSigner.signMessage(message) } - async signTransaction (transaction: Deferrable): Promise { + async signTransaction( + transaction: Deferrable + ): Promise { throw new Error('not implemented') } - async signUserOperation (userOperation: UserOperationStruct): Promise { - const message = await this.smartWalletAPI.getUserOpHash(userOperation) + async signUserOperation(userOperation: UserOperationStruct): Promise { + const message = await this.smartAccountAPI.getUserOpHash(userOperation) return await this.originalSigner.signMessage(message) } } diff --git a/packages/boba/bundler_sdk/src/HttpRpcClient.ts b/packages/boba/bundler_sdk/src/HttpRpcClient.ts index d1932754c8..e8b91d0d36 100644 --- a/packages/boba/bundler_sdk/src/HttpRpcClient.ts +++ b/packages/boba/bundler_sdk/src/HttpRpcClient.ts @@ -1,8 +1,9 @@ import { JsonRpcProvider } from '@ethersproject/providers' import { ethers } from 'ethers' -import { hexValue, resolveProperties } from 'ethers/lib/utils' +import { resolveProperties } from 'ethers/lib/utils' import { UserOperationStruct } from '@boba/accountabstraction' import Debug from 'debug' +import { deepHexlify } from '@boba/bundler_utils' const debug = Debug('aa.rpc') @@ -35,34 +36,30 @@ export class HttpRpcClient { /** * send a UserOperation to the bundler * @param userOp1 - * @return requestId the id of this operation, for getUserOperationTransaction + * @return userOpHash the id of this operation, for getUserOperationTransaction */ async sendUserOpToBundler (userOp1: UserOperationStruct): Promise { await this.initializing - const userOp = await resolveProperties(userOp1) - const hexifiedUserOp: any = - Object.keys(userOp) - .map(key => { - let val = (userOp as any)[key] - if (typeof val !== 'string' || !val.startsWith('0x')) { - val = hexValue(val) - } - return [key, val] - }) - .reduce((set, [k, v]) => ({ - ...set, - [k]: v - }), {}) - + const hexifiedUserOp = deepHexlify(await resolveProperties(userOp1)) const jsonRequestData: [UserOperationStruct, string] = [hexifiedUserOp, this.entryPointAddress] - await this.printUserOperation(jsonRequestData) + + await this.printUserOperation('eth_sendUserOperation', jsonRequestData) return await this.userOpJsonRpcProvider .send('eth_sendUserOperation', [hexifiedUserOp, this.entryPointAddress]) } - private async printUserOperation ([userOp1, entryPointAddress]: [UserOperationStruct, string]): Promise { + async estimateUserOpGas (userOp1: Partial): Promise { + await this.initializing + const hexifiedUserOp = deepHexlify(await resolveProperties(userOp1)) + const jsonRequestData: [UserOperationStruct, string] = [hexifiedUserOp, this.entryPointAddress] + await this.printUserOperation('eth_estimateUserOperationGas', jsonRequestData) + return await this.userOpJsonRpcProvider + .send('eth_estimateUserOperationGas', [hexifiedUserOp, this.entryPointAddress]) + } + + private async printUserOperation (method: string, [userOp1, entryPointAddress]: [UserOperationStruct, string]): Promise { const userOp = await resolveProperties(userOp1) - debug('sending eth_sendUserOperation', { + debug('sending', method, { ...userOp // initCode: (userOp.initCode ?? '').length, // callData: (userOp.callData ?? '').length diff --git a/packages/boba/bundler_sdk/src/Provider.ts b/packages/boba/bundler_sdk/src/Provider.ts index 548e7d0ed6..9925040e95 100644 --- a/packages/boba/bundler_sdk/src/Provider.ts +++ b/packages/boba/bundler_sdk/src/Provider.ts @@ -1,10 +1,8 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ import { JsonRpcProvider } from '@ethersproject/providers' import { Wallet } from 'ethers' -import { - EntryPoint__factory, - SimpleAccountDeployer__factory, -} from '@boba/accountabstraction' +import { EntryPoint__factory, SimpleAccountFactory__factory } from '@boba/accountabstraction' import { ClientConfig } from './ClientConfig' import { SimpleAccountAPI } from './SimpleAccountAPI' @@ -18,50 +16,40 @@ const debug = Debug('aa.wrapProvider') /** * wrap an existing provider to tunnel requests through Account Abstraction. - * * @param originalProvider the normal provider * @param config see ClientConfig for more info * @param originalSigner use this signer as the owner. of this wallet. By default, use the provider's signer * @param wallet optional, boba does not allow eth_sendTransaction from a remote signer, if on boba pass wallet * @param senderCreatorAddress optional, boba does not return revert data for custom errors, if on boba pass a senderCreator to compute account address */ -export async function wrapProvider( +export async function wrapProvider ( originalProvider: JsonRpcProvider, config: ClientConfig, originalSigner: Signer = originalProvider.getSigner(), wallet?: Wallet, senderCreatorAddress?: string ): Promise { - const entryPoint = EntryPoint__factory.connect( - config.entryPointAddress, - originalProvider - ) + const entryPoint = EntryPoint__factory.connect(config.entryPointAddress, originalProvider) // Initial SimpleAccount instance is not deployed and exists just for the interface const detDeployer = new DeterministicDeployer(originalProvider, wallet) - const simpleWalletDeployer = await detDeployer.deterministicDeploy( - SimpleAccountDeployer__factory.bytecode - ) + const SimpleAccountFactory = await detDeployer.deterministicDeploy(new SimpleAccountFactory__factory(), 0, [entryPoint.address]) let smartWalletAPIOwner - if (wallet != null) { - smartWalletAPIOwner = wallet - } else { - smartWalletAPIOwner = originalSigner - } - const smartWalletAPI = new SimpleAccountAPI({ + if (wallet != null) { + smartWalletAPIOwner = wallet + } else { + smartWalletAPIOwner = originalSigner + } + const smartAccountAPI = new SimpleAccountAPI({ provider: originalProvider, entryPointAddress: entryPoint.address, - senderCreatorAddress: senderCreatorAddress, owner: smartWalletAPIOwner, - factoryAddress: simpleWalletDeployer, - paymasterAPI: config.paymasterAPI, + senderCreatorAddress: senderCreatorAddress, + factoryAddress: SimpleAccountFactory, + paymasterAPI: config.paymasterAPI }) debug('config=', config) - const chainId = await originalProvider.getNetwork().then((net) => net.chainId) - const httpRpcClient = new HttpRpcClient( - config.bundlerUrl, - config.entryPointAddress, - chainId - ) + const chainId = await originalProvider.getNetwork().then(net => net.chainId) + const httpRpcClient = new HttpRpcClient(config.bundlerUrl, config.entryPointAddress, chainId) return await new ERC4337EthersProvider( chainId, config, @@ -69,6 +57,6 @@ export async function wrapProvider( originalProvider, httpRpcClient, entryPoint, - smartWalletAPI + smartAccountAPI ).init() } diff --git a/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts b/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts index 4ec4c8f0a3..2c6cccb668 100644 --- a/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts +++ b/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts @@ -1,18 +1,19 @@ import { BigNumber, BigNumberish } from 'ethers' import { SimpleAccount, - SimpleAccount__factory, SimpleAccountDeployer, - SimpleAccountDeployer__factory + SimpleAccount__factory, SimpleAccountFactory, + SimpleAccountFactory__factory } from '@boba/accountabstraction' + import { arrayify, hexConcat } from 'ethers/lib/utils' import { Signer } from '@ethersproject/abstract-signer' -import { BaseApiParams, BaseWalletAPI } from './BaseWalletAPI' +import { BaseApiParams, BaseAccountAPI } from './BaseAccountAPI' /** * constructor params, added no top of base params: - * @param owner the signer object for the wallet owner - * @param factoryAddress address of contract "factory" to deploy new contracts (not needed if wallet already deployed) - * @param index nonce value used when creating multiple wallets for the same owner + * @param owner the signer object for the account owner + * @param factoryAddress address of contract "factory" to deploy new contracts (not needed if account already deployed) + * @param index nonce value used when creating multiple accounts for the same owner */ export interface SimpleAccountApiParams extends BaseApiParams { owner: Signer @@ -22,24 +23,24 @@ export interface SimpleAccountApiParams extends BaseApiParams { } /** - * An implementation of the BaseWalletAPI using the SimpleAccount contract. + * An implementation of the BaseAccountAPI using the SimpleAccount contract. * - contract deployer gets "entrypoint", "owner" addresses and "index" nonce * - owner signs requests using normal "Ethereum Signed Message" (ether's signer.signMessage()) * - nonce method is "nonce()" * - execute method is "execFromEntryPoint()" */ -export class SimpleAccountAPI extends BaseWalletAPI { +export class SimpleAccountAPI extends BaseAccountAPI { factoryAddress?: string owner: Signer index: number /** - * our wallet contract. + * our account contract. * should support the "execFromEntryPoint" and "nonce" methods */ - walletContract?: SimpleAccount + accountContract?: SimpleAccount - factory?: SimpleAccountDeployer + factory?: SimpleAccountFactory constructor (params: SimpleAccountApiParams) { super(params) @@ -48,37 +49,37 @@ export class SimpleAccountAPI extends BaseWalletAPI { this.index = params.index ?? 0 } - async _getWalletContract (): Promise { - if (this.walletContract == null) { - this.walletContract = SimpleAccount__factory.connect(await this.getWalletAddress(), this.provider) + async _getAccountContract (): Promise { + if (this.accountContract == null) { + this.accountContract = SimpleAccount__factory.connect(await this.getAccountAddress(), this.provider) } - return this.walletContract + return this.accountContract } /** - * return the value to put into the "initCode" field, if the wallet is not yet deployed. - * this value holds the "factory" address, followed by this wallet's information + * return the value to put into the "initCode" field, if the account is not yet deployed. + * this value holds the "factory" address, followed by this account's information */ - async getWalletInitCode (): Promise { + async getAccountInitCode (): Promise { if (this.factory == null) { if (this.factoryAddress != null && this.factoryAddress !== '') { - this.factory = SimpleAccountDeployer__factory.connect(this.factoryAddress, this.provider) + this.factory = SimpleAccountFactory__factory.connect(this.factoryAddress, this.provider) } else { throw new Error('no factory to get initCode') } } return hexConcat([ this.factory.address, - this.factory.interface.encodeFunctionData('deployAccount', [this.entryPointAddress, await this.owner.getAddress(), this.index]) + this.factory.interface.encodeFunctionData('createAccount', [await this.owner.getAddress(), this.index]) ]) } async getNonce (): Promise { - if (await this.checkWalletPhantom()) { + if (await this.checkAccountPhantom()) { return BigNumber.from(0) } - const walletContract = await this._getWalletContract() - return await walletContract.nonce() + const accountContract = await this._getAccountContract() + return await accountContract.nonce() } /** @@ -88,9 +89,9 @@ export class SimpleAccountAPI extends BaseWalletAPI { * @param data */ async encodeExecute (target: string, value: BigNumberish, data: string): Promise { - const walletContract = await this._getWalletContract() - return walletContract.interface.encodeFunctionData( - 'execFromEntryPoint', + const accountContract = await this._getAccountContract() + return accountContract.interface.encodeFunctionData( + 'execute', [ target, value, diff --git a/packages/boba/bundler_sdk/src/calcPreVerificationGas.ts b/packages/boba/bundler_sdk/src/calcPreVerificationGas.ts index d4abd76c15..4ee996afdd 100644 --- a/packages/boba/bundler_sdk/src/calcPreVerificationGas.ts +++ b/packages/boba/bundler_sdk/src/calcPreVerificationGas.ts @@ -1,3 +1,4 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ import { UserOperationStruct } from '@boba/accountabstraction' import { NotPromise, packUserOp } from '@boba/bundler_utils' import { arrayify, hexlify } from 'ethers/lib/utils' @@ -48,35 +49,41 @@ export const DefaultGasOverheads: GasOverheads = { zeroByte: 4, nonZeroByte: 16, bundleSize: 1, - sigSize: 65 + sigSize: 65, } /** * calculate the preVerificationGas of the given UserOperation * preVerificationGas (by definition) is the cost overhead that can't be calculated on-chain. * it is based on parameters that are defined by the Ethereum protocol for external transactions. + * * @param userOp filled userOp to calculate. The only possible missing fields can be the signature and preVerificationGas itself * @param overheads gas overheads to use, to override the default values */ -export function calcPreVerificationGas (userOp: Partial>, overheads?: Partial): number { +export function calcPreVerificationGas( + userOp: Partial>, + overheads?: Partial +): number { const ov = { ...DefaultGasOverheads, ...(overheads ?? {}) } const p: NotPromise = { // dummy values, in case the UserOp is incomplete. preVerificationGas: 21000, // dummy value, just for calldata cost signature: hexlify(Buffer.alloc(ov.sigSize, 1)), // dummy signature - ...userOp + ...userOp, } as any - if (p.signature === '') { p.signature = hexlify(Buffer.alloc(ov.sigSize, 1)) } const packed = arrayify(packUserOp(p, false)) - const callDataCost = packed.map(x => x === 0 ? ov.zeroByte : ov.nonZeroByte).reduce((sum, x) => sum + x) + const lengthInWord = (packed.length + 31) / 32 + const callDataCost = packed + .map((x) => (x === 0 ? ov.zeroByte : ov.nonZeroByte)) + .reduce((sum, x) => sum + x) const ret = Math.round( callDataCost + - ov.fixed / ov.bundleSize + - ov.perUserOp + - ov.perUserOpWord * packed.length + ov.fixed / ov.bundleSize + + ov.perUserOp + + ov.perUserOpWord * lengthInWord ) return ret } diff --git a/packages/boba/bundler_sdk/src/index.ts b/packages/boba/bundler_sdk/src/index.ts index a5a94c3686..ff4aa6e1bd 100644 --- a/packages/boba/bundler_sdk/src/index.ts +++ b/packages/boba/bundler_sdk/src/index.ts @@ -4,3 +4,6 @@ export { wrapProvider } from './Provider' export { ERC4337EthersSigner } from './ERC4337EthersSigner' export { ERC4337EthersProvider } from './ERC4337EthersProvider' export { ClientConfig } from './ClientConfig' +export { HttpRpcClient } from './HttpRpcClient' +export { DeterministicDeployer } from './DeterministicDeployer' +export * from './calcPreVerificationGas' diff --git a/packages/boba/bundler_sdk/test/0-deterministicDeployer.test.ts b/packages/boba/bundler_sdk/test/0-deterministicDeployer.test.ts index f383d2954d..d714ae8d95 100644 --- a/packages/boba/bundler_sdk/test/0-deterministicDeployer.test.ts +++ b/packages/boba/bundler_sdk/test/0-deterministicDeployer.test.ts @@ -9,12 +9,9 @@ const deployer = new DeterministicDeployer(ethers.provider) describe('#deterministicDeployer', () => { it('deploy deployer', async () => { expect(await deployer.isDeployerDeployed()).to.equal(false) - await deployer.deployDeployer() + await deployer.deployFactory() expect(await deployer.isDeployerDeployed()).to.equal(true) }) - it('should ignore deploy again of deployer', async () => { - await deployer.deployDeployer() - }) it('should deploy at given address', async () => { const ctr = hexValue(new SampleRecipient__factory(ethers.provider.getSigner()).getDeployTransaction().data!) DeterministicDeployer.init(ethers.provider) diff --git a/packages/boba/bundler_sdk/test/1-SimpleAccountAPI.test.ts b/packages/boba/bundler_sdk/test/1-SimpleAccountAPI.test.ts index 4bf43c807b..838833bef9 100644 --- a/packages/boba/bundler_sdk/test/1-SimpleAccountAPI.test.ts +++ b/packages/boba/bundler_sdk/test/1-SimpleAccountAPI.test.ts @@ -1,7 +1,7 @@ import { EntryPoint, EntryPoint__factory, - SimpleAccountDeployer__factory, + SimpleAccountFactory__factory, UserOperationStruct } from '@boba/accountabstraction' import { Wallet } from 'ethers' @@ -23,8 +23,8 @@ describe('SimpleAccountAPI', () => { let entryPoint: EntryPoint let beneficiary: string let recipient: SampleRecipient - let walletAddress: string - let walletDeployed = false + let accountAddress: string + let accountDeployed = false before('init', async () => { entryPoint = await new EntryPoint__factory(signer).deploy() @@ -32,7 +32,8 @@ describe('SimpleAccountAPI', () => { recipient = await new SampleRecipient__factory(signer).deploy() owner = Wallet.createRandom() - const factoryAddress = await DeterministicDeployer.deploy(SimpleAccountDeployer__factory.bytecode) + DeterministicDeployer.init(ethers.provider) + const factoryAddress = await DeterministicDeployer.deploy(new SimpleAccountFactory__factory(), 0, [entryPoint.address]) api = new SimpleAccountAPI({ provider, entryPointAddress: entryPoint.address, @@ -61,11 +62,11 @@ describe('SimpleAccountAPI', () => { }) it('should deploy to counterfactual address', async () => { - walletAddress = await api.getWalletAddress() - expect(await provider.getCode(walletAddress).then(code => code.length)).to.equal(2) + accountAddress = await api.getAccountAddress() + expect(await provider.getCode(accountAddress).then(code => code.length)).to.equal(2) await signer.sendTransaction({ - to: walletAddress, + to: accountAddress, value: parseEther('0.1') }) const op = await api.createSignedUserOp({ @@ -74,9 +75,9 @@ describe('SimpleAccountAPI', () => { }) await expect(entryPoint.handleOps([op], beneficiary)).to.emit(recipient, 'Sender') - .withArgs(anyValue, walletAddress, 'hello') - expect(await provider.getCode(walletAddress).then(code => code.length)).to.greaterThan(1000) - walletDeployed = true + .withArgs(anyValue, accountAddress, 'hello') + expect(await provider.getCode(accountAddress).then(code => code.length)).to.greaterThan(1000) + accountDeployed = true }) context('#rethrowError', () => { @@ -93,7 +94,7 @@ describe('SimpleAccountAPI', () => { await expect( entryPoint.handleOps([userOp], beneficiary) .catch(rethrowError)) - .to.revertedWith('FailedOp: ECDSA: invalid signature length') + .to.revertedWith('FailedOp: AA23 reverted: ECDSA: invalid signature length') }) it('should parse Error(message) error', async () => { await expect( @@ -109,14 +110,14 @@ describe('SimpleAccountAPI', () => { }) }) - it('should use wallet API after creation without a factory', async function () { - if (!walletDeployed) { + it('should use account API after creation without a factory', async function () { + if (!accountDeployed) { this.skip() } const api1 = new SimpleAccountAPI({ provider, entryPointAddress: entryPoint.address, - walletAddress, + accountAddress, owner }) const op1 = await api1.createSignedUserOp({ @@ -124,6 +125,6 @@ describe('SimpleAccountAPI', () => { data: recipient.interface.encodeFunctionData('something', ['world']) }) await expect(entryPoint.handleOps([op1], beneficiary)).to.emit(recipient, 'Sender') - .withArgs(anyValue, walletAddress, 'world') + .withArgs(anyValue, accountAddress, 'world') }) }) diff --git a/packages/boba/bundler_sdk/test/3-ERC4337EthersSigner.test.ts b/packages/boba/bundler_sdk/test/3-ERC4337EthersSigner.test.ts index 3bb9902b33..9315eaee19 100644 --- a/packages/boba/bundler_sdk/test/3-ERC4337EthersSigner.test.ts +++ b/packages/boba/bundler_sdk/test/3-ERC4337EthersSigner.test.ts @@ -6,7 +6,6 @@ import { expect } from 'chai' import { parseEther } from 'ethers/lib/utils' import { Wallet } from 'ethers' import { anyValue } from '@nomicfoundation/hardhat-chai-matchers/withArgs' - const provider = ethers.provider const signer = provider.getSigner() @@ -47,19 +46,19 @@ describe('ERC4337EthersSigner, Provider', function () { await recipient.something('hello', { gasLimit: 1e6 }) throw new Error('should revert') } catch (e: any) { - expect(e.message).to.eq('FailedOp(0,0x0000000000000000000000000000000000000000,account didn\'t pay prefund)') + expect(e.message).to.eq('FailedOp(0,AA21 didn\'t pay prefund)') } }) it('should use ERC-4337 Signer and Provider to send the UserOperation to the bundler', async function () { - const walletAddress = await aaProvider.getSigner().getAddress() + const accountAddress = await aaProvider.getSigner().getAddress() await signer.sendTransaction({ - to: walletAddress, + to: accountAddress, value: parseEther('0.1') }) const ret = await recipient.something('hello') await expect(ret).to.emit(recipient, 'Sender') - .withArgs(anyValue, walletAddress, 'hello') + .withArgs(anyValue, accountAddress, 'hello') }) it('should revert if on-chain userOp execution reverts', async function () { diff --git a/packages/boba/bundler_sdk/tsconfig.json b/packages/boba/bundler_sdk/tsconfig.json index 5b03befcaf..e5e87c458c 100644 --- a/packages/boba/bundler_sdk/tsconfig.json +++ b/packages/boba/bundler_sdk/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../../tsconfig.json", "compilerOptions": { + "skipLibCheck": true, + "rootDir": "./src", "outDir": "./dist", "types": [ "node" diff --git a/packages/boba/bundler_sdk/tsconfig.packages.json b/packages/boba/bundler_sdk/tsconfig.packages.json deleted file mode 100644 index 3ff24b05b8..0000000000 --- a/packages/boba/bundler_sdk/tsconfig.packages.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "es2017", - "module": "commonjs", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "outDir": "dist", - "strict": true, - "composite": true, - "allowJs": true, - "resolveJsonModule": true, - "moduleResolution": "node", - "noImplicitThis": true, - "alwaysStrict": true, - "declaration": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "sourceMap": true - } -} diff --git a/packages/boba/bundler_utils/package.json b/packages/boba/bundler_utils/package.json index 4d19b93cd1..8fe072a4b3 100644 --- a/packages/boba/bundler_utils/package.json +++ b/packages/boba/bundler_utils/package.json @@ -1,7 +1,7 @@ { "name": "@boba/bundler_utils", "version": "0.2.3", - "main": "./dist/src/index.js", + "main": "./dist/index.js", "license": "MIT", "types": "dist/index", "private": true, @@ -11,7 +11,7 @@ "README.md" ], "scripts": { - "build:stopp": "tsc -p ./tsconfig.json && hardhat compile", + "build": "tsc -p ./tsconfig.json && hardhat compile", "clean": "rimraf node_modules cache artifacts dist/ ./tsconfig.tsbuildinfo", "hardhat-compile": "hardhat compile", "lint-fix": "eslint -f unix . --fix", diff --git a/packages/boba/bundler_utils/src/ERC4337Utils.ts b/packages/boba/bundler_utils/src/ERC4337Utils.ts index 6e10d1e4a2..0432c5b688 100644 --- a/packages/boba/bundler_utils/src/ERC4337Utils.ts +++ b/packages/boba/bundler_utils/src/ERC4337Utils.ts @@ -1,12 +1,19 @@ -import { defaultAbiCoder, hexConcat, keccak256 } from 'ethers/lib/utils' +import { defaultAbiCoder, hexConcat, hexlify, keccak256, resolveProperties } from 'ethers/lib/utils' import { UserOperationStruct } from '@boba/accountabstraction' import { abi as entryPointAbi } from '@boba/accountabstraction/artifacts/contracts/interfaces/IEntryPoint.sol/IEntryPoint.json' import { ethers } from 'ethers' +import Debug from 'debug' -export const AddressZero = ethers.constants.AddressZero +const debug = Debug('aa.utils') + +// UserOperation is the first parameter of validateUseOp +const validateUserOpMethod = 'simulateValidation' +const UserOpType = entryPointAbi.find(entry => entry.name === validateUserOpMethod)?.inputs[0] +if (UserOpType == null) { + throw new Error(`unable to find method ${validateUserOpMethod} in EP ${entryPointAbi.filter(x => x.type === 'function').map(x => x.name).join(',')}`) +} -// UserOperation is the first parameter of simulateValidation -const UserOpType = entryPointAbi.find(entry => entry.name === 'simulateValidation')?.inputs[0] +export const AddressZero = ethers.constants.AddressZero // reverse "Deferrable" or "PromiseOrValue" fields export type NotPromise = { @@ -88,61 +95,11 @@ export function packUserOp (op: NotPromise, forSignature = encoded = '0x' + encoded.slice(66, encoded.length - 64) return encoded } + const typevalues = (UserOpType as any).components.map((c: { name: keyof typeof op, type: string }) => ({ type: c.type, val: op[c.name] })) - // 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 - // } - // ] - // console.log('hard-coded typedvalues', typevalues) - // console.log('from ABI typedValues', typedValues) - if (!forSignature) { - // for the purpose of calculating gas cost, also hash signature - typevalues.push({ - type: 'bytes', - val: op.signature - }) - } return encode(typevalues, forSignature) } @@ -164,34 +121,27 @@ export function getUserOpHash (op: NotPromise, entryPoint: } const ErrorSig = keccak256(Buffer.from('Error(string)')).slice(0, 10) // 0x08c379a0 -const FailedOpSig = keccak256(Buffer.from('FailedOp(uint256,address,string)')).slice(0, 10) // 0x00fa072b +const FailedOpSig = keccak256(Buffer.from('FailedOp(uint256,string)')).slice(0, 10) // 0x220266b6 interface DecodedError { message: string opIndex?: number - paymaster?: string } /** * decode bytes thrown by revert as Error(message) or FailedOp(opIndex,paymaster,message) */ export function decodeErrorReason (error: string): DecodedError | undefined { - // console.log('decoding', error) + debug('decoding', error) if (error.startsWith(ErrorSig)) { const [message] = defaultAbiCoder.decode(['string'], '0x' + error.substring(10)) return { message } } else if (error.startsWith(FailedOpSig)) { - let [opIndex, paymaster, message] = defaultAbiCoder.decode(['uint256', 'address', 'string'], '0x' + error.substring(10)) + let [opIndex, message] = defaultAbiCoder.decode(['uint256', 'string'], '0x' + error.substring(10)) message = `FailedOp: ${message as string}` - if (paymaster.toString() !== ethers.constants.AddressZero) { - message = `${message as string} (paymaster ${paymaster as string})` - } else { - paymaster = undefined - } return { message, - opIndex, - paymaster + opIndex } } } @@ -225,3 +175,32 @@ export function rethrowError (e: any): any { } throw e } + +/** + * hexlify all members of object, recursively + * @param obj + */ +export function deepHexlify (obj: any): any { + if (typeof obj === 'function') { + return undefined + } + if (obj == null || typeof obj === 'string' || typeof obj === 'boolean') { + return obj + } else if (obj._isBigNumber != null || typeof obj !== 'object') { + return hexlify(obj).replace(/^0x0/, '0x') + } + if (Array.isArray(obj)) { + return obj.map(member => deepHexlify(member)) + } + return Object.keys(obj) + .reduce((set, key) => ({ + ...set, + [key]: deepHexlify(obj[key]) + }), {}) +} + +// resolve all property and hexlify. +// (UserOpMethodHandler receives data from the network, so we need to pack our generated values) +export async function resolveHexlify (a: any): Promise { + return deepHexlify(await resolveProperties(a)) +} diff --git a/packages/boba/bundler_utils/src/Version.ts b/packages/boba/bundler_utils/src/Version.ts index 51aa3dc55e..c132f65517 100644 --- a/packages/boba/bundler_utils/src/Version.ts +++ b/packages/boba/bundler_utils/src/Version.ts @@ -1,2 +1,2 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires -export const erc4337RuntimeVersion: string = require('../../package.json').version +export const erc4337RuntimeVersion: string = require('../package.json').version diff --git a/packages/boba/bundler_utils/src/postExecCheck.ts b/packages/boba/bundler_utils/src/postExecCheck.ts index ac0363a4b0..de855db436 100644 --- a/packages/boba/bundler_utils/src/postExecCheck.ts +++ b/packages/boba/bundler_utils/src/postExecCheck.ts @@ -1,12 +1,33 @@ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ import { resolveProperties } from 'ethers/lib/utils' import { NotPromise } from './ERC4337Utils' import { EntryPoint, UserOperationStruct } from '@boba/accountabstraction' +import Debug from 'debug' -export async function postExecutionDump (entryPoint: EntryPoint, requestId: string): Promise { - const { gasPaid, gasUsed, success, userOp } = await postExecutionCheck(entryPoint, requestId) +const debug = Debug('aa.postExec') + +export async function postExecutionDump( + entryPoint: EntryPoint, + userOpHash: string +): Promise { + const { gasPaid, gasUsed, success, userOp } = await postExecutionCheck( + entryPoint, + userOpHash + ) /// / debug dump: - console.log('==== used=', gasUsed, 'paid', gasPaid, 'over=', gasPaid - gasUsed, - 'callLen=', userOp.callData.length, 'initLen=', userOp.initCode.length, success ? 'success' : 'failed') + debug( + '==== used=', + gasUsed, + 'paid', + gasPaid, + 'over=', + gasPaid - gasUsed, + 'callLen=', + userOp?.callData?.length, + 'initLen=', + userOp?.initCode?.length, + success ? 'success' : 'failed' + ) } /** @@ -14,18 +35,24 @@ export async function postExecutionDump (entryPoint: EntryPoint, requestId: stri * (the only field that EntryPoint can check is the preVerificationGas. * There is no "view-mode" way to determine the actual gas cost of a given transaction, * so we must do it after mining it. + * * @param entryPoint - * @param requestId + * @param userOpHash */ -export async function postExecutionCheck (entryPoint: EntryPoint, requestId: string): Promise<{ +export async function postExecutionCheck( + entryPoint: EntryPoint, + userOpHash: string +): Promise<{ gasUsed: number gasPaid: number success: boolean userOp: NotPromise }> { - const req = await entryPoint.queryFilter(entryPoint.filters.UserOperationEvent(requestId)) + const req = await entryPoint.queryFilter( + entryPoint.filters.UserOperationEvent(userOpHash) + ) if (req.length === 0) { - console.log('postExecutionCheck: failed to read event (not mined)') + debug('postExecutionCheck: failed to read event (not mined)') // @ts-ignore return { gasUsed: 0, gasPaid: 0, success: false, userOp: {} } } @@ -34,17 +61,13 @@ export async function postExecutionCheck (entryPoint: EntryPoint, requestId: str const tx = await req[0].getTransaction() const { ops } = entryPoint.interface.decodeFunctionData('handleOps', tx.data) const userOp = await resolveProperties(ops[0] as UserOperationStruct) - const { - actualGasPrice, - actualGasCost, - success - } = req[0].args - const gasPaid = actualGasCost.div(actualGasPrice).toNumber() + const { actualGasUsed, success } = req[0].args + const gasPaid = actualGasUsed.toNumber() const gasUsed = transactionReceipt.gasUsed.toNumber() return { gasUsed, gasPaid, success, - userOp + userOp, } } diff --git a/packages/boba/gateway/package.json b/packages/boba/gateway/package.json index 164ef7c6ea..8e822b7fa6 100644 --- a/packages/boba/gateway/package.json +++ b/packages/boba/gateway/package.json @@ -1,6 +1,6 @@ { "name": "@boba/gateway", - "version": "0.0.3", + "version": "1.0.0", "private": true, "scripts": { "buildenv-dev": "react-env", diff --git a/packages/boba/teleportation/test/teleportation.spec.ts b/packages/boba/teleportation/test/teleportation.spec.ts index 62e08b6657..5393da8729 100644 --- a/packages/boba/teleportation/test/teleportation.spec.ts +++ b/packages/boba/teleportation/test/teleportation.spec.ts @@ -22,7 +22,7 @@ import L1ERC20Json from '@boba/contracts/artifacts/contracts/test-helpers/L1ERC2 import { ChainInfo } from '../src/utils/types' /* Imports: Core */ -import { TeleportationService } from '../dist/src/service' +import { TeleportationService } from '../dist/service' describe('teleportation', () => { let signer: Signer diff --git a/tsconfig.json b/tsconfig.json index 7561201839..e56366fb1e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "target": "es2017", "sourceMap": true, "esModuleInterop": true, - "composite": true, + "resolveJsonModule": true, "declaration": true, "allowJs": true, From c9c7d0dfcc2e98424b5de9ebf6a72d2de1056edc Mon Sep 17 00:00:00 2001 From: Souradeep Das Date: Wed, 3 May 2023 19:18:12 +0530 Subject: [PATCH 2/9] fix: qsp30 (#773) fix: BOB1-30 (cherry picked from commit 7feda887c72d82efa65abb54737986cd16965bdd) --- integration-tests/test/alt-l2/nft_bridge.spec.ts | 12 ++++++++++++ integration-tests/test/eth-l2/nft_bridge.spec.ts | 12 ++++++++++++ .../contracts/ERC721Bridges/L1NFTBridge.sol | 2 ++ .../contracts/ERC721Bridges/L2NFTBridge.sol | 2 ++ .../contracts/ERC721Bridges/L2NFTBridgeAltL1.sol | 2 ++ 5 files changed, 30 insertions(+) diff --git a/integration-tests/test/alt-l2/nft_bridge.spec.ts b/integration-tests/test/alt-l2/nft_bridge.spec.ts index 065abbb482..300a683d1c 100644 --- a/integration-tests/test/alt-l2/nft_bridge.spec.ts +++ b/integration-tests/test/alt-l2/nft_bridge.spec.ts @@ -95,8 +95,11 @@ describe('NFT Bridge Test', async () => { const ownerL1 = await L1ERC721.ownerOf(DUMMY_TOKEN_ID) const ownerL2 = await L2ERC721.ownerOf(DUMMY_TOKEN_ID) + const depositMap = await L1Bridge.deposits(L1ERC721.address, DUMMY_TOKEN_ID) + expect(ownerL1).to.deep.eq(L1Bridge.address) expect(ownerL2).to.deep.eq(env.l2Wallet.address) + expect(depositMap).to.deep.eq(L2ERC721.address) }) it('should be able to transfer NFT on L2', async () => { @@ -155,6 +158,9 @@ describe('NFT Bridge Test', async () => { const ownerL1 = await L1ERC721.ownerOf(DUMMY_TOKEN_ID) expect(ownerL1).to.be.deep.eq(env.l2Wallet_2.address) + + const depositMap = await L1Bridge.deposits(L1ERC721.address, DUMMY_TOKEN_ID) + expect(depositMap).to.deep.eq(ethers.constants.AddressZero) }) it('should deposit NFT to another L2 wallet', async () => { @@ -410,8 +416,11 @@ describe('NFT Bridge Test', async () => { const ownerL1 = await L1ERC721.ownerOf(DUMMY_TOKEN_ID) const ownerL2 = await L2ERC721.ownerOf(DUMMY_TOKEN_ID) + const exitsMap = await L2Bridge.exits(L2ERC721.address, DUMMY_TOKEN_ID) + expect(ownerL1).to.deep.eq(env.l2Wallet.address) expect(ownerL2).to.deep.eq(L2Bridge.address) + expect(exitsMap).to.deep.eq(L1ERC721.address) }) it('should be able to transfer NFT on L1', async () => { @@ -462,6 +471,9 @@ describe('NFT Bridge Test', async () => { const ownerL2 = await L2ERC721.ownerOf(DUMMY_TOKEN_ID) expect(ownerL2).to.deep.eq(env.l2Wallet.address) + + const exitsMap = await L2Bridge.exits(L2ERC721.address, DUMMY_TOKEN_ID) + expect(exitsMap).to.deep.eq(ethers.constants.AddressZero) }) it('should fail to exit NFT to another L1 wallet if not paying enough Boba', async () => { diff --git a/integration-tests/test/eth-l2/nft_bridge.spec.ts b/integration-tests/test/eth-l2/nft_bridge.spec.ts index 4847675d50..aa37d8d363 100644 --- a/integration-tests/test/eth-l2/nft_bridge.spec.ts +++ b/integration-tests/test/eth-l2/nft_bridge.spec.ts @@ -102,8 +102,11 @@ describe('NFT Bridge Test', async () => { const ownerL1 = await L1ERC721.ownerOf(DUMMY_TOKEN_ID) const ownerL2 = await L2ERC721.ownerOf(DUMMY_TOKEN_ID) + const depositMap = await L1Bridge.deposits(L1ERC721.address, DUMMY_TOKEN_ID) + expect(ownerL1).to.deep.eq(L1Bridge.address) expect(ownerL2).to.deep.eq(env.l2Wallet.address) + expect(depositMap).to.deep.eq(L2ERC721.address) }) it('should be able to transfer NFT on L2', async () => { @@ -185,6 +188,9 @@ describe('NFT Bridge Test', async () => { const ownerL1 = await L1ERC721.ownerOf(DUMMY_TOKEN_ID) expect(ownerL1).to.be.deep.eq(env.l2Wallet_2.address) + + const depositMap = await L1Bridge.deposits(L1ERC721.address, DUMMY_TOKEN_ID) + expect(depositMap).to.deep.eq(ethers.constants.AddressZero) }) it('should deposit NFT to another L2 wallet', async () => { @@ -518,8 +524,11 @@ describe('NFT Bridge Test', async () => { const ownerL1 = await L1ERC721.ownerOf(DUMMY_TOKEN_ID) const ownerL2 = await L2ERC721.ownerOf(DUMMY_TOKEN_ID) + const exitsMap = await L2Bridge.exits(L2ERC721.address, DUMMY_TOKEN_ID) + expect(ownerL1).to.deep.eq(env.l2Wallet.address) expect(ownerL2).to.deep.eq(L2Bridge.address) + expect(exitsMap).to.deep.eq(L1ERC721.address) }) it('should be able to transfer NFT on L1', async () => { @@ -570,6 +579,9 @@ describe('NFT Bridge Test', async () => { const ownerL2 = await L2ERC721.ownerOf(DUMMY_TOKEN_ID) expect(ownerL2).to.deep.eq(env.l2Wallet.address) + + const exitsMap = await L2Bridge.exits(L2ERC721.address, DUMMY_TOKEN_ID) + expect(exitsMap).to.deep.eq(ethers.constants.AddressZero) }) it('should fail to exit NFT to another L1 wallet if not enough Boba balance', async () => { diff --git a/packages/boba/contracts/contracts/ERC721Bridges/L1NFTBridge.sol b/packages/boba/contracts/contracts/ERC721Bridges/L1NFTBridge.sol index 906d3c1366..a8b053dd4b 100644 --- a/packages/boba/contracts/contracts/ERC721Bridges/L1NFTBridge.sol +++ b/packages/boba/contracts/contracts/ERC721Bridges/L1NFTBridge.sol @@ -432,6 +432,8 @@ contract L1NFTBridge is iL1NFTBridge, CrossDomainEnabled, ERC721Holder, Reentran // When a withdrawal is finalized on L1, the L1 Bridge transfers the funds to the withdrawer IERC721(_l1Contract).safeTransferFrom(address(this), _to, _tokenId); + deposits[_l1Contract][_tokenId] = address(0); + emit NFTWithdrawalFinalized(_l1Contract, _l2Contract, _from, _to, _tokenId, _data); } else { // replyNeeded helps store the status if a message needs to be sent back to the other layer diff --git a/packages/boba/contracts/contracts/ERC721Bridges/L2NFTBridge.sol b/packages/boba/contracts/contracts/ERC721Bridges/L2NFTBridge.sol index bf8208ef42..0eb6b696f6 100644 --- a/packages/boba/contracts/contracts/ERC721Bridges/L2NFTBridge.sol +++ b/packages/boba/contracts/contracts/ERC721Bridges/L2NFTBridge.sol @@ -545,6 +545,8 @@ contract L2NFTBridge is iL2NFTBridge, CrossDomainEnabled, ERC721Holder, Reentran _to, _tokenId ); + exits[_l2Contract][_tokenId] = address(0); + emit DepositFinalized(_l1Contract, _l2Contract, _from, _to, _tokenId, _data); } } diff --git a/packages/boba/contracts/contracts/ERC721Bridges/L2NFTBridgeAltL1.sol b/packages/boba/contracts/contracts/ERC721Bridges/L2NFTBridgeAltL1.sol index 168e7fca1b..858586131e 100644 --- a/packages/boba/contracts/contracts/ERC721Bridges/L2NFTBridgeAltL1.sol +++ b/packages/boba/contracts/contracts/ERC721Bridges/L2NFTBridgeAltL1.sol @@ -551,6 +551,8 @@ contract L2NFTBridgeAltL1 is iL2NFTBridgeAltL1, CrossDomainEnabled, ERC721Holder _to, _tokenId ); + exits[_l2Contract][_tokenId] = address(0); + emit DepositFinalized(_l1Contract, _l2Contract, _from, _to, _tokenId, _data); } } From 0bc854093cb8384f23a7f7761047696905a89006 Mon Sep 17 00:00:00 2001 From: "Riedl Kevin, Bsc" Date: Wed, 3 May 2023 17:02:03 +0200 Subject: [PATCH 3/9] run op fix (#771) (cherry picked from commit 176cd3cdd15ab15b70258d9447a19bb8c92b0e09) --- packages/boba/account-abstraction/src/runop.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/boba/account-abstraction/src/runop.ts b/packages/boba/account-abstraction/src/runop.ts index cef53d0d2f..80d4e4c005 100644 --- a/packages/boba/account-abstraction/src/runop.ts +++ b/packages/boba/account-abstraction/src/runop.ts @@ -91,9 +91,10 @@ import { Create2Factory } from './Create2Factory' const testCounter = TestCounter__factory.connect(testCounterAddress, aasigner) const prebalance = await provider.getBalance(myAddress) + const testCounterConnected = testCounter.connect(ethersSigner) console.log('balance=', prebalance.div(1e9).toString(), 'deposit=', preDeposit.div(1e9).toString()) - console.log('estimate direct call', { gasUsed: await testCounter.connect(ethersSigner).estimateGas.justemit().then(t => t.toNumber()) }) - const ret = await testCounter.justemit() + console.log('estimate direct call', { gasUsed: await testCounterConnected.estimateGas.justemit().then(t => t.toNumber()) }) + const ret = await testCounterConnected.justemit() console.log('waiting for mine, hash (reqId)=', ret.hash) const rcpt = await ret.wait() const netname = await provider.getNetwork().then(net => net.name) @@ -107,7 +108,7 @@ import { Create2Factory } from './Create2Factory' console.log(logs.map((e: any) => ({ ev: e.event, ...objdump(e.args!) }))) console.log('1st run gas used:', await evInfo(rcpt)) - const ret1 = await testCounter.justemit() + const ret1 = await testCounterConnected.justemit() const rcpt2 = await ret1.wait() console.log('2nd run:', await evInfo(rcpt2)) From 42f6a6e222b7db3be20630416f28a5106c66a2a1 Mon Sep 17 00:00:00 2001 From: "Riedl Kevin, Bsc" Date: Wed, 3 May 2023 17:02:39 +0200 Subject: [PATCH 4/9] close-server (#768) (cherry picked from commit 72021afb6d6ffbe8d0ff88ecb98f6a381c10c635) --- integration-tests/test/alt-l2/turing.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/integration-tests/test/alt-l2/turing.spec.ts b/integration-tests/test/alt-l2/turing.spec.ts index 5f4718bc22..09f8311e57 100644 --- a/integration-tests/test/alt-l2/turing.spec.ts +++ b/integration-tests/test/alt-l2/turing.spec.ts @@ -8,6 +8,7 @@ chai.use(solidity) import { OptimismEnv } from './shared/env' import { verifyStateRoots } from './shared/state-root-verification' +import { Server } from "http"; describe('Turing 256 Bit Random Number Test', async () => { let env: OptimismEnv @@ -23,6 +24,12 @@ describe('Turing 256 Bit Random Number Test', async () => { const apiPort = 5123 let URL: string + let server: Server + + after(async () => { + await server.close(console.error) + }) + before(async () => { env = await OptimismEnv.new() @@ -79,7 +86,7 @@ describe('Turing 256 Bit Random Number Test', async () => { const http = require('http') const ip = require("ip") // start local server - const server = module.exports = http.createServer(async function (req, res) { + server = module.exports = http.createServer(async function (req, res) { if (req.headers['content-type'] === 'application/json') { From a8aa401a5e25d793d39040daee064d0041862c2a Mon Sep 17 00:00:00 2001 From: Souradeep Das Date: Thu, 4 May 2023 18:24:55 +0530 Subject: [PATCH 5/9] [AA]: fix inconsistent userOpHash (#757) * add token callback handler on SimpleAccount * fix: userOpHash packing * prevent recursive calls into handleOps * move nonce validation from individual Account to EntryPoint * add bundler changes for nonce change to EP (cherry picked from commit cc4e205d2b67f83b9f1e290fe94c7de924fa94ed) --- .../contracts/core/BaseAccount.sol | 37 ++++--- .../contracts/core/EntryPoint.sol | 19 +++- .../contracts/core/Helpers.sol | 15 +++ .../contracts/core/NonceManager.sol | 40 +++++++ .../contracts/interfaces/IEntryPoint.sol | 9 +- .../contracts/interfaces/INonceManager.sol | 27 +++++ .../contracts/interfaces/UserOperation.sol | 33 +++--- .../contracts/samples/SimpleAccount.sol | 19 +--- .../samples/callback/TokenCallbackHandler.sol | 61 +++++++++++ .../samples/gnosis/EIP4337Fallback.sol | 8 ++ .../samples/gnosis/EIP4337Manager.sol | 15 ++- .../contracts/test/MaliciousAccount.sol | 5 +- .../account-abstraction/eip/EIPS/eip-4337.md | 50 +++++---- .../account-abstraction/gascalc/GasChecker.ts | 44 ++++++-- .../reports/gas-checker.txt | 36 ++++--- .../boba/account-abstraction/test/UserOp.ts | 93 +++++----------- .../test/entrypoint.test.ts | 100 ++++++++++++++++-- .../account-abstraction/test/gnosis.test.ts | 17 +-- .../test/simple-wallet.test.ts | 35 +++--- .../account-abstraction/test/y.bls.test.ts | 3 +- .../contracts/tests/TestOpcodesAccount.sol | 5 + .../contracts/tests/TestRulesAccount.sol | 9 +- .../boba/bundler/src/UserOpMethodHandler.ts | 8 +- packages/boba/bundler/src/runner/runop.ts | 2 +- .../bundler/test/UserOpMethodHandler.test.ts | 47 +++++--- .../boba/bundler_sdk/src/BaseAccountAPI.ts | 2 +- .../boba/bundler_sdk/src/SimpleAccountAPI.ts | 2 +- .../src/TransactionDetailsForUserOp.ts | 1 + .../boba/bundler_utils/src/ERC4337Utils.ts | 88 +++------------ 29 files changed, 538 insertions(+), 292 deletions(-) create mode 100644 packages/boba/account-abstraction/contracts/core/NonceManager.sol create mode 100644 packages/boba/account-abstraction/contracts/interfaces/INonceManager.sol create mode 100644 packages/boba/account-abstraction/contracts/samples/callback/TokenCallbackHandler.sol diff --git a/packages/boba/account-abstraction/contracts/core/BaseAccount.sol b/packages/boba/account-abstraction/contracts/core/BaseAccount.sol index 6c06e9184e..95b2639914 100644 --- a/packages/boba/account-abstraction/contracts/core/BaseAccount.sol +++ b/packages/boba/account-abstraction/contracts/core/BaseAccount.sol @@ -2,8 +2,7 @@ pragma solidity ^0.8.12; /* solhint-disable avoid-low-level-calls */ -/* solhint-disable no-inline-assembly */ -/* solhint-disable reason-string */ +/* solhint-disable no-empty-blocks */ import "../interfaces/IAccount.sol"; import "../interfaces/IEntryPoint.sol"; @@ -22,10 +21,13 @@ abstract contract BaseAccount is IAccount { uint256 constant internal SIG_VALIDATION_FAILED = 1; /** - * return the account nonce. - * subclass should return a nonce value that is used both by _validateAndUpdateNonce, and by the external provider (to read the current nonce) + * Return the account nonce. + * This method returns the next sequential nonce. + * For a nonce of a specific key, use `entrypoint.getNonce(account, key)` */ - function nonce() public view virtual returns (uint256); + function getNonce() public view virtual returns (uint256) { + return entryPoint().getNonce(address(this), 0); + } /** * return the entryPoint used by this account. @@ -41,9 +43,7 @@ abstract contract BaseAccount is IAccount { external override virtual returns (uint256 validationData) { _requireFromEntryPoint(); validationData = _validateSignature(userOp, userOpHash); - if (userOp.initCode.length == 0) { - _validateAndUpdateNonce(userOp); - } + _validateNonce(userOp.nonce); _payPrefund(missingAccountFunds); } @@ -71,12 +71,23 @@ abstract contract BaseAccount is IAccount { internal virtual returns (uint256 validationData); /** - * validate the current nonce matches the UserOperation nonce. - * then it should update the account's state to prevent replay of this UserOperation. - * called only if initCode is empty (since "nonce" field is used as "salt" on account creation) - * @param userOp the op to validate. + * Validate the nonce of the UserOperation. + * This method may validate the nonce requirement of this account. + * e.g. + * To limit the nonce to use sequenced UserOps only (no "out of order" UserOps): + * `require(nonce < type(uint64).max)` + * For a hypothetical account that *requires* the nonce to be out-of-order: + * `require(nonce & type(uint64).max == 0)` + * + * The actual nonce uniqueness is managed by the EntryPoint, and thus no other + * action is needed by the account itself. + * + * @param nonce to validate + * + * solhint-disable-next-line no-empty-blocks */ - function _validateAndUpdateNonce(UserOperation calldata userOp) internal virtual; + function _validateNonce(uint256 nonce) internal view virtual { + } /** * sends to the entrypoint (msg.sender) the missing funds for this transaction. diff --git a/packages/boba/account-abstraction/contracts/core/EntryPoint.sol b/packages/boba/account-abstraction/contracts/core/EntryPoint.sol index a25fa7d949..b5473a5e68 100644 --- a/packages/boba/account-abstraction/contracts/core/EntryPoint.sol +++ b/packages/boba/account-abstraction/contracts/core/EntryPoint.sol @@ -16,8 +16,10 @@ import "../utils/Exec.sol"; import "./StakeManager.sol"; import "./SenderCreator.sol"; import "./Helpers.sol"; +import "./NonceManager.sol"; +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; -contract EntryPoint is IEntryPoint, StakeManager { +contract EntryPoint is IEntryPoint, StakeManager, NonceManager, ReentrancyGuard { using UserOperationLib for UserOperation; @@ -87,7 +89,7 @@ contract EntryPoint is IEntryPoint, StakeManager { * @param ops the operations to execute * @param beneficiary the address to receive the fees */ - function handleOps(UserOperation[] calldata ops, address payable beneficiary) public { + function handleOps(UserOperation[] calldata ops, address payable beneficiary) public nonReentrant { uint256 opslen = ops.length; UserOpInfo[] memory opInfos = new UserOpInfo[](opslen); @@ -100,6 +102,7 @@ contract EntryPoint is IEntryPoint, StakeManager { } uint256 collected = 0; + emit BeforeExecution(); for (uint256 i = 0; i < opslen; i++) { collected += _executeUserOp(i, ops[i], opInfos[i]); @@ -117,7 +120,7 @@ contract EntryPoint is IEntryPoint, StakeManager { function handleAggregatedOps( UserOpsPerAggregator[] calldata opsPerAggregator, address payable beneficiary - ) public { + ) public nonReentrant { uint256 opasLen = opsPerAggregator.length; uint256 totalOps = 0; @@ -142,6 +145,8 @@ contract EntryPoint is IEntryPoint, StakeManager { UserOpInfo[] memory opInfos = new UserOpInfo[](totalOps); + emit BeforeExecution(); + uint256 opIndex = 0; for (uint256 a = 0; a < opasLen; a++) { UserOpsPerAggregator calldata opa = opsPerAggregator[a]; @@ -349,7 +354,8 @@ contract EntryPoint is IEntryPoint, StakeManager { * @param initCode the constructor code to be passed into the UserOperation. */ function getSenderAddress(bytes calldata initCode) public { - revert SenderAddressResult(senderCreator.createSender(initCode)); + address sender = senderCreator.createSender(initCode); + revert SenderAddressResult(sender); } function _simulationOnlyValidations(UserOperation calldata userOp) internal view { @@ -511,6 +517,11 @@ contract EntryPoint is IEntryPoint, StakeManager { uint256 gasUsedByValidateAccountPrepayment; (uint256 requiredPreFund) = _getRequiredPrefund(mUserOp); (gasUsedByValidateAccountPrepayment, validationData) = _validateAccountPrepayment(opIndex, userOp, outOpInfo, requiredPreFund); + + if (!_validateAndUpdateNonce(mUserOp.sender, mUserOp.nonce)) { + revert FailedOp(opIndex, "AA25 invalid account nonce"); + } + //a "marker" where account opcode validation is done and paymaster opcode validation is about to start // (used only by off-chain simulateValidation) numberMarker(); diff --git a/packages/boba/account-abstraction/contracts/core/Helpers.sol b/packages/boba/account-abstraction/contracts/core/Helpers.sol index d0bb0c9196..ef362bb240 100644 --- a/packages/boba/account-abstraction/contracts/core/Helpers.sol +++ b/packages/boba/account-abstraction/contracts/core/Helpers.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.12; +/* solhint-disable no-inline-assembly */ + /** * returned data from validateUserOp. * validateUserOp returns a uint256, with is created by `_packedValidationData` and parsed by `_parseValidationData` @@ -63,3 +65,16 @@ pragma solidity ^0.8.12; function _packValidationData(bool sigFailed, uint48 validUntil, uint48 validAfter) pure returns (uint256) { return (sigFailed ? 1 : 0) | (uint256(validUntil) << 160) | (uint256(validAfter) << (160 + 48)); } + +/** + * keccak function over calldata. + * @dev copy calldata into memory, do keccak and drop allocated memory. Strangely, this is more efficient than letting solidity do it. + */ + function calldataKeccak(bytes calldata data) pure returns (bytes32 ret) { + assembly { + let mem := mload(0x40) + let len := data.length + calldatacopy(mem, data.offset, len) + ret := keccak256(mem, len) + } + } diff --git a/packages/boba/account-abstraction/contracts/core/NonceManager.sol b/packages/boba/account-abstraction/contracts/core/NonceManager.sol new file mode 100644 index 0000000000..f0bc20dbf4 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/core/NonceManager.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "../interfaces/IEntryPoint.sol"; + +/** + * nonce management functionality + */ +contract NonceManager is INonceManager { + + /** + * The next valid sequence number for a given nonce key. + */ + mapping(address => mapping(uint192 => uint256)) public nonceSequenceNumber; + + function getNonce(address sender, uint192 key) + public view override returns (uint256 nonce) { + return nonceSequenceNumber[sender][key] | (uint256(key) << 64); + } + + // allow an account to manually increment its own nonce. + // (mainly so that during construction nonce can be made non-zero, + // to "absorb" the gas cost of first nonce increment to 1st transaction (construction), + // not to 2nd transaction) + function incrementNonce(uint192 key) public override { + nonceSequenceNumber[msg.sender][key]++; + } + + /** + * validate nonce uniqueness for this account. + * called just after validateUserOp() + */ + function _validateAndUpdateNonce(address sender, uint256 nonce) internal returns (bool) { + + uint192 key = uint192(nonce >> 64); + uint64 seq = uint64(nonce); + return nonceSequenceNumber[sender][key]++ == seq; + } + +} diff --git a/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol b/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol index c25288b38b..69ce75c8bd 100644 --- a/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol +++ b/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol @@ -12,8 +12,9 @@ pragma solidity ^0.8.12; import "./UserOperation.sol"; import "./IStakeManager.sol"; import "./IAggregator.sol"; +import "./INonceManager.sol"; -interface IEntryPoint is IStakeManager { +interface IEntryPoint is IStakeManager, INonceManager { /*** * An event emitted after each successful request @@ -45,6 +46,12 @@ interface IEntryPoint is IStakeManager { */ event UserOperationRevertReason(bytes32 indexed userOpHash, address indexed sender, uint256 nonce, bytes revertReason); + /** + * an event emitted by handleOps(), before starting the execution loop. + * any event emitted before this event, is part of the validation. + */ + event BeforeExecution(); + /** * signature aggregator used by the following UserOperationEvents within this bundle. */ diff --git a/packages/boba/account-abstraction/contracts/interfaces/INonceManager.sol b/packages/boba/account-abstraction/contracts/interfaces/INonceManager.sol new file mode 100644 index 0000000000..fe649130b2 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/interfaces/INonceManager.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +interface INonceManager { + + /** + * Return the next nonce for this sender. + * Within a given key, the nonce values are sequenced (starting with zero, and incremented by one on each userop) + * But UserOp with different keys can come with arbitrary order. + * + * @param sender the account address + * @param key the high 192 bit of the nonce + * @return nonce a full nonce to pass for next UserOp with this sender. + */ + function getNonce(address sender, uint192 key) + external view returns (uint256 nonce); + + /** + * Manually increment the nonce of the sender. + * This method is exposed just for completeness.. + * Account does NOT need to call it, neither during validation, nor elsewhere, + * as the EntryPoint will update the nonce regardless. + * Possible use-case is call it with various keys to "initialize" their nonces to one, so that future + * UserOperations will not pay extra for the first transaction with a given key. + */ + function incrementNonce(uint192 key) external; +} diff --git a/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol b/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol index dfff42791f..437c276063 100644 --- a/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol +++ b/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.12; /* solhint-disable no-inline-assembly */ +import {calldataKeccak} from "../core/Helpers.sol"; + /** * User Operation struct * @param sender the sender account of this request. @@ -59,19 +61,24 @@ library UserOperationLib { } function pack(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) - } + 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) { diff --git a/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol b/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol index 65fe9e68f2..6c1a198253 100644 --- a/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol +++ b/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol @@ -10,6 +10,7 @@ import "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; import "../core/BaseAccount.sol"; +import "./callback/TokenCallbackHandler.sol"; /** * minimal account. @@ -17,15 +18,9 @@ import "../core/BaseAccount.sol"; * has execute, eth handling methods * has a single signer that can send requests through the entryPoint. */ -contract SimpleAccount is BaseAccount, UUPSUpgradeable, Initializable { +contract SimpleAccount is BaseAccount, TokenCallbackHandler, UUPSUpgradeable, Initializable { using ECDSA for bytes32; - //filler member, to push the nonce and owner to the same slot - // the "Initializeble" class takes 2 bytes in the first slot - bytes28 private _filler; - - //explicit sizes of nonce, to fit a single storage cell with "owner" - uint96 private _nonce; address public owner; IEntryPoint private immutable _entryPoint; @@ -37,11 +32,6 @@ contract SimpleAccount is BaseAccount, UUPSUpgradeable, Initializable { _; } - /// @inheritdoc BaseAccount - function nonce() public view virtual override returns (uint256) { - return _nonce; - } - /// @inheritdoc BaseAccount function entryPoint() public view virtual override returns (IEntryPoint) { return _entryPoint; @@ -99,11 +89,6 @@ contract SimpleAccount is BaseAccount, UUPSUpgradeable, Initializable { require(msg.sender == address(entryPoint()) || msg.sender == owner, "account: not Owner or EntryPoint"); } - /// implement template method of BaseAccount - function _validateAndUpdateNonce(UserOperation calldata userOp) internal override { - require(_nonce++ == userOp.nonce, "account: invalid nonce"); - } - /// implement template method of BaseAccount function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash) internal override virtual returns (uint256 validationData) { diff --git a/packages/boba/account-abstraction/contracts/samples/callback/TokenCallbackHandler.sol b/packages/boba/account-abstraction/contracts/samples/callback/TokenCallbackHandler.sol new file mode 100644 index 0000000000..d7ed9cbbbd --- /dev/null +++ b/packages/boba/account-abstraction/contracts/samples/callback/TokenCallbackHandler.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +/* solhint-disable no-empty-blocks */ + +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import "@openzeppelin/contracts/token/ERC777/IERC777Recipient.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; + +/** + * Token callback handler. + * Handles supported tokens' callbacks, allowing account receiving these tokens. + */ +contract TokenCallbackHandler is IERC777Recipient, IERC721Receiver, IERC1155Receiver { + function tokensReceived( + address, + address, + address, + uint256, + bytes calldata, + bytes calldata + ) external pure override { + } + + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external pure override returns (bytes4) { + return IERC721Receiver.onERC721Received.selector; + } + + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes calldata + ) external pure override returns (bytes4) { + return IERC1155Receiver.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, + address, + uint256[] calldata, + uint256[] calldata, + bytes calldata + ) external pure override returns (bytes4) { + return IERC1155Receiver.onERC1155BatchReceived.selector; + } + + function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { + return + interfaceId == type(IERC721Receiver).interfaceId || + interfaceId == type(IERC1155Receiver).interfaceId || + interfaceId == type(IERC165).interfaceId; + } +} diff --git a/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Fallback.sol b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Fallback.sol index 3d1c8bd434..4cc0978d5c 100644 --- a/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Fallback.sol +++ b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Fallback.sol @@ -50,6 +50,14 @@ contract EIP4337Fallback is DefaultCallbackHandler, IAccount, IERC1271 { return abi.decode(ret, (uint256)); } + /** + * Helper for wallet to get the next nonce. + */ + function getNonce() public returns (uint256 nonce) { + bytes memory ret = delegateToManager(); + (nonce) = abi.decode(ret, (uint256)); + } + /** * called from the Safe. delegate actual work to EIP4337Manager */ diff --git a/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Manager.sol b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Manager.sol index bc3468638e..2a687ada50 100644 --- a/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Manager.sol +++ b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Manager.sol @@ -55,10 +55,8 @@ contract EIP4337Manager is IAccount, GnosisSafeStorage, Executor { validationData = SIG_VALIDATION_FAILED; } - if (userOp.initCode.length == 0) { - require(uint256(nonce) == userOp.nonce, "account: invalid nonce"); - nonce = bytes32(uint256(nonce) + 1); - } + // mimic normal Safe nonce behaviour: prevent parallel nonces + require(userOp.nonce < type(uint64).max, "account: nonsequential nonce"); if (missingAccountFunds > 0) { //Note: MAY pay more than the minimum, to deposit for future transactions @@ -105,6 +103,12 @@ contract EIP4337Manager is IAccount, GnosisSafeStorage, Executor { } } + /** + * Helper for wallet to get the next nonce. + */ + function getNonce() public view returns (uint256) { + return IEntryPoint(entryPoint).getNonce(address(this), 0); + } /** * set up a safe as EIP-4337 enabled. @@ -158,7 +162,8 @@ contract EIP4337Manager is IAccount, GnosisSafeStorage, Executor { sig[64] = bytes1(uint8(27)); sig[2] = bytes1(uint8(1)); sig[35] = bytes1(uint8(1)); - UserOperation memory userOp = UserOperation(address(safe), uint256(nonce), "", "", 0, 1000000, 0, 0, 0, "", sig); + uint256 nonce = uint256(IEntryPoint(manager.entryPoint()).getNonce(address(safe), 0)); + UserOperation memory userOp = UserOperation(address(safe), nonce, "", "", 0, 1000000, 0, 0, 0, "", sig); UserOperation[] memory userOps = new UserOperation[](1); userOps[0] = userOp; IEntryPoint _entryPoint = IEntryPoint(payable(manager.entryPoint())); diff --git a/packages/boba/account-abstraction/contracts/test/MaliciousAccount.sol b/packages/boba/account-abstraction/contracts/test/MaliciousAccount.sol index d42e918ec2..5b840e4f05 100644 --- a/packages/boba/account-abstraction/contracts/test/MaliciousAccount.sol +++ b/packages/boba/account-abstraction/contracts/test/MaliciousAccount.sol @@ -12,11 +12,12 @@ contract MaliciousAccount is IAccount { function validateUserOp(UserOperation calldata userOp, bytes32, uint256 missingAccountFunds) external returns (uint256 validationData) { ep.depositTo{value : missingAccountFunds}(address(this)); - // Now calculate basefee per EntryPoint.getUserOpGasPrice() and compare it to the basefe we pass off-chain as nonce + // Now calculate basefee per EntryPoint.getUserOpGasPrice() and compare it to the basefe we pass off-chain in the signature + uint256 externalBaseFee = abi.decode(userOp.signature, (uint256)); uint256 requiredGas = userOp.callGasLimit + userOp.verificationGasLimit + userOp.preVerificationGas; uint256 gasPrice = missingAccountFunds / requiredGas; uint256 basefee = gasPrice - userOp.maxPriorityFeePerGas; - require (basefee == userOp.nonce, "Revert after first validation"); + require (basefee == externalBaseFee, "Revert after first validation"); return 0; } } diff --git a/packages/boba/account-abstraction/eip/EIPS/eip-4337.md b/packages/boba/account-abstraction/eip/EIPS/eip-4337.md index 4e9550864b..27c5f9e431 100644 --- a/packages/boba/account-abstraction/eip/EIPS/eip-4337.md +++ b/packages/boba/account-abstraction/eip/EIPS/eip-4337.md @@ -29,16 +29,16 @@ This proposal takes a different approach, avoiding any adjustments to the consen * **Try to support other use cases** * Privacy-preserving applications * Atomic multi-operations (similar goal to [EIP-3074](./eip-3074.md)) - * Pay tx fees with [EIP-20](./eip-20.md) tokens, allow developers to pay fees for their users, and [EIP-3074](./eip-3074.md)-like **sponsored transaction** use cases more generally + * Pay tx fees with [ERC-20](./eip-20.md) tokens, allow developers to pay fees for their users, and [EIP-3074](./eip-3074.md)-like **sponsored transaction** use cases more generally * Support aggregated signature (e.g. BLS) ## Specification ### Definitions -* **UserOperation** - a structure that describes a transaction to be sent on behalf of a user. To avoid confusion, it is named "UserOperation" instead of "transaction." +* **UserOperation** - a structure that describes a transaction to be sent on behalf of a user. To avoid confusion, it is not named "transaction". * Like a transaction, it contains "sender", "to", "calldata", "maxFeePerGas", "maxPriorityFee", "signature", "nonce" - * unlike transaction, it contains several other fields, described below + * unlike a transaction, it contains several other fields, described below * also, the "nonce" and "signature" fields usage is not defined by the protocol, but by each account implementation * **Sender** - the account contract sending a user operation. * **EntryPoint** - a singleton contract to execute bundles of UserOperations. Bundlers/Clients whitelist the supported entrypoint. @@ -51,7 +51,7 @@ To avoid Ethereum consensus changes, we do not attempt to create new transaction | Field | Type | Description | - | - | - | | `sender` | `address` | The account making the operation | -| `nonce` | `uint256` | Anti-replay parameter; also used as the salt for first-time account creation | +| `nonce` | `uint256` | Anti-replay parameter | | `initCode` | `bytes` | The initCode of the account (needed if and only if the account is not yet on-chain and needs to be created) | | `callData` | `bytes` | The data to pass to the `sender` during the main execution call | | `callGasLimit` | `uint256` | The amount of gas to allocate the main execution call | @@ -121,11 +121,14 @@ interface IAccount { } ``` -The `userOpHash` is a hash over the userOp (except signature), entryPoint and chainId. The account: +The `userOpHash` is a hash over the userOp (except signature), entryPoint and chainId. + +The account: * MUST validate the caller is a trusted EntryPoint * If the account does not support signature aggregation, it MUST validate the signature is a valid signature of the `userOpHash`, and SHOULD return SIG_VALIDATION_FAILED (and not revert) on signature mismatch. Any other error should revert. +* The MAY check the nonce field, but should not implement the replay protection mechanism: the EntryPoint maintains uniqueness of nonces per user account. * MUST pay the entryPoint (caller) at least the "missingAccountFunds" (which might be zero, in case current account's deposit is high enough) * The account MAY pay more than this minimum, to cover future transactions (it can always issue `withdrawTo` to retrieve it) * The return value MUST be packed of `authorizer`, `validUntil` and `validAfter` timestamps. @@ -159,7 +162,7 @@ interface IAggregator { #### Using signature aggregators -An account signify it uses signature aggregation returning its address from `validateUserOp`. +An account signifies it uses signature aggregation returning its address from `validateUserOp`. During `simulateValidation`, this aggregator is returned (in the `ValidationResultWithAggregator`) The bundler should first accept the aggregator (validate its stake info and that it is not throttled/banned) @@ -180,6 +183,7 @@ The entry point's `handleOps` function must perform the following steps (we firs * **Create the account if it does not yet exist**, using the initcode provided in the `UserOperation`. If the account does not exist, _and_ the initcode is empty, or does not deploy a contract at the "sender" address, the call must fail. * **Call `validateUserOp` on the account**, passing in the `UserOperation`, the required fee and aggregator (if there is one). The account should verify the operation's signature, and pay the fee if the account considers the operation valid. If any `validateUserOp` call fails, `handleOps` must skip execution of at least that operation, and may revert entirely. * Validate the account's deposit in the entryPoint is high enough to cover the max possible cost (cover the already-done verification and max execution gas) +* Validate the nonce uniqueness. see [Keep Nonce Uniqueness](#keep-nonce-uniqueness) below In the execution loop, the `handleOps` call must perform the following steps for each `UserOperation`: @@ -188,11 +192,22 @@ In the execution loop, the `handleOps` call must perform the following steps for ![](../assets/eip-4337/image1.png) Before accepting a `UserOperation`, bundlers should use an RPC method to locally call the `simulateValidation` function of the entry point, to verify that the signature is correct and the operation actually pays fees; see the [Simulation section below](#simulation) for details. -A node/bundler SHOULD drop (and not add to the mempool) `UserOperation` that fails the validation +A node/bundler SHOULD drop (not add to the mempool) a `UserOperation` that fails the validation + +### Keep Nonce Uniqueness + +The EntryPoint maintains nonce uniqueness for each submitted UserOperation using the following algorithm: +* The nonce is treated as 2 separate fields: + * 64-bit "sequence" + * 192-bit "key" +* Within each "key", the "sequence" value must have consecutive values, starting with zero. +* That is, a nonce with a new "key" value is allowed, as long as the "sequence" part is zero. The next nonce for that key must be "1", and so on. +* The EntryPoint exports a method `getNonce(address sender, uint192 key)` to return the next valid nonce for this key. +* The behaviour of a "classic" sequential nonce can be achieved by validating that the "key" part is always zero. ### Extension: paymasters -We extend the entry point logic to support **paymasters** that can sponsor transactions for other users. This feature can be used to allow application developers to subsidize fees for their users, allow users to pay fees with [EIP-20](./eip-20.md) tokens and many other use cases. When the paymaster is not equal to the zero address, the entry point implements a different flow: +We extend the entry point logic to support **paymasters** that can sponsor transactions for other users. This feature can be used to allow application developers to subsidize fees for their users, allow users to pay fees with [ERC-20](./eip-20.md) tokens and many other use cases. When the paymaster is not equal to the zero address, the entry point implements a different flow: ![](../assets/eip-4337/image2.png) @@ -289,7 +304,6 @@ The simulated call performs the full validation, by calling: Either `validateUserOp` or `validatePaymasterUserOp` may return a "validAfter" and "validUntil" timestamps, which is the time-range that this UserOperation is valid on-chain. The simulateValidation call returns this range. A node MAY drop a UserOperation if it expires too soon (e.g. wouldn't make it to the next block) - If the `ValidationResult` includes `sigFail`, the client SHOULD drop the `UserOperation`. The operations differ in their opcode banning policy. @@ -320,7 +334,7 @@ An address `A` is associated with: 1. Slots of contract `A` address itself. 2. Slot `A` on any other address. -3. Slots of type `keccak256(A || X) + n` on any other address. (to cover `mapping(address => value)`, which is usually used for balance in EIP-20 tokens). +3. Slots of type `keccak256(A || X) + n` on any other address. (to cover `mapping(address => value)`, which is usually used for balance in ERC-20 tokens). `n` is an offset value up to 128, to allow accessing fields in the format `mapping(address => struct)` @@ -393,9 +407,9 @@ The value of MIN_STAKE_VALUE is determined per chain, and specified in the "bund Under the following special conditions, unstaked entities still can be used: -- An entity that doesn't use any storage at all, or only the senders's storage (not the entity's storage - that does require a stake) -- If the UserOp doesn't create a new account (that is initCode is empty), then the entity may also use [storage associated with the sender](#storage-associated-with-an-address)) -- A paymaster that has a “postOp()” method (that is, validatePaymasterUserOp returns “context”) must be staked +* An entity that doesn't use any storage at all, or only the senders's storage (not the entity's storage - that does require a stake) +* If the UserOp doesn't create a new account (that is initCode is empty), then the entity may also use [storage associated with the sender](#storage-associated-with-an-address)) +* A paymaster that has a “postOp()” method (that is, validatePaymasterUserOp returns “context”) must be staked #### Specification. @@ -456,10 +470,10 @@ The entry point-based approach allows for a clean separation between verificatio Paymasters facilitate transaction sponsorship, allowing third-party-designed mechanisms to pay for transactions. Many of these mechanisms _could_ be done by having the paymaster wrap a `UserOperation` with their own, but there are some important fundamental limitations to that approach: -* No possibility for "passive" paymasters (eg. that accept fees in some EIP-20 token at an exchange rate pulled from an on-chain DEX) +* No possibility for "passive" paymasters (eg. that accept fees in some ERC-20 token at an exchange rate pulled from an on-chain DEX) * Paymasters run the risk of getting griefed, as users could send ops that appear to pay the paymaster but then change their behavior after a block -The paymaster scheme allows a contract to passively pay on users' behalf under arbitrary conditions. It even allows EIP-20 token paymasters to secure a guarantee that they would only need to pay if the user pays them: the paymaster contract can check that there is sufficient approved EIP-20 balance in the `validatePaymasterUserOp` method, and then extract it with `transferFrom` in the `postOp` call; if the op itself transfers out or de-approves too much of the EIP-20s, the inner `postOp` will fail and revert the execution and the outer `postOp` can extract payment (note that because of storage access restrictions the EIP-20 would need to be a wrapper defined within the paymaster itself). +The paymaster scheme allows a contract to passively pay on users' behalf under arbitrary conditions. It even allows ERC-20 token paymasters to secure a guarantee that they would only need to pay if the user pays them: the paymaster contract can check that there is sufficient approved ERC-20 balance in the `validatePaymasterUserOp` method, and then extract it with `transferFrom` in the `postOp` call; if the op itself transfers out or de-approves too much of the ERC-20s, the inner `postOp` will fail and revert the execution and the outer `postOp` can extract payment (note that because of storage access restrictions the ERC-20 would need to be a wrapper defined within the paymaster itself). ### First-time account creation @@ -898,7 +912,7 @@ An array of reputation entries with the fields: ## Backwards Compatibility -This EIP does not change the consensus layer, so there are no backwards compatibility issues for Ethereum as a whole. Unfortunately it is not easily compatible with pre-[EIP-4337](./eip-4337.md) accounts, because those accounts do not have a `validateUserOp` function. If the account has a function for authorizing a trusted op submitter, then this could be fixed by creating an [EIP-4337](./eip-4337.md) compatible account that re-implements the verification logic as a wrapper and setting it to be the original account's trusted op submitter. +This EIP does not change the consensus layer, so there are no backwards compatibility issues for Ethereum as a whole. Unfortunately it is not easily compatible with pre-[ERC-4337](./eip-4337.md) accounts, because those accounts do not have a `validateUserOp` function. If the account has a function for authorizing a trusted op submitter, then this could be fixed by creating an [ERC-4337](./eip-4337.md) compatible account that re-implements the verification logic as a wrapper and setting it to be the original account's trusted op submitter. ## Reference Implementation @@ -906,7 +920,7 @@ See `https://github.com/eth-infinitism/account-abstraction/tree/main/contracts` ## Security Considerations -The entry point contract will need to be very heavily audited and formally verified, because it will serve as a central trust point for _all_ [EIP-4337](./eip-4337.md). In total, this architecture reduces auditing and formal verification load for the ecosystem, because the amount of work that individual _accounts_ have to do becomes much smaller (they need only verify the `validateUserOp` function and its "check signature, increment nonce and pay fees" logic) and check that other functions are `msg.sender == ENTRY_POINT` gated (perhaps also allowing `msg.sender == self`), but it is nevertheless the case that this is done precisely by concentrating security risk in the entry point contract that needs to be verified to be very robust. +The entry point contract will need to be very heavily audited and formally verified, because it will serve as a central trust point for _all_ [EIP-4337](./eip-4337.md). In total, this architecture reduces auditing and formal verification load for the ecosystem, because the amount of work that individual _accounts_ have to do becomes much smaller (they need only verify the `validateUserOp` function and its "check signature, and pay fees" logic) and check that other functions are `msg.sender == ENTRY_POINT` gated (perhaps also allowing `msg.sender == self`), but it is nevertheless the case that this is done precisely by concentrating security risk in the entry point contract that needs to be verified to be very robust. Verification would need to cover two primary claims (not including claims needed to protect paymasters, and claims needed to establish p2p-level DoS resistance): @@ -915,4 +929,4 @@ Verification would need to cover two primary claims (not including claims needed ## Copyright -Copyright and related rights waived via [CC0](../LICENSE.md). +Copyright and related rights waived via [CC0](../LICENSE.md). \ No newline at end of file diff --git a/packages/boba/account-abstraction/gascalc/GasChecker.ts b/packages/boba/account-abstraction/gascalc/GasChecker.ts index dc32b751ad..393e11742e 100644 --- a/packages/boba/account-abstraction/gascalc/GasChecker.ts +++ b/packages/boba/account-abstraction/gascalc/GasChecker.ts @@ -14,12 +14,13 @@ import { } from '../typechain' import { BigNumberish, Wallet } from 'ethers' import hre from 'hardhat' -import { fillAndSign } from '../test/UserOp' +import { fillAndSign, fillUserOp, signUserOp } from '../test/UserOp' import { TransactionReceipt } from '@ethersproject/abstract-provider' import { table, TableUserConfig } from 'table' import { Create2Factory } from '../src/Create2Factory' import * as fs from 'fs' import { SimpleAccountInterface } from '../typechain/contracts/samples/SimpleAccount' +import { UserOperation } from '../test/UserOperation' const gasCheckerLogFile = './reports/gas-checker.txt' @@ -52,7 +53,7 @@ interface GasTestInfo { export const DefaultGasTestInfo: Partial = { dest: 'self', // destination is the account itself. destValue: parseEther('0'), - destCallData: '0xaffed0e0', // nonce() + destCallData: '0xb0d691fe', // entryPoint() gasPrice: 10e9 } @@ -111,6 +112,8 @@ export class GasChecker { ]) } + createdAccounts = new Set() + /** * create accounts up to this counter. * make sure they all have balance. @@ -123,15 +126,33 @@ export class GasChecker { hexConcat([ SimpleAccountFactory__factory.bytecode, defaultAbiCoder.encode(['address'], [this.entryPoint().address]) - ]), 0, 2708636) + ]), 0, 2885201) console.log('factaddr', factoryAddress) const fact = SimpleAccountFactory__factory.connect(factoryAddress, ethersSigner) // create accounts + const creationOps: UserOperation[] = [] for (const n of range(count)) { const salt = n // const initCode = this.accountInitCode(fact, salt) const addr = await fact.getAddress(this.accountOwner.address, salt) + + if (!this.createdAccounts.has(addr)) { + // explicit call to fillUseROp with no "entryPoint", to make sure we manually fill everything and + // not attempt to fill from blockchain. + const op = signUserOp(await fillUserOp({ + sender: addr, + nonce: 0, + callGasLimit: 30000, + verificationGasLimit: 1000000, + // paymasterAndData: paymaster, + preVerificationGas: 1, + maxFeePerGas: 0 + }), this.accountOwner, this.entryPoint().address, await provider.getNetwork().then(net => net.chainId)) + creationOps.push(op) + this.createdAccounts.add(addr) + } + this.accounts[addr] = this.accountOwner // deploy if not already deployed. await fact.createAccount(this.accountOwner.address, salt) @@ -140,6 +161,7 @@ export class GasChecker { await GasCheckCollector.inst.entryPoint.depositTo(addr, { value: minDepositOrBalance.mul(5) }) } } + await this.entryPoint().handleOps(creationOps, ethersSigner.getAddress()) } /** @@ -238,7 +260,9 @@ export class GasChecker { title: info.title // receipt: rcpt } - if (info.diffLastGas) { ret1.gasDiff = gasDiff } + if (info.diffLastGas) { + ret1.gasDiff = gasDiff + } console.debug(ret1) return ret1 } @@ -338,15 +362,17 @@ export class GasCheckCollector { fs.appendFileSync(gasCheckerLogFile, s + '\n') } - write('== gas estimate of direct calling the account\'s "execFromEntryPoint" method') - write(' the destination is "account.nonce()", which is known to be "hot" address used by this account') + write('== gas estimate of direct calling the account\'s "execute" method') + write(' the destination is "account.entryPoint()", which is known to be "hot" address used by this account') write(' it little higher than EOA call: its an exec from entrypoint (or account owner) into account contract, verifying msg.sender and exec to target)') - Object.values(gasEstimatePerExec).forEach(({ title, accountEst }) => { - write(`- gas estimate "${title}" - ${accountEst}`) - }) + + write(table(Object.values(gasEstimatePerExec).map((row) => [ + `gas estimate "${row.title}"`, row.accountEst + ]), this.tableConfig)) const tableOutput = table(this.tabRows, this.tableConfig) write(tableOutput) + process.exit(0) } addRow (res: GasTestResult): void { diff --git a/packages/boba/account-abstraction/reports/gas-checker.txt b/packages/boba/account-abstraction/reports/gas-checker.txt index b01ac4e819..17dea2e6f5 100644 --- a/packages/boba/account-abstraction/reports/gas-checker.txt +++ b/packages/boba/account-abstraction/reports/gas-checker.txt @@ -1,35 +1,39 @@ -== gas estimate of direct calling the account's "execFromEntryPoint" method - the destination is "account.nonce()", which is known to be "hot" address used by this account +== gas estimate of direct calling the account's "execute" method + the destination is "account.entryPoint()", which is known to be "hot" address used by this account it little higher than EOA call: its an exec from entrypoint (or account owner) into account contract, verifying msg.sender and exec to target) -- gas estimate "simple" - 31033 -- gas estimate "big tx 5k" - 127284 +╔══════════════════════════╤════════╗ +║ gas estimate "simple" │ 29014 ║ +╟──────────────────────────┼────────╢ +║ gas estimate "big tx 5k" │ 125260 ║ +╚══════════════════════════╧════════╝ + ╔════════════════════════════════╤═══════╤═══════════════╤════════════════╤═════════════════════╗ ║ handleOps description │ count │ total gasUsed │ per UserOp gas │ per UserOp overhead ║ ║ │ │ │ (delta for │ (compared to ║ ║ │ │ │ one UserOp) │ account.exec()) ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple │ 1 │ 75948 │ │ ║ +║ simple │ 1 │ 81901 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple - diff from previous │ 2 │ │ 41449 │ 10416 ║ +║ simple - diff from previous │ 2 │ │ 44212 │ 15198 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple │ 10 │ 449053 │ │ ║ +║ simple │ 10 │ 479854 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple - diff from previous │ 11 │ │ 41560 │ 10527 ║ +║ simple - diff from previous │ 11 │ │ 44236 │ 15222 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster │ 1 │ 82245 │ │ ║ +║ simple paymaster │ 1 │ 88172 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster with diff │ 2 │ │ 40408 │ 9375 ║ +║ simple paymaster with diff │ 2 │ │ 43165 │ 14151 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster │ 10 │ 446306 │ │ ║ +║ simple paymaster │ 10 │ 476994 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster with diff │ 11 │ │ 40604 │ 9571 ║ +║ simple paymaster with diff │ 11 │ │ 43260 │ 14246 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx 5k │ 1 │ 177693 │ │ ║ +║ big tx 5k │ 1 │ 182958 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx - diff from previous │ 2 │ │ 142772 │ 15488 ║ +║ big tx - diff from previous │ 2 │ │ 144723 │ 19463 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx 5k │ 10 │ 1468115 │ │ ║ +║ big tx 5k │ 10 │ 1485438 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx - diff from previous │ 11 │ │ 144290 │ 17006 ║ +║ big tx - diff from previous │ 11 │ │ 144712 │ 19452 ║ ╚════════════════════════════════╧═══════╧═══════════════╧════════════════╧═════════════════════╝ diff --git a/packages/boba/account-abstraction/test/UserOp.ts b/packages/boba/account-abstraction/test/UserOp.ts index 0872d477f5..c391f1e9a6 100644 --- a/packages/boba/account-abstraction/test/UserOp.ts +++ b/packages/boba/account-abstraction/test/UserOp.ts @@ -13,66 +13,25 @@ import { import { UserOperation } from './UserOperation' import { Create2Factory } from '../src/Create2Factory' -function encode (typevalues: Array<{ type: string, val: any }>, forSignature: boolean): string { - 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 defaultAbiCoder.encode(types, values) -} - -// export function packUserOp(op: UserOperation, hashBytes = true): string { -// if ( !hashBytes || true ) { -// return packUserOp1(op, hashBytes) -// } -// -// const opEncoding = Object.values(testUtil.interface.functions).find(func => func.name == 'packUserOp')!.inputs[0] -// let packed = defaultAbiCoder.encode([opEncoding], [{...op, signature:'0x'}]) -// packed = '0x'+packed.slice(64+2) //skip first dword (length) -// packed = packed.slice(0,packed.length-64) //remove signature (the zero-length) -// return packed -// } - 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 = defaultAbiCoder.encode([userOpType as any], [{ ...op, signature: '0x' }]) - // 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 defaultAbiCoder.encode( + ['address', 'uint256', 'bytes32', 'bytes32', + 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', + 'bytes32'], + [op.sender, op.nonce, keccak256(op.initCode), keccak256(op.callData), + op.callGasLimit, op.verificationGasLimit, op.preVerificationGas, op.maxFeePerGas, op.maxPriorityFeePerGas, + keccak256(op.paymasterAndData)]) + } else { + // for the purpose of calculating gas cost encode also signature (and no keccak of bytes) + return defaultAbiCoder.encode( + ['address', 'uint256', 'bytes', 'bytes', + 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', + 'bytes', 'bytes'], + [op.sender, op.nonce, op.initCode, op.callData, + op.callGasLimit, op.verificationGasLimit, op.preVerificationGas, op.maxFeePerGas, op.maxPriorityFeePerGas, + op.paymasterAndData, op.signature]) } - return encode(typevalues, forSignature) } export function packUserOp1 (op: UserOperation): string { @@ -82,8 +41,8 @@ export function packUserOp1 (op: UserOperation): string { 'bytes32', // initCode 'bytes32', // callData 'uint256', // callGasLimit - 'uint', // verificationGasLimit - 'uint', // preVerificationGas + 'uint256', // verificationGasLimit + 'uint256', // preVerificationGas 'uint256', // maxFeePerGas 'uint256', // maxPriorityFeePerGas 'bytes32' // paymasterAndData @@ -115,7 +74,7 @@ export const DefaultsForUserOp: UserOperation = { initCode: '0x', callData: '0x', callGasLimit: 0, - verificationGasLimit: 100000, // default verification gas. will add create2 cost (3200+200*length) if initCode exists + verificationGasLimit: 150000, // default verification gas. will add create2 cost (3200+200*length) if initCode exists preVerificationGas: 21000, // should also cover calldata cost. maxFeePerGas: 0, maxPriorityFeePerGas: 1e9, @@ -160,13 +119,13 @@ export function fillUserOpDefaults (op: Partial, defaults = Defau // - calculate sender by eth_call the deployment code // - default verificationGasLimit estimateGas of deployment code plus default 100000 // no initCode: -// - update nonce from account.nonce() +// - update nonce from account.getNonce() // entryPoint param is only required to fill in "sender address when specifying "initCode" -// nonce: assume contract as "nonce()" function, and fill in. +// nonce: assume contract as "getNonce()" function, and fill in. // sender - only in case of construction: fill sender from initCode. // callGasLimit: VERY crude estimation (by estimating call to account, and add rough entryPoint overhead // verificationGasLimit: hard-code default at 100k. should add "create2" cost -export async function fillUserOp (op: Partial, entryPoint?: EntryPoint): Promise { +export async function fillUserOp (op: Partial, entryPoint?: EntryPoint, getNonceFunction = 'getNonce'): Promise { const op1 = { ...op } const provider = entryPoint?.provider if (op.initCode != null) { @@ -198,8 +157,8 @@ export async function fillUserOp (op: Partial, entryPoint?: Entry } if (op1.nonce == null) { if (provider == null) throw new Error('must have entryPoint to autofill nonce') - const c = new Contract(op.sender!, ['function nonce() view returns(address)'], provider) - op1.nonce = await c.nonce().catch(rethrow()) + const c = new Contract(op.sender!, [`function ${getNonceFunction}() view returns(uint256)`], provider) + op1.nonce = await c[getNonceFunction]().catch(rethrow()) } if (op1.callGasLimit == null && op.callData != null) { if (provider == null) throw new Error('must have entryPoint for callGasLimit estimate') @@ -232,9 +191,9 @@ export async function fillUserOp (op: Partial, entryPoint?: Entry return op2 } -export async function fillAndSign (op: Partial, signer: Wallet | Signer, entryPoint?: EntryPoint): Promise { +export async function fillAndSign (op: Partial, signer: Wallet | Signer, entryPoint?: EntryPoint, getNonceFunction = 'getNonce'): Promise { const provider = entryPoint?.provider - const op2 = await fillUserOp(op, entryPoint) + const op2 = await fillUserOp(op, entryPoint, getNonceFunction) const chainId = await provider!.getNetwork().then(net => net.chainId) const message = arrayify(getUserOpHash(op2, entryPoint!.address, chainId)) diff --git a/packages/boba/account-abstraction/test/entrypoint.test.ts b/packages/boba/account-abstraction/test/entrypoint.test.ts index d4502b44a6..a3f06d9132 100644 --- a/packages/boba/account-abstraction/test/entrypoint.test.ts +++ b/packages/boba/account-abstraction/test/entrypoint.test.ts @@ -42,7 +42,8 @@ import { simulationResultCatch, createAccount, getAggregatedAccountInitCode, - simulationResultWithAggregationCatch + simulationResultWithAggregationCatch, + decodeRevertReason } from './testutils' import { DefaultsForUserOp, fillAndSign, getUserOpHash } from './UserOp' import { UserOperation } from './UserOperation' @@ -233,7 +234,7 @@ describe('EntryPoint', function () { // using wrong nonce const op = await fillAndSign({ sender: account.address, nonce: 1234 }, accountOwner, entryPoint) await expect(entryPoint.callStatic.simulateValidation(op)).to - .revertedWith('AA23 reverted: account: invalid nonce') + .revertedWith('AA25 invalid account nonce') }) it('should report signature failure without revert', async () => { @@ -411,7 +412,8 @@ describe('EntryPoint', function () { const userOp: UserOperation = { sender: maliciousAccount.address, - nonce: block.baseFeePerGas, + nonce: await entryPoint.getNonce(maliciousAccount.address, 0), + signature: defaultAbiCoder.encode(['uint256'], [block.baseFeePerGas]), initCode: '0x', callData: '0x', callGasLimit: '0x' + 1e5.toString(16), @@ -420,8 +422,7 @@ describe('EntryPoint', function () { // we need maxFeeperGas > block.basefee + maxPriorityFeePerGas so requiredPrefund onchain is basefee + maxPriorityFeePerGas maxFeePerGas: block.baseFeePerGas.mul(3), maxPriorityFeePerGas: block.baseFeePerGas, - paymasterAndData: '0x', - signature: '0x' + paymasterAndData: '0x' } try { await expect(entryPoint.simulateValidation(userOp, { gasLimit: 1e6 })) @@ -447,13 +448,14 @@ describe('EntryPoint', function () { sender: testRevertAccount.address, callGasLimit: 1e5, maxFeePerGas: 1, + nonce: await entryPoint.getNonce(testRevertAccount.address, 0), verificationGasLimit: 1e5, callData: badData.data! } const beneficiaryAddress = createAddress() await expect(entryPoint.simulateValidation(badOp, { gasLimit: 3e5 })) .to.revertedWith('ValidationResult') - const tx = await entryPoint.handleOps([badOp], beneficiaryAddress, { gasLimit: 3e5 }) + const tx = await entryPoint.handleOps([badOp], beneficiaryAddress) // { gasLimit: 3e5 }) const receipt = await tx.wait() const userOperationRevertReasonEvent = receipt.events?.find(event => event.event === 'UserOperationRevertReason') expect(userOperationRevertReasonEvent?.event).to.equal('UserOperationRevertReason') @@ -510,6 +512,71 @@ describe('EntryPoint', function () { }) }) + describe('2d nonces', () => { + const beneficiaryAddress = createAddress() + let sender: string + const key = 1 + const keyShifted = BigNumber.from(key).shl(64) + + before(async () => { + const { proxy } = await createAccount(ethersSigner, accountOwner.address, entryPoint.address) + sender = proxy.address + await fund(sender) + }) + + it('should fail nonce with new key and seq!=0', async () => { + const op = await fillAndSign({ + sender, + nonce: keyShifted.add(1) + }, accountOwner, entryPoint) + await expect(entryPoint.callStatic.handleOps([op], beneficiaryAddress)).to.revertedWith('AA25 invalid account nonce') + }) + + describe('with key=1, seq=1', () => { + before(async () => { + const op = await fillAndSign({ + sender, + nonce: keyShifted + }, accountOwner, entryPoint) + await entryPoint.handleOps([op], beneficiaryAddress) + }) + + it('should get next nonce value by getNonce', async () => { + expect(await entryPoint.getNonce(sender, key)).to.eql(keyShifted.add(1)) + }) + + it('should allow to increment nonce of different key', async () => { + const op = await fillAndSign({ + sender, + nonce: await entryPoint.getNonce(sender, key) + }, accountOwner, entryPoint) + await entryPoint.callStatic.handleOps([op], beneficiaryAddress) + }) + + it('should allow manual nonce increment', async () => { + // must be called from account itself + const incNonceKey = 5 + const incrementCallData = entryPoint.interface.encodeFunctionData('incrementNonce', [incNonceKey]) + const callData = account.interface.encodeFunctionData('execute', [entryPoint.address, 0, incrementCallData]) + const op = await fillAndSign({ + sender, + callData, + nonce: await entryPoint.getNonce(sender, key) + }, accountOwner, entryPoint) + await entryPoint.handleOps([op], beneficiaryAddress) + + expect(await entryPoint.getNonce(sender, incNonceKey)).to.equal(BigNumber.from(incNonceKey).shl(64).add(1)) + }) + it('should fail with nonsequential seq', async () => { + const op = await fillAndSign({ + sender, + nonce: keyShifted.add(3) + }, accountOwner, entryPoint) + await expect(entryPoint.callStatic.handleOps([op], beneficiaryAddress)).to.revertedWith('AA25 invalid account nonce') + }) + }) + }) + describe('without paymaster (account pays in eth)', () => { describe('#handleOps', () => { let counter: TestCounter @@ -717,6 +784,24 @@ describe('EntryPoint', function () { await calcGasUsage(rcpt, entryPoint, beneficiaryAddress) }) + it('should fail to call recursively into handleOps', async () => { + const beneficiaryAddress = createAddress() + + const callHandleOps = entryPoint.interface.encodeFunctionData('handleOps', [[], beneficiaryAddress]) + const execHandlePost = account.interface.encodeFunctionData('execute', [entryPoint.address, 0, callHandleOps]) + const op = await fillAndSign({ + sender: account.address, + callData: execHandlePost + }, accountOwner, entryPoint) + + const rcpt = await entryPoint.handleOps([op], beneficiaryAddress, { + gasLimit: 1e7 + }).then(async r => r.wait()) + + const error = rcpt.events?.find(ev => ev.event === 'UserOperationRevertReason') + expect(decodeRevertReason(error?.args?.revertReason)).to.eql('Error(ReentrancyGuard: reentrant call)', 'execution of handleOps inside a UserOp should revert') + }) + it('should report failure on insufficient verificationGas after creation', async () => { const op0 = await fillAndSign({ sender: account.address, @@ -1026,8 +1111,7 @@ describe('EntryPoint', function () { addr = await entryPoint.callStatic.getSenderAddress(initCode).catch(e => e.errorArgs.sender) await ethersSigner.sendTransaction({ to: addr, value: parseEther('0.1') }) userOp = await fillAndSign({ - initCode, - nonce: 10 + initCode }, accountOwner, entryPoint) }) it('simulateValidation should return aggregator and its stake', async () => { diff --git a/packages/boba/account-abstraction/test/gnosis.test.ts b/packages/boba/account-abstraction/test/gnosis.test.ts index 784b7f914b..24819edf06 100644 --- a/packages/boba/account-abstraction/test/gnosis.test.ts +++ b/packages/boba/account-abstraction/test/gnosis.test.ts @@ -103,7 +103,7 @@ describe('Gnosis Proxy', function () { it('should fail from wrong entrypoint', async function () { const op = await fillAndSign({ sender: proxy.address - }, owner, entryPoint) + }, owner, entryPoint, 'getNonce') const anotherEntryPoint = await new EntryPoint__factory(ethersSigner).deploy() @@ -116,14 +116,14 @@ describe('Gnosis Proxy', function () { nonce: 1234, callGasLimit: 1e6, callData: safe_execTxCallData - }, owner, entryPoint) - await expect(entryPoint.handleOps([op], beneficiary)).to.revertedWith('account: invalid nonce') + }, owner, entryPoint, 'getNonce') + await expect(entryPoint.handleOps([op], beneficiary)).to.revertedWith('AA25 invalid account nonce') op = await fillAndSign({ sender: proxy.address, callGasLimit: 1e6, callData: safe_execTxCallData - }, owner, entryPoint) + }, owner, entryPoint, 'getNonce') // invalidate the signature op.callGasLimit = 1 await expect(entryPoint.handleOps([op], beneficiary)).to.revertedWith('FailedOp(0, "AA24 signature error")') @@ -134,7 +134,7 @@ describe('Gnosis Proxy', function () { sender: proxy.address, callGasLimit: 1e6, callData: safe_execTxCallData - }, owner, entryPoint) + }, owner, entryPoint, 'getNonce') const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait()) console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash) @@ -151,7 +151,8 @@ describe('Gnosis Proxy', function () { sender: proxy.address, callGasLimit: 1e6, callData: safe_execFailTxCallData - }, owner, entryPoint) + }, owner, entryPoint, 'getNonce') + const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait()) console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash) @@ -183,7 +184,7 @@ describe('Gnosis Proxy', function () { sender: counterfactualAddress, initCode, verificationGasLimit: 400000 - }, owner, entryPoint) + }, owner, entryPoint, 'getNonce') const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait()) console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash) @@ -200,7 +201,7 @@ describe('Gnosis Proxy', function () { const op = await fillAndSign({ sender: counterfactualAddress, callData: safe_execTxCallData - }, owner, entryPoint) + }, owner, entryPoint, 'getNonce') const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait()) console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash) diff --git a/packages/boba/account-abstraction/test/simple-wallet.test.ts b/packages/boba/account-abstraction/test/simple-wallet.test.ts index d5350e0eb6..1a57054d36 100644 --- a/packages/boba/account-abstraction/test/simple-wallet.test.ts +++ b/packages/boba/account-abstraction/test/simple-wallet.test.ts @@ -2,31 +2,36 @@ import { Wallet } from 'ethers' import { ethers } from 'hardhat' import { expect } from 'chai' import { + ERC1967Proxy__factory, SimpleAccount, SimpleAccountFactory__factory, + SimpleAccount__factory, TestUtil, TestUtil__factory } from '../typechain' import { + createAccount, createAddress, createAccountOwner, + deployEntryPoint, getBalance, isDeployed, ONE_ETH, - createAccount, HashZero + HashZero } from './testutils' import { fillUserOpDefaults, getUserOpHash, packUserOp, signUserOp } from './UserOp' import { parseEther } from 'ethers/lib/utils' import { UserOperation } from './UserOperation' describe('SimpleAccount', function () { - const entryPoint = '0x'.padEnd(42, '2') + let entryPoint: string let accounts: string[] let testUtil: TestUtil let accountOwner: Wallet const ethersSigner = ethers.provider.getSigner() before(async function () { + entryPoint = await deployEntryPoint().then(e => e.address) accounts = await ethers.provider.listAccounts() // ignore in geth.. this is just a sanity test. should be refactored to use a single-account mode.. if (accounts.length < 2) this.skip() @@ -59,11 +64,18 @@ describe('SimpleAccount', function () { let expectedPay: number const actualGasPrice = 1e9 + // for testing directly validateUserOp, we initialize the account with EOA as entryPoint. + let entryPointEoa: string before(async () => { - // that's the account of ethersSigner - const entryPoint = accounts[2]; - ({ proxy: account } = await createAccount(await ethers.getSigner(entryPoint), accountOwner.address, entryPoint)) + entryPointEoa = accounts[2] + const epAsSigner = await ethers.getSigner(entryPointEoa) + + // cant use "SimpleAccountFactory", since it attempts to increment nonce first + const implementation = await new SimpleAccount__factory(ethersSigner).deploy(entryPointEoa) + const proxy = await new ERC1967Proxy__factory(ethersSigner).deploy(implementation.address, '0x') + account = SimpleAccount__factory.connect(proxy.address, epAsSigner) + await ethersSigner.sendTransaction({ from: accounts[0], to: account.address, value: parseEther('0.2') }) const callGasLimit = 200000 const verificationGasLimit = 100000 @@ -75,9 +87,9 @@ describe('SimpleAccount', function () { callGasLimit, verificationGasLimit, maxFeePerGas - }), accountOwner, entryPoint, chainId) + }), accountOwner, entryPointEoa, chainId) - userOpHash = await getUserOpHash(userOp, entryPoint, chainId) + userOpHash = await getUserOpHash(userOp, entryPointEoa, chainId) expectedPay = actualGasPrice * (callGasLimit + verificationGasLimit) @@ -91,20 +103,13 @@ describe('SimpleAccount', function () { expect(preBalance - postBalance).to.eql(expectedPay) }) - it('should increment nonce', async () => { - expect(await account.nonce()).to.equal(1) - }) - - it('should reject same TX on nonce error', async () => { - await expect(account.validateUserOp(userOp, userOpHash, 0)).to.revertedWith('invalid nonce') - }) - it('should return NO_SIG_VALIDATION on wrong signature', async () => { const userOpHash = HashZero const deadline = await account.callStatic.validateUserOp({ ...userOp, nonce: 1 }, userOpHash, 0) expect(deadline).to.eq(1) }) }) + context('SimpleAccountFactory', () => { it('sanity: check deployer', async () => { const ownerAddr = createAddress() diff --git a/packages/boba/account-abstraction/test/y.bls.test.ts b/packages/boba/account-abstraction/test/y.bls.test.ts index 16cfcc42a0..6dae29ee09 100644 --- a/packages/boba/account-abstraction/test/y.bls.test.ts +++ b/packages/boba/account-abstraction/test/y.bls.test.ts @@ -184,8 +184,7 @@ describe('bls account', function () { await fund(senderAddress, '0.01') const userOp = await fillUserOp({ sender: senderAddress, - initCode, - nonce: 2 + initCode }, entrypoint) const requestHash = await blsAgg.getUserOpHash(userOp) const sigParts = signer3.sign(requestHash) diff --git a/packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol b/packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol index ae59961fb9..9497ebdff0 100644 --- a/packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol +++ b/packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol @@ -16,6 +16,7 @@ contract Dummy { contract TestOpcodesAccount is TestRuleAccount { event TestMessage(address eventSender); + event ExecutionMessage(); function runRule(string memory rule) public virtual override returns (uint) { if (eq(rule, "number")) return block.number; @@ -31,6 +32,10 @@ contract TestOpcodesAccount is TestRuleAccount { } return super.runRule(rule); } + + function execEvent() public { + emit ExecutionMessage(); + } } contract TestOpcodesAccountFactory { diff --git a/packages/boba/bundler/contracts/tests/TestRulesAccount.sol b/packages/boba/bundler/contracts/tests/TestRulesAccount.sol index d0c5256ddf..91c12f08cf 100644 --- a/packages/boba/bundler/contracts/tests/TestRulesAccount.sol +++ b/packages/boba/bundler/contracts/tests/TestRulesAccount.sol @@ -31,7 +31,12 @@ contract TestRulesAccount is IAccount, IPaymaster { return keccak256(bytes(a)) == keccak256(bytes(b)); } - event TestMessage(address eventSender); + event TestFromValidation(); + event TestMessage(); + + function execSendMessage() public { + emit TestMessage(); + } function runRule(string memory rule) public returns (uint) { if (eq(rule, "")) return 0; @@ -50,7 +55,7 @@ contract TestRulesAccount is IAccount, IPaymaster { else if (eq(rule, "inner-revert")) return coin.reverting(); else if (eq(rule, "emit-msg")) { - emit TestMessage(address(this)); + emit TestFromValidation(); return 0;} revert(string.concat("unknown rule: ", rule)); diff --git a/packages/boba/bundler/src/UserOpMethodHandler.ts b/packages/boba/bundler/src/UserOpMethodHandler.ts index 8d2bbe4e06..4ca2a2824e 100644 --- a/packages/boba/bundler/src/UserOpMethodHandler.ts +++ b/packages/boba/bundler/src/UserOpMethodHandler.ts @@ -240,8 +240,14 @@ export class UserOpMethodHandler { _filterLogs(userOpEvent: UserOperationEventEvent, logs: Log[]): Log[] { let startIndex = -1 let endIndex = -1 + const events = Object.values(this.entryPoint.interface.events) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const beforeExecutionTopic = this.entryPoint.interface.getEventTopic(events.find(e => e.name === 'BeforeExecution')!) logs.forEach((log, index) => { - if (log?.topics[0] === userOpEvent.topics[0]) { + if (log?.topics[0] === beforeExecutionTopic) { + // all UserOp execution events start after the "BeforeExecution" event. + startIndex = endIndex = index + } else if (log?.topics[0] === userOpEvent.topics[0]) { // process UserOperationEvent if (log.topics[1] === userOpEvent.topics[1]) { // it's our userOpHash. save as end of logs array diff --git a/packages/boba/bundler/src/runner/runop.ts b/packages/boba/bundler/src/runner/runop.ts index 5d1fa8b151..2860027256 100644 --- a/packages/boba/bundler/src/runner/runop.ts +++ b/packages/boba/bundler/src/runner/runop.ts @@ -240,7 +240,7 @@ async function main(): Promise { } const dest = addr - const data = keccak256(Buffer.from('nonce()')).slice(0, 10) + const data = keccak256(Buffer.from('entryPoint()')).slice(0, 10) console.log('data=', data) await client.runUserOp(dest, data) console.log('after run1') diff --git a/packages/boba/bundler/test/UserOpMethodHandler.test.ts b/packages/boba/bundler/test/UserOpMethodHandler.test.ts index ced96aadbf..b3b4f1e37f 100644 --- a/packages/boba/bundler/test/UserOpMethodHandler.test.ts +++ b/packages/boba/bundler/test/UserOpMethodHandler.test.ts @@ -16,7 +16,7 @@ import { Signer, Wallet } from 'ethers' import { DeterministicDeployer, SimpleAccountAPI } from '@boba/bundler_sdk' import { postExecutionDump } from '@boba/bundler_utils/dist/postExecCheck' import { - SampleRecipient, TestRuleAccount, TestOpcodesAccount__factory + SampleRecipient, TestRulesAccount, TestRulesAccount__factory } from '../dist/src/types' import { resolveHexlify } from '@boba/bundler_utils' import { UserOperationEventEvent } from '@boba/accountabstraction' @@ -166,20 +166,21 @@ describe('UserOpMethodHandler', function () { const transactionReceipt = await event!.getTransactionReceipt() assert.isNotNull(transactionReceipt) const logs = transactionReceipt.logs.filter(log => log.address === entryPoint.address) - const deployedEvent = entryPoint.interface.parseLog(logs[0]) - const depositedEvent = entryPoint.interface.parseLog(logs[1]) + .map(log => entryPoint.interface.parseLog(log)) + expect(logs.map(log => log.name)).to.eql([ + 'AccountDeployed', + 'Deposited', + 'BeforeExecution', + 'UserOperationEvent' + ]) const [senderEvent] = await sampleRecipient.queryFilter(sampleRecipient.filters.Sender(), transactionReceipt.blockHash) - const userOperationEvent = entryPoint.interface.parseLog(logs[2]) + const userOperationEvent = logs[3] - assert.equal(deployedEvent.args.sender, userOperation.sender) - assert.equal(userOperationEvent.name, 'UserOperationEvent') assert.equal(userOperationEvent.args.success, true) const expectedTxOrigin = await methodHandler.signer.getAddress() assert.equal(senderEvent.args.txOrigin, expectedTxOrigin, 'sample origin should be bundler') assert.equal(senderEvent.args.msgSender, accountAddress, 'sample msgsender should be account address') - - assert.equal(depositedEvent.name, 'Deposited') }) it('getUserOperationByHash should return submitted UserOp', async () => { @@ -292,14 +293,15 @@ describe('UserOpMethodHandler', function () { describe('#getUserOperationReceipt', function () { let userOpHash: string let receipt: UserOperationReceipt - let acc: TestRuleAccount + let acc: TestRulesAccount before(async () => { - acc = await new TestOpcodesAccount__factory(signer).deploy() + acc = await new TestRulesAccount__factory(signer).deploy() + const callData = acc.interface.encodeFunctionData('execSendMessage') const op: UserOperationStruct = { sender: acc.address, initCode: '0x', nonce: 0, - callData: '0x', + callData, callGasLimit: 1e6, verificationGasLimit: 1e6, preVerificationGas: 50000, @@ -324,10 +326,9 @@ describe('UserOpMethodHandler', function () { expect(await methodHandler.getUserOperationReceipt(ethers.constants.HashZero)).to.equal(null) }) - it('receipt should contain only userOp-specific events..', async () => { + it('receipt should contain only userOp execution events..', async () => { expect(receipt.logs.length).to.equal(1) - const evParams = acc.interface.decodeEventLog('TestMessage', receipt.logs[0].data, receipt.logs[0].topics) - expect(evParams.eventSender).to.equal(acc.address) + acc.interface.decodeEventLog('TestMessage', receipt.logs[0].data, receipt.logs[0].topics) }) it('general receipt fields', () => { expect(receipt.success).to.equal(true) @@ -337,8 +338,22 @@ describe('UserOpMethodHandler', function () { // filter out BOR-specific events.. const logs = receipt.receipt.logs .filter(log => log.address !== '0x0000000000000000000000000000000000001010') - // one UserOperationEvent, and one op-specific event. - expect(logs.length).to.equal(2) + const eventNames = logs + // .filter(l => l.address == entryPoint.address) + .map(l => { + try { + return entryPoint.interface.parseLog(l) + } catch (e) { + return acc.interface.parseLog(l) + } + }) + .map(l => l.name) + expect(eventNames).to.eql([ + 'TestFromValidation', // account validateUserOp + 'BeforeExecution', // entryPoint marker + 'TestMessage', // account execution event + 'UserOperationEvent' // post-execution event + ]) }) }) }) diff --git a/packages/boba/bundler_sdk/src/BaseAccountAPI.ts b/packages/boba/bundler_sdk/src/BaseAccountAPI.ts index b5ccf47d45..cd83585c8e 100644 --- a/packages/boba/bundler_sdk/src/BaseAccountAPI.ts +++ b/packages/boba/bundler_sdk/src/BaseAccountAPI.ts @@ -257,7 +257,7 @@ export abstract class BaseAccountAPI { const partialUserOp: any = { sender: this.getAccountAddress(), - nonce: this.getNonce(), + nonce: info.nonce ?? this.getNonce(), initCode, callData, callGasLimit, diff --git a/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts b/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts index 2c6cccb668..1c0379d19f 100644 --- a/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts +++ b/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts @@ -79,7 +79,7 @@ export class SimpleAccountAPI extends BaseAccountAPI { return BigNumber.from(0) } const accountContract = await this._getAccountContract() - return await accountContract.nonce() + return await accountContract.getNonce() } /** diff --git a/packages/boba/bundler_sdk/src/TransactionDetailsForUserOp.ts b/packages/boba/bundler_sdk/src/TransactionDetailsForUserOp.ts index 1e9beeac25..6419f795f3 100644 --- a/packages/boba/bundler_sdk/src/TransactionDetailsForUserOp.ts +++ b/packages/boba/bundler_sdk/src/TransactionDetailsForUserOp.ts @@ -7,4 +7,5 @@ export interface TransactionDetailsForUserOp { gasLimit?: BigNumberish maxFeePerGas?: BigNumberish maxPriorityFeePerGas?: BigNumberish + nonce?: BigNumberish } diff --git a/packages/boba/bundler_utils/src/ERC4337Utils.ts b/packages/boba/bundler_utils/src/ERC4337Utils.ts index 0432c5b688..95bdb7d627 100644 --- a/packages/boba/bundler_utils/src/ERC4337Utils.ts +++ b/packages/boba/bundler_utils/src/ERC4337Utils.ts @@ -20,12 +20,6 @@ export type NotPromise = { [P in keyof T]: Exclude> } -function encode (typevalues: Array<{ type: string, val: any }>, forSignature: boolean): string { - 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 defaultAbiCoder.encode(types, values) -} - /** * pack the userOperation * @param op @@ -34,73 +28,23 @@ function encode (typevalues: Array<{ type: string, val: any }>, forSignature: bo */ export function packUserOp (op: NotPromise, 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' - } - // console.log('hard-coded userOpType', userOpType) - // console.log('from ABI userOpType', UserOpType) - let encoded = defaultAbiCoder.encode([userOpType as any], [{ - ...op, - signature: '0x' - }]) - // remove leading word (total length) and trailing word (zero-length signature) - encoded = '0x' + encoded.slice(66, encoded.length - 64) - return encoded + return defaultAbiCoder.encode( + ['address', 'uint256', 'bytes32', 'bytes32', + 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', + 'bytes32'], + [op.sender, op.nonce, keccak256(op.initCode), keccak256(op.callData), + op.callGasLimit, op.verificationGasLimit, op.preVerificationGas, op.maxFeePerGas, op.maxPriorityFeePerGas, + keccak256(op.paymasterAndData)]) + } else { + // for the purpose of calculating gas cost encode also signature (and no keccak of bytes) + return defaultAbiCoder.encode( + ['address', 'uint256', 'bytes', 'bytes', + 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', + 'bytes', 'bytes'], + [op.sender, op.nonce, op.initCode, op.callData, + op.callGasLimit, op.verificationGasLimit, op.preVerificationGas, op.maxFeePerGas, op.maxPriorityFeePerGas, + op.paymasterAndData, op.signature]) } - - const typevalues = (UserOpType as any).components.map((c: { name: keyof typeof op, type: string }) => ({ - type: c.type, - val: op[c.name] - })) - return encode(typevalues, forSignature) } /** From 56d8dc9e421fa524a9f171eee28023df4e95d928 Mon Sep 17 00:00:00 2001 From: "Riedl Kevin, Bsc" Date: Fri, 5 May 2023 13:48:11 +0200 Subject: [PATCH 6/9] ValidationManager account for signature expiration (#775) * resolve #753 * Update packages/boba/bundler/src/modules/ValidationManager.ts Co-authored-by: Ino Murko * fix bool * validAfter/Until integrationt tests, validAfter * cleanup * regex * integration_tests * integration & unit tests --------- Co-authored-by: Ino Murko (cherry picked from commit ef02beec28208a47e0f776fe8a3fa8c8d52c04b2) --- .gitignore | 2 + .../eth-l2/boba_aa_sponsoring_fee.spec.ts | 54 +++++++++++++++++++ packages/boba/bundler/src/modules/Types.ts | 1 + .../bundler/src/modules/ValidationManager.ts | 25 +++++---- .../boba/bundler/test/ValidateManager.test.ts | 33 ++++++++++-- 5 files changed, 99 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 3e57c951b5..7c628e8571 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ temp coverage.json *.tsbuildinfo +integration-tests/result/output.xml + env.yml env.yaml .aws-sam/ diff --git a/integration-tests/test/eth-l2/boba_aa_sponsoring_fee.spec.ts b/integration-tests/test/eth-l2/boba_aa_sponsoring_fee.spec.ts index 6f375fecff..a0a5fe26cc 100644 --- a/integration-tests/test/eth-l2/boba_aa_sponsoring_fee.spec.ts +++ b/integration-tests/test/eth-l2/boba_aa_sponsoring_fee.spec.ts @@ -171,5 +171,59 @@ describe('Sponsoring Tx\n', async () => { expect(postUserBalance).to.eq(preUserBalance) expect(postPaymasterDeposit).to.eq(prePaymasterDeposit.sub(logEP.args.actualGasCost)) }) + + it('should not be able to submit a userOp to the bundler and trigger tx when signature expired', async () => { + const validUntil = (await env.l2Provider.getBlock('latest')).timestamp - 300 + const validAfter = (await env.l2Provider.getBlock('latest')).timestamp - 600 + const op = await accountAPI.createSignedUserOp({ + target: recipient.address, + data: recipient.interface.encodeFunctionData('something', ['hello']), + }) + + // add preverificaiton gas to account for paymaster signature + op.paymasterAndData = hexConcat([VerifyingPaymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [validUntil, validAfter]), '0x' + '00'.repeat(65)]) + op.preVerificationGas = BigNumber.from(await op.preVerificationGas).add(3000) + const hash = await VerifyingPaymaster.getHash(op, validUntil, validAfter) + const sig = await offchainSigner.signMessage(utils.arrayify(hash)) + + op.paymasterAndData = hexConcat([VerifyingPaymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [validUntil, validAfter]), sig]) + const res = await VerifyingPaymaster.parsePaymasterAndData(op.paymasterAndData) + + expect(res.signature).to.eq(sig) + expect(res.validAfter).to.eq(validAfter) + expect(res.validUntil).to.eq(validUntil) + signedOp = await accountAPI.signUserOp(op) + + await expect(bundlerProvider.sendUserOpToBundler(signedOp)).to.be.rejectedWith( + Error, /expires too soon/ + ) + }) + + it('should not be able to submit a userOp to the bundler and trigger tx when signature is not valid yet', async () => { + const validUntil = (await env.l2Provider.getBlock('latest')).timestamp + 800 + const validAfter = (await env.l2Provider.getBlock('latest')).timestamp + 600 + const op = await accountAPI.createSignedUserOp({ + target: recipient.address, + data: recipient.interface.encodeFunctionData('something', ['hello']), + }) + + // add preverificaiton gas to account for paymaster signature + op.paymasterAndData = hexConcat([VerifyingPaymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [validUntil, validAfter]), '0x' + '00'.repeat(65)]) + op.preVerificationGas = BigNumber.from(await op.preVerificationGas).add(3000) + const hash = await VerifyingPaymaster.getHash(op, validUntil, validAfter) + const sig = await offchainSigner.signMessage(utils.arrayify(hash)) + + op.paymasterAndData = hexConcat([VerifyingPaymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [validUntil, validAfter]), sig]) + const res = await VerifyingPaymaster.parsePaymasterAndData(op.paymasterAndData) + + expect(res.signature).to.eq(sig) + expect(res.validAfter).to.eq(validAfter) + expect(res.validUntil).to.eq(validUntil) + signedOp = await accountAPI.signUserOp(op) + + await expect(bundlerProvider.sendUserOpToBundler(signedOp)).to.be.rejectedWith( + Error, /not valid yet/ + ) + }) }) }) diff --git a/packages/boba/bundler/src/modules/Types.ts b/packages/boba/bundler/src/modules/Types.ts index d97c4d8340..e71f0f7fda 100644 --- a/packages/boba/bundler/src/modules/Types.ts +++ b/packages/boba/bundler/src/modules/Types.ts @@ -12,6 +12,7 @@ export enum ValidationErrors { InsufficientStake = -32505, UnsupportedSignatureAggregator = -32506, InvalidSignature = -32507, + NotValidYet = -32508, } export enum ExecutionErrors { diff --git a/packages/boba/bundler/src/modules/ValidationManager.ts b/packages/boba/bundler/src/modules/ValidationManager.ts index 79706e394e..a154fc90af 100644 --- a/packages/boba/bundler/src/modules/ValidationManager.ts +++ b/packages/boba/bundler/src/modules/ValidationManager.ts @@ -162,17 +162,20 @@ export class ValidationManager { 'Invalid UserOp signature or paymaster signature', ValidationErrors.InvalidSignature ) -//TODO -// requireCond( -// res.returnInfo.validUntil == null || -// res.returnInfo.validUntil + 30 < Date.now() / 1000, -// 'expires too soon', -// ValidationErrors.ExpiresShortly -// ) -console.log(`res.aggregatorInfo ${res.aggregatorInfo}`) -console.log(res.aggregatorInfo.addr) -console.log(res.aggregatorInfo.stake) -console.log(res.aggregatorInfo.unstakeDelaySec) + requireCond( + res.returnInfo.validUntil == null || + res.returnInfo.validUntil > (Date.now() / 1000) + 30, + 'expires too soon', + ValidationErrors.ExpiresShortly, + ) + requireCond( + res.returnInfo.validAfter == null || + // not adding "buffer" here + res.returnInfo.validAfter < Date.now() / 1000, + 'not valid yet', + ValidationErrors.NotValidYet, + ) + if ( res.aggregatorInfo.addr !== AddressZero && !BigNumber.from(0).eq(res.aggregatorInfo.stake) && diff --git a/packages/boba/bundler/test/ValidateManager.test.ts b/packages/boba/bundler/test/ValidateManager.test.ts index 142938d2d8..0a38211834 100644 --- a/packages/boba/bundler/test/ValidateManager.test.ts +++ b/packages/boba/bundler/test/ValidateManager.test.ts @@ -25,6 +25,7 @@ import { isGeth } from '../src/utils' import { TestRecursionAccount__factory } from '../dist/src/types/factories/contracts/tests/TestRecursionAccount__factory' // import { resolveNames } from './testUtils' import { UserOperation } from '../src/modules/Types' +import { BytesLike } from "ethers"; const cEmptyUserOp: UserOperation = { sender: AddressZero, @@ -51,9 +52,9 @@ describe('#ValidationManager', () => { let rulesAccount: TestRulesAccount let storageAccount: TestStorageAccount - async function testUserOp (validateRule: string = '', pmRule?: string, initFunc?: string, factoryAddress = opcodeFactory.address): Promise { - const userOp = await createTestUserOp(validateRule, pmRule, initFunc, factoryAddress) - return { userOp, ...await vm.validateUserOp(userOp) } + async function testUserOp (validateRule: string = '', pmRule?: string, initFunc?: string, factoryAddress = opcodeFactory.address, paymasterData?: BytesLike[]): Promise { + const userOp = await createTestUserOp(validateRule, pmRule, initFunc, factoryAddress, paymasterData) + return { userOp, ...await vm.validateUserOp(userOp) } } async function testExistingUserOp (validateRule: string = '', pmRule = ''): Promise { @@ -75,7 +76,7 @@ describe('#ValidationManager', () => { } } - async function createTestUserOp (validateRule: string = '', pmRule?: string, initFunc?: string, factoryAddress = opcodeFactory.address): Promise { + async function createTestUserOp (validateRule: string = '', pmRule?: string, initFunc?: string, factoryAddress = opcodeFactory.address, paymasterData?: BytesLike[]): Promise { if (initFunc === undefined) { initFunc = opcodeFactory.interface.encodeFunctionData('create', ['']) } @@ -84,7 +85,11 @@ describe('#ValidationManager', () => { factoryAddress, initFunc ]) - const paymasterAndData = pmRule == null ? '0x' : hexConcat([paymaster.address, Buffer.from(pmRule)]) + + if (!paymasterData || !paymasterData.length) { + paymasterData.push(Buffer.from(pmRule)) + } + const paymasterAndData = pmRule == null ? '0x' : hexConcat([paymaster.address, ...paymasterData ]) const signature = hexlify(Buffer.from(validateRule)) const callinitCodeForAddr = await provider.call({ to: factoryAddress, @@ -184,6 +189,24 @@ describe('#ValidationManager', () => { .catch(e => e) ).to.match(/unstaked account accessed/) }) + it('should fail with expired signature in validation', async () => { + const currBlockTimestamp = (await provider.getBlock()).timestamp + const validUntil = currBlockTimestamp - 300 + const validAfter = currBlockTimestamp - 600 + const paymasterData = [defaultAbiCoder.encode(['uint48', 'uint48'], [validUntil, validAfter]), '0x' + '00'.repeat(65)] + + expect(await testUserOp('expired-sig', undefined, undefined, undefined, paymasterData) + .catch(e => e.message)).to.match(/expires too soon/) + }) + it('should fail with signature that is only valid in future in validation', async () => { + const currBlockTimestamp = (await provider.getBlock()).timestamp + const validUntil = currBlockTimestamp + 800 + const validAfter = currBlockTimestamp + 600 + const paymasterData = [defaultAbiCoder.encode(['uint48', 'uint48'], [validUntil, validAfter]), '0x' + '00'.repeat(65)] + + expect(await testUserOp('not-yet-valid-sig', undefined, undefined, undefined, paymasterData) + .catch(e => e.message)).to.match(/not valid yet/) + }) it('account succeeds referencing its own balance (after wallet creation)', async () => { await testExistingUserOp('balance-self') From 2955c34e582daded8cbeab8255385fac7c2ed4c2 Mon Sep 17 00:00:00 2001 From: "Riedl Kevin, Bsc" Date: Fri, 5 May 2023 14:59:52 +0200 Subject: [PATCH 7/9] npm release workflow for bundler-sdk (#749) (cherry picked from commit 718141f1995fe1892f0b146d119cf9d3a9e3ba8e) --- .github/workflows/aa-bundler-sdk.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/aa-bundler-sdk.yml diff --git a/.github/workflows/aa-bundler-sdk.yml b/.github/workflows/aa-bundler-sdk.yml new file mode 100644 index 0000000000..ffd879dabb --- /dev/null +++ b/.github/workflows/aa-bundler-sdk.yml @@ -0,0 +1,23 @@ +name: Publish AA-Bundler to NPM +on: + release: + types: [created] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: '16.x' + registry-url: 'https://registry.npmjs.org' + - name: Install dependencies and build 🔧 + run: yarn install --frozen-lockfile --legacy-peer-deps && yarn run build + - name: Publish package on NPM 📦 + working-directory: ./packages/boba/bundler_sdk + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_BOBA_FOUNDATION }} From bec1ec1bd1bceba6061993f84745d3eb5bd8cf09 Mon Sep 17 00:00:00 2001 From: Sahil K <86316370+sk-enya@users.noreply.github.com> Date: Sun, 7 May 2023 12:58:33 +0530 Subject: [PATCH 8/9] Fix/banxa and bridges (#772) * adding boba network * fixing boba bridge url * fixing bridge integration * replace code by selectors * remove hardcoded symbol * enable banxa only for mainnet * Available bridge inable only for mainnet * adding support for testnet * update conditional for other bridges * implemented the available bridges with typescript * unit test cases for available bridges * typo in Available bridges --------- Co-authored-by: alvaro-ricotta Co-authored-by: alvaro-ricotta <81116391+alvaro-ricotta@users.noreply.github.com> Co-authored-by: Ino Murko (cherry picked from commit dcf9b7e3b992f88cc3c1b42250fd6ad3db960c31) --- .vscode/settings.json | 2 +- packages/boba/gateway/.gitignore | 2 + .../availableBridges/availableBridges.js | 75 --------- .../availableBridges.styles.js | 36 ----- .../availableBridges/bobaBridges.ts | 44 ++++++ .../src/components/availableBridges/index.tsx | 107 +++++++++++++ .../tests/__snapshots__/index.test.tsx.snap | 108 +++++++++++++ .../availableBridges/tests/index.test.tsx | 142 ++++++++++++++++++ .../src/components/availableBridges/types.ts | 11 ++ packages/boba/gateway/src/components/index.ts | 1 + .../src/containers/bridge/Bridge.styles.js | 26 +--- .../bridge/bobaBridge/bobaBridge.js | 3 +- .../gateway/src/services/networkService.js | 10 -- packages/boba/gateway/src/themes/dark.ts | 5 + packages/boba/gateway/src/themes/light.ts | 5 + packages/boba/gateway/src/util/banxa.ts | 15 ++ packages/boba/gateway/src/util/bobaBridges.js | 65 -------- 17 files changed, 445 insertions(+), 212 deletions(-) delete mode 100644 packages/boba/gateway/src/components/availableBridges/availableBridges.js delete mode 100644 packages/boba/gateway/src/components/availableBridges/availableBridges.styles.js create mode 100644 packages/boba/gateway/src/components/availableBridges/bobaBridges.ts create mode 100644 packages/boba/gateway/src/components/availableBridges/index.tsx create mode 100644 packages/boba/gateway/src/components/availableBridges/tests/__snapshots__/index.test.tsx.snap create mode 100644 packages/boba/gateway/src/components/availableBridges/tests/index.test.tsx create mode 100644 packages/boba/gateway/src/components/availableBridges/types.ts create mode 100644 packages/boba/gateway/src/util/banxa.ts delete mode 100644 packages/boba/gateway/src/util/bobaBridges.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 660b4e5c53..68cc39faf5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "[typescript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, }, "eslint.workingDirectories": [ diff --git a/packages/boba/gateway/.gitignore b/packages/boba/gateway/.gitignore index 770bb50b8b..7afddb8ee8 100644 --- a/packages/boba/gateway/.gitignore +++ b/packages/boba/gateway/.gitignore @@ -1,2 +1,4 @@ .idea coverage +.env.prod +.env.stage diff --git a/packages/boba/gateway/src/components/availableBridges/availableBridges.js b/packages/boba/gateway/src/components/availableBridges/availableBridges.js deleted file mode 100644 index 781f681e17..0000000000 --- a/packages/boba/gateway/src/components/availableBridges/availableBridges.js +++ /dev/null @@ -1,75 +0,0 @@ -import React, { useEffect, useState } from 'react' -import * as S from './availableBridges.styles' -import {useSelector } from 'react-redux' - -import { selectActiveNetwork } from 'selectors' - -import { Link, Typography } from '@mui/material' - -import networkService from 'services/networkService' -import { BANXA_URL } from 'util/constant' -import { NETWORK } from 'util/network/network.util' - -function AvailableBridges({ token = null, walletAddress = "" }) { - const currentNetwork = useSelector(selectActiveNetwork()); - const isAvailableOnBanxa = token?.symbol === 'ETH' || token?.symbol === 'BOBA' - - const [ bridges, setBridges ] = useState([]) - const banxaUrl = () => { - const banxaUrl = BANXA_URL; - const config = { - coinType: token?.symbol, - fiatType: 'USD', - fiatAmount: '', - blockChain: 'Boba Network', - walletAddress: walletAddress - } - return `${banxaUrl}coinType=${config.coinType}&fiatType=${config.fiatType}&blockchain=${config.blockChain}&walletAddress=${walletAddress}` - } - - useEffect(() => { - if (token) { - let res = networkService.getTokenSpecificBridges(token.symbol) - setBridges(res) - } - }, [ token ]) - - return - - - Third party bridges - - - - {currentNetwork === NETWORK.ETHEREUM && isAvailableOnBanxa && ( - - - Banxa - - - ) - } - {bridges.map((bridge) => { - return - - {bridge.name} - - - })} - - -} - -export default React.memo(AvailableBridges) diff --git a/packages/boba/gateway/src/components/availableBridges/availableBridges.styles.js b/packages/boba/gateway/src/components/availableBridges/availableBridges.styles.js deleted file mode 100644 index c88b2433f8..0000000000 --- a/packages/boba/gateway/src/components/availableBridges/availableBridges.styles.js +++ /dev/null @@ -1,36 +0,0 @@ -import { Box, styled } from "@mui/material" - -export const BridgesContainer = styled(Box)(({ theme }) => ({ - background: theme.palette.background.glassy, - filter: 'drop-shadow(0px 4px 20px rgba(35, 92, 41, 0.06))', - borderRadius: '20px', - border: 'none', - backdropFilter: 'blur(50px)', - flex: 1, - minHeight: 'fit-content', - padding: '24px', - width: '100%', - maxWidth: '600px', -})) - -export const Wrapper = styled(Box)(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', - gap: '5px' -})) - -export const LabelContainer = styled(Box)(({ theme }) => ({ - width: '100%', - display: 'flex', - justifyContent: 'space-around', - gap: '10px', - alignItems: 'center', - margin: '10px 0px' -})) - -export const BridgeContent = styled(Box)(({ theme }) => ({ - borderRadius: theme.palette.primary.borderRadius, - background: theme.palette.background.secondaryLight, - padding: '5px 10px', - border: theme.palette.primary.border, -})) diff --git a/packages/boba/gateway/src/components/availableBridges/bobaBridges.ts b/packages/boba/gateway/src/components/availableBridges/bobaBridges.ts new file mode 100644 index 0000000000..2b78a3cf7e --- /dev/null +++ b/packages/boba/gateway/src/components/availableBridges/bobaBridges.ts @@ -0,0 +1,44 @@ +import { IBridges } from './types' + +export const bobaBridges: IBridges[] = [ + { + name: 'Synapse', + type: 'SYNAPSE', + link: 'https://synapseprotocol.com/', + tokens: ['ETH', 'nETH', 'gOHM', 'DAI', 'USDC', 'USDT', 'SYN', 'nUSD'], + }, + { + name: 'Anyswap', + type: 'ANYSWAP', + link: 'https://anyswap.exchange/#/router', + tokens: ['MIM', 'AVAX', 'FRAX', 'FTM', 'FXS', 'MATIC'], + }, + { + name: 'Celer', + type: 'CELER', + link: 'https://cbridge.celer.network/#/transfer', + tokens: ['ETH', 'BOBA', 'FRAX', 'OLO'], + }, + { + name: 'BoringDAO', + type: 'BORINGDAO', + link: 'https://oportal.boringdao.com/twoway', + tokens: ['USDT'], + }, + { + name: 'PolyBridge', + type: 'POLYBRIDGE', + link: 'https://bridge.poly.network/', + tokens: ['BOBA'], + }, + { + name: 'Symbiosis', + type: 'SYMBIOSIS', + link: 'https://app.symbiosis.finance/swap', + tokens: ['USDC'], + }, +] + +export const bridgeByToken = (symbol: string) => { + return bobaBridges.filter((bridge) => bridge.tokens.includes(symbol)) +} diff --git a/packages/boba/gateway/src/components/availableBridges/index.tsx b/packages/boba/gateway/src/components/availableBridges/index.tsx new file mode 100644 index 0000000000..d30f7a4ad6 --- /dev/null +++ b/packages/boba/gateway/src/components/availableBridges/index.tsx @@ -0,0 +1,107 @@ +import { Text } from 'components/global/text' +import React, { useEffect, useState } from 'react' +import styled from 'styled-components' +import { bridgeByToken } from './bobaBridges' +import { useSelector } from 'react-redux' +import { selectActiveNetwork, selectActiveNetworkType } from 'selectors' +import { NETWORK, NETWORK_TYPE } from 'util/network/network.util' +import { prepareBanxaUrl } from 'util/banxa' +import { AvailableBridgesProps, IBridges } from './types' + +const AvailableBridgeContainer = styled.div(({ theme }) => ({ + background: theme.bg.glassy, + filter: 'drop-shadow(0px 4px 20px rgba(35, 92, 41, 0.06))', + borderRadius: '20px', + border: 'none', + backdropFilter: 'blur(50px)', + flex: 1, + minHeight: 'fit-content', + padding: '20px 24px', + width: '100%', + maxWidth: '600px', +})) + +const AvailableBridgeTitle = styled(Text)` + font-size: 1rem; + margin-bottom: 10px; +` + +const Link = styled.a` + text-decoration: none; + color: inherit; +` + +const AvailableBridgeContent = styled(Text)(({ theme }) => ({ + borderRadius: '12px', + background: theme.bg.secondary, + border: theme.border, + lineHeight: 1.5, + padding: '10px', + marginBottom: '5px', + fontSize: '1rem', +})) + +export const AvailableBridges = ({ + token = null, + walletAddress = '', +}: AvailableBridgesProps) => { + const [bridges, setbridges] = useState([]) + const [banxaUrl, setBanxaUrl] = useState('') + const [isBanxaEnable, setIsBanxaEnable] = useState(false) + + const networkType = useSelector(selectActiveNetworkType()) + const network = useSelector(selectActiveNetwork()) + + useEffect(() => { + if (token) { + const _bridges = bridgeByToken(token?.symbol) + setbridges(_bridges) + + if (token?.symbol === 'ETH' || token?.symbol === 'BOBA') { + const _banxaUrl = prepareBanxaUrl({ + symbol: token.symbol, + address: walletAddress, + }) + setBanxaUrl(_banxaUrl) + setIsBanxaEnable(true) + } + } + }, [token, walletAddress]) + + if ( + !token || + networkType === NETWORK_TYPE.TESTNET || + network !== NETWORK.ETHEREUM || + (!isBanxaEnable && !bridges.length) + ) { + return <> + } + + return ( + + Third party bridges + {network === NETWORK.ETHEREUM && isBanxaEnable && ( + + Banxa + + )} + {bridges.map((bridge: any) => ( + + {bridge.name} + + ))} + + ) +} diff --git a/packages/boba/gateway/src/components/availableBridges/tests/__snapshots__/index.test.tsx.snap b/packages/boba/gateway/src/components/availableBridges/tests/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..4c956f0015 --- /dev/null +++ b/packages/boba/gateway/src/components/availableBridges/tests/__snapshots__/index.test.tsx.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Avaialble Bridges should match snapshot connected on mainnet 1`] = ` + + .c1 { + padding: 0px; + margin: 0px; + font-weight: 400; + font-size: 0.9rem; + line-height: 1.25; +} + +.c0 { + background: rgba(255,255,255,0.04); + -webkit-filter: drop-shadow(0px 4px 20px rgba(35,92,41,0.06)); + filter: drop-shadow(0px 4px 20px rgba(35,92,41,0.06)); + border-radius: 20px; + border: none; + -webkit-backdrop-filter: blur(50px); + backdrop-filter: blur(50px); + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + min-height: -webkit-fit-content; + min-height: -moz-fit-content; + min-height: fit-content; + padding: 20px 24px; + width: 100%; + max-width: 600px; +} + +.c2 { + font-size: 1rem; + margin-bottom: 10px; +} + +.c3 { + -webkit-text-decoration: none; + text-decoration: none; + color: inherit; +} + +.c4 { + border-radius: 12px; + background: rgba(255,255,255,0.14); + border: solid 1px #2d2f3a; + line-height: 1.5; + padding: 10px; + margin-bottom: 5px; + font-size: 1rem; +} + +
+

+ Third party bridges +

+ +

+ Banxa +

+
+ +

+ Synapse +

+
+ +

+ Celer +

+
+
+
+`; + +exports[`Avaialble Bridges should match snapshot network type is TESTNET 1`] = ``; + +exports[`Avaialble Bridges should match snapshot selected network is not ETHEREUM 1`] = ``; + +exports[`Avaialble Bridges should match snapshot token value is null 1`] = ``; diff --git a/packages/boba/gateway/src/components/availableBridges/tests/index.test.tsx b/packages/boba/gateway/src/components/availableBridges/tests/index.test.tsx new file mode 100644 index 0000000000..85d324a575 --- /dev/null +++ b/packages/boba/gateway/src/components/availableBridges/tests/index.test.tsx @@ -0,0 +1,142 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureStore from 'redux-mock-store' +import Theme from 'themes' +import { AvailableBridges } from '..' +import { NETWORK, NETWORK_TYPE } from 'util/network/network.util' + +const mockStore = configureStore() + +const renderAvailableBridges = ({ + token, + walletAddress, + options = null, +}: any) => { + return render( + + + + + + ) +} + +describe('Available Bridges', () => { + let token = { + symbol: 'ETH', + } + const walletAddress = 'random-code' + + describe('should match snapshot', () => { + test('token value is null', () => { + const { asFragment } = renderAvailableBridges({ + token: null, + walletAddress, + }) + expect(asFragment()).toMatchSnapshot() + }) + test('network type is TESTNET', () => { + const { asFragment } = renderAvailableBridges({ + token: null, + walletAddress, + options: { + store: { + network: { + activeNetwork: NETWORK.ETHEREUM, + activeNetworkType: NETWORK_TYPE.TESTNET, + }, + }, + }, + }) + expect(asFragment()).toMatchSnapshot() + }) + test('selected network is not ETHEREUM', () => { + const { asFragment } = renderAvailableBridges({ + token: null, + walletAddress, + options: { + store: { + network: { + activeNetwork: NETWORK.BNB, + activeNetworkType: NETWORK_TYPE.TESTNET, + }, + }, + }, + }) + expect(asFragment()).toMatchSnapshot() + }) + + test('connected on mainnet', () => { + const { asFragment } = renderAvailableBridges({ + token, + walletAddress, + }) + expect(asFragment()).toMatchSnapshot() + }) + }) + + test('should see banxa for ETH token on mainnet', () => { + renderAvailableBridges({ + token, + walletAddress, + }) + expect(screen.getByTestId('banxa')).toBeInTheDocument() + }) + test('should see banxa for BOBA token on mainnet', () => { + token = { + symbol: 'BOBA', + } + renderAvailableBridges({ + token, + walletAddress, + }) + expect(screen.getByTestId('banxa')).toBeInTheDocument() + }) + + test('should not see banxa for other token on mainnet', () => { + token = { + symbol: 'USDC', + } + renderAvailableBridges({ + token, + walletAddress, + }) + expect(screen.queryByTestId('banxa')).not.toBeInTheDocument() + }) + + describe('Bridges should render correct for token', () => { + test('should 2 bridge for ETH ', () => { + renderAvailableBridges({ + token, + walletAddress, + }) + expect(screen.queryAllByTestId('bridge').length).toBe(2) + }) + test('should 2 bridge for BOBA ', () => { + renderAvailableBridges({ + token: { symbol: 'BOBA' }, + walletAddress, + }) + expect(screen.queryAllByTestId('bridge').length).toBe(2) + }) + test('should 2 bridge for USDC ', () => { + renderAvailableBridges({ + token: { symbol: 'USDC' }, + walletAddress, + }) + expect(screen.queryAllByTestId('bridge').length).toBe(2) + }) + }) +}) diff --git a/packages/boba/gateway/src/components/availableBridges/types.ts b/packages/boba/gateway/src/components/availableBridges/types.ts new file mode 100644 index 0000000000..60c5d9cafb --- /dev/null +++ b/packages/boba/gateway/src/components/availableBridges/types.ts @@ -0,0 +1,11 @@ +export interface IBridges { + name: string + type: string + link: string + tokens: string[] +} + +export interface AvailableBridgesProps { + token?: any | null // FIXME: fix the type of token + walletAddress: string +} diff --git a/packages/boba/gateway/src/components/index.ts b/packages/boba/gateway/src/components/index.ts index 9b1f899900..9e347c0a5d 100644 --- a/packages/boba/gateway/src/components/index.ts +++ b/packages/boba/gateway/src/components/index.ts @@ -4,4 +4,5 @@ export { default as CounterButton } from 'components/counterButton/CounterButton export { default as Pager } from 'components/pager/Pager' export { default as PageTitle } from 'components/pageTitle/PageTitle' +export * from 'components/availableBridges' export * from 'components/mainMenu/' diff --git a/packages/boba/gateway/src/containers/bridge/Bridge.styles.js b/packages/boba/gateway/src/containers/bridge/Bridge.styles.js index d74ffe9741..b45141beec 100644 --- a/packages/boba/gateway/src/containers/bridge/Bridge.styles.js +++ b/packages/boba/gateway/src/containers/bridge/Bridge.styles.js @@ -12,33 +12,11 @@ export const PageContainer = styled(Box)(({ theme }) => ({ width: '70%', [ theme.breakpoints.between('md', 'lg') ]: { width: '90%', - padding: '0px', - '::after': { - content: '" "', - position: 'absolute', - left: '-5%', - bottom: '10%', - width: '130%', - height: '100%', - background: `url(${bobaBridgeBg}) no-repeat`, - backgroundSize: '85%', - zIndex: '-1', - } + padding: '0px' }, [ theme.breakpoints.between('sm', 'md') ]: { width: '90%', - padding: '0px', - '::after': { - content: '" "', - position: 'absolute', - left: '-5%', - bottom: '10%', - width: '130%', - height: '100%', - background: `url(${bobaBridgeBg}) no-repeat`, - backgroundSize: '85%', - zIndex: '-1', - } + padding: '0px' }, [ theme.breakpoints.down('sm') ]: { width: '100%', diff --git a/packages/boba/gateway/src/containers/bridge/bobaBridge/bobaBridge.js b/packages/boba/gateway/src/containers/bridge/bobaBridge/bobaBridge.js index c4a8f85125..b0698d21ee 100644 --- a/packages/boba/gateway/src/containers/bridge/bobaBridge/bobaBridge.js +++ b/packages/boba/gateway/src/containers/bridge/bobaBridge/bobaBridge.js @@ -23,7 +23,6 @@ import { useNavigate } from 'react-router-dom' import { Box, Typography, Switch, useTheme } from '@mui/material' import Button from 'components/button/Button.js' -import AvailableBridges from 'components/availableBridges/availableBridges.js' import * as S from './bobaBridge.styles' @@ -46,6 +45,8 @@ import { setConnectETH, setConnectBOBA } from 'actions/setupAction' import { L1_ICONS, L2_ICONS } from 'util/network/network.util' import { DEFAULT_NETWORK, LAYER } from 'util/constant' +import { AvailableBridges } from 'components' + function BobaBridge() { const layer = useSelector(selectLayer()) diff --git a/packages/boba/gateway/src/services/networkService.js b/packages/boba/gateway/src/services/networkService.js index f542ed750a..7a61368572 100644 --- a/packages/boba/gateway/src/services/networkService.js +++ b/packages/boba/gateway/src/services/networkService.js @@ -91,7 +91,6 @@ import GraphQLService from "./graphql.service" import tokenInfo from "@boba/register/addresses/tokenInfo" -import { bobaBridges } from 'util/bobaBridges' import { MIN_NATIVE_L1_BALANCE } from 'util/constant' import { getPoolDetail } from 'util/poolDetails' import { pingRpcUrl, getNetworkDetail, NETWORK, NETWORK_TYPE } from 'util/network/network.util' @@ -4050,15 +4049,6 @@ class NetworkService { return ethers.utils.formatEther(await L2BillingContract.exitFee()) } - - /***********************************************/ - /***** Boba Bridges *****/ - /***********************************************/ - - getTokenSpecificBridges(tokenSymbol) { - return bobaBridges.filter((bridge) => bridge.tokens.includes(tokenSymbol)) - } - /***********************************************/ /***** VeBoba *****/ /***********************************************/ diff --git a/packages/boba/gateway/src/themes/dark.ts b/packages/boba/gateway/src/themes/dark.ts index 552cbcd9ea..013cf0b490 100644 --- a/packages/boba/gateway/src/themes/dark.ts +++ b/packages/boba/gateway/src/themes/dark.ts @@ -6,6 +6,11 @@ const dark = { primaryfg: '#000000', //cyan screen, warning: 'yellow', + border: 'solid 1px #2d2f3a', + bg: { + glassy: 'rgba(255, 255, 255, 0.04)', + secondary: 'rgba(255, 255, 255, 0.14)', + }, } export default dark diff --git a/packages/boba/gateway/src/themes/light.ts b/packages/boba/gateway/src/themes/light.ts index 7f09723144..6b14eafca3 100644 --- a/packages/boba/gateway/src/themes/light.ts +++ b/packages/boba/gateway/src/themes/light.ts @@ -6,6 +6,11 @@ const light = { primaryfg: '#000000', screen, warning: 'yellow', + border: 'solid 1px rgba(0, 0, 0, 0.12)', + bg: { + glassy: 'rgba(0,0,0, 0.09)', + secondary: 'rgba(0, 0, 0, 0.08)', + }, } export default light diff --git a/packages/boba/gateway/src/util/banxa.ts b/packages/boba/gateway/src/util/banxa.ts new file mode 100644 index 0000000000..22fe4f94b5 --- /dev/null +++ b/packages/boba/gateway/src/util/banxa.ts @@ -0,0 +1,15 @@ +import { BANXA_URL } from './constant' + +interface Props { + symbol: string + address: string +} + +const FIAT_TYPE: string = 'USD' +const BLOCK_CHAIN: string = 'Boba Network' + +export const prepareBanxaUrl = ({ symbol, address }: Props) => { + const endpoint = BANXA_URL + + return `${endpoint}coinType=${symbol}&fiatType=${FIAT_TYPE}&blockchain=${BLOCK_CHAIN}&walletAddress=${address}` +} diff --git a/packages/boba/gateway/src/util/bobaBridges.js b/packages/boba/gateway/src/util/bobaBridges.js deleted file mode 100644 index 2bcf984116..0000000000 --- a/packages/boba/gateway/src/util/bobaBridges.js +++ /dev/null @@ -1,65 +0,0 @@ -export const bobaBridges = [ - { - "name": "Synapse", - "type": "SYNAPSE", - "link": "https://synapseprotocol.com/", - "tokens": [ - "ETH", - "nETH", - "gOHM", - "DAI", - "USDC", - "USDT", - "SYN", - "nUSD" - ] - }, - { - "name": "Anyswap", - "type": "ANYSWAP", - "link": "https://anyswap.exchange/#/router", - "tokens": [ - "MIM", - "AVAX", - "FRAX", - "FTM", - "FXS", - "MATIC" - ] - }, - { - "name": "Celer", - "type": "CELER", - "link": "https://cbridge.celer.network/#/transfer", - "tokens": [ - "ETH", - "BOBA", - "FRAX", - "OLO" - ] - }, - { - "name": "BoringDAO", - "type": "BORINGDAO", - "link": "https://oportal.boringdao.com/twoway", - "tokens": [ - "USDT" - ] - }, - { - "name": "PolyBridge", - "type": "POLYBRIDGE", - "link": "https://bridge.poly.network/", - "tokens": [ - "BOBA" - ] - }, - { - "name": "Symbiosis", - "type": "SYMBIOSIS", - "link": "https://app.symbiosis.finance/swap", - "tokens": [ - "USDC" - ] - } -] From 17d82fa3b2527fa8e9831a56857e3955ec8e89c0 Mon Sep 17 00:00:00 2001 From: Souradeep Das Date: Mon, 8 May 2023 18:05:24 +0530 Subject: [PATCH 9/9] add validation of entryPoint and wrapper (#779) * add validaiton of entryPoint and wrapper (cherry picked from commit 41f3150d70d2ec93faa76b234736fc357cc96c67) --- packages/boba/bundler/src/BundlerServer.ts | 14 ++++++------- packages/boba/bundler/src/runBundler.ts | 23 +++++++++++++++++----- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/boba/bundler/src/BundlerServer.ts b/packages/boba/bundler/src/BundlerServer.ts index 8db931e3b8..6a200781c8 100644 --- a/packages/boba/bundler/src/BundlerServer.ts +++ b/packages/boba/bundler/src/BundlerServer.ts @@ -15,7 +15,7 @@ import { BundlerConfig } from './BundlerConfig' import { UserOpMethodHandler } from './UserOpMethodHandler' import { Server } from 'http' import { RpcError } from './utils' -import { UserOperationStruct } from '@boba/accountabstraction' +import { EntryPointWrapper__factory, EntryPointWrapper, UserOperationStruct } from '@boba/accountabstraction' import { DebugMethodHandler } from './DebugMethodHandler' import Debug from 'debug' @@ -80,13 +80,11 @@ export class BundlerServer { maxPriorityFeePerGas: 0, signature: '0x', } - // TODO: https://github.com/bobanetwork/boba/issues/759 - // // await EntryPoint__factory.connect(this.config.entryPoint,this.provider).callStatic.addStake(0) - // const err = await EntryPoint__factory.connect(this.config.entryPoint, this.provider).callStatic.simulateValidation(emptyUserOp) - // .catch(e => e) - // if (err?.errorName !== 'FailedOp') { - // this.fatal(`Invalid entryPoint contract at ${this.config.entryPoint}. wrong version?`) - // } + const ret = await EntryPointWrapper__factory.connect(this.config.entryPointWrapper, this.wallet).callStatic.simulateValidation(emptyUserOp) + const [failedOpStatus, _]: [EntryPointWrapper.FailedOpStatusStructOutput, any] = ret + if (failedOpStatus?.status !== true) { + this.fatal(`Invalid entryPoint contract at ${this.config.entryPoint}. wrong version?`) + } const bal = await this.provider.getBalance(this.wallet.address) console.log( 'signer', diff --git a/packages/boba/bundler/src/runBundler.ts b/packages/boba/bundler/src/runBundler.ts index 77d54df3d0..09c1d84141 100644 --- a/packages/boba/bundler/src/runBundler.ts +++ b/packages/boba/bundler/src/runBundler.ts @@ -33,10 +33,12 @@ export let showStackTraces = false export async function connectContracts( wallet: Wallet, - entryPointAddress: string -): Promise { + entryPointAddress: string, + entryPointWrapperAddress: string +): Promise<{ entryPoint: EntryPoint, entryPointWrapper: EntryPointWrapper }> { const entryPoint = EntryPoint__factory.connect(entryPointAddress, wallet) - return entryPoint + const entryPointWrapper = EntryPointWrapper__factory.connect(entryPointWrapperAddress, wallet) + return { entryPoint, entryPointWrapper } } export async function connectContractsViaAddressManager ( @@ -48,7 +50,7 @@ export async function connectContractsViaAddressManager ( const entryPointWrapperAddress = await addressManager.getAddress('L2_EntryPointWrapper') const entryPoint = EntryPoint__factory.connect(entryPointAddress, wallet) const entryPointWrapper = EntryPointWrapper__factory.connect(entryPointWrapperAddress, wallet) - return { entryPoint: entryPoint, entryPointWrapper } + return { entryPoint, entryPointWrapper } } function getAddressManager (provider: any, addressManagerAddress: any): ethers.Contract { @@ -176,8 +178,19 @@ export async function runBundler( entryPoint = eP entryPointWrapper = epW } else { - const eP = await connectContracts(wallet, config.entryPoint) + const { entryPoint: eP, entryPointWrapper: epW } = await connectContracts(wallet, config.entryPoint, config.entryPointWrapper) + config.entryPoint = eP.address entryPoint = eP + config.entryPointWrapper = epW.address + entryPointWrapper = epW + } + + const entryPointFromWrapper = await entryPointWrapper.entryPoint() + if ( + entryPointFromWrapper.toLowerCase() !== entryPoint.address.toLowerCase() + ) { + console.error('WARN: entryPointWrapper may be incompatible with entryPoint') + process.exit(1) } // bundleSize=1 replicate current immediate bundling mode const execManagerConfig = {