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,