diff --git a/.circleci/config.yml b/.circleci/config.yml index a424f7feb0..f56db363a5 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 bundler + docker-compose -f <> -f docker-compose-side.yml up -d bobalink aa_deployer working_directory: ops - run: name: Start background logging @@ -254,5 +254,5 @@ workflows: name: proxyd-tests binary_name: proxyd working_directory: go/proxyd - - test_flow_bundler_and_depcheck + # - test_flow_bundler_and_depcheck diff --git a/integration-tests/contracts/SampleRecipient.sol b/integration-tests/contracts/SampleRecipient.sol new file mode 100644 index 0000000000..61fbe2d26a --- /dev/null +++ b/integration-tests/contracts/SampleRecipient.sol @@ -0,0 +1,20 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@boba/accountabstraction/contracts/samples/SimpleAccount.sol"; + +contract SampleRecipient { + + SimpleAccount account; + + event Sender(address txOrigin, address msgSender, string message); + + function something(string memory message) public { + emit Sender(tx.origin, msg.sender, message); + } + + // solhint-disable-next-line + function reverting() public { + revert( "test revert"); + } +} diff --git a/integration-tests/hardhat.config.ts b/integration-tests/hardhat.config.ts index 6fb93934bc..a45fab8810 100644 --- a/integration-tests/hardhat.config.ts +++ b/integration-tests/hardhat.config.ts @@ -50,6 +50,17 @@ const config: HardhatUserConfig = { }, }, }, + { + version: '0.8.12', // Required for WETH9 + settings: { + optimizer: { enabled: true, runs: 10_000 }, + outputSelection: { + '*': { + '*': ['storageLayout'], + }, + }, + }, + }, ], }, } 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 new file mode 100644 index 0000000000..b9f6191e08 --- /dev/null +++ b/integration-tests/test/eth-l2/boba_aa_fee_alt_token.spec.ts @@ -0,0 +1,220 @@ +// 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 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 new file mode 100644 index 0000000000..c6e33d2eba --- /dev/null +++ b/integration-tests/test/eth-l2/boba_aa_fee_boba.spec.ts @@ -0,0 +1,223 @@ +// 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 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 new file mode 100644 index 0000000000..116478a49d --- /dev/null +++ b/integration-tests/test/eth-l2/boba_aa_sponsoring_fee.spec.ts @@ -0,0 +1,158 @@ +// 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)) +// }) +// }) +// }) diff --git a/integration-tests/test/eth-l2/boba_aa_wallet.spec.ts b/integration-tests/test/eth-l2/boba_aa_wallet.spec.ts new file mode 100644 index 0000000000..8ff2802f90 --- /dev/null +++ b/integration-tests/test/eth-l2/boba_aa_wallet.spec.ts @@ -0,0 +1,209 @@ +// 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') +// }) +// }) diff --git a/integration-tests/test/eth-l2/shared/env.ts b/integration-tests/test/eth-l2/shared/env.ts index dc42ff71c9..e98213dad0 100644 --- a/integration-tests/test/eth-l2/shared/env.ts +++ b/integration-tests/test/eth-l2/shared/env.ts @@ -31,7 +31,9 @@ import { getL1Bridge, getBASEDeployerAddresses, getBOBADeployerAddresses, + getAABOBADeployerAddresses, envConfig, + BUNDLER_URL, } from './utils' export interface CrossDomainMessagePair { @@ -46,8 +48,11 @@ export class OptimismEnv { // L1 Contracts addressesBASE: any addressesBOBA: any + addressesAABOBA: any l1Bridge: Contract + bundlerUrl: string + // L2 Contracts ovmEth: Contract @@ -73,6 +78,7 @@ export class OptimismEnv { constructor(args: any) { this.addressesBASE = args.addressesBASE this.addressesBOBA = args.addressesBOBA + this.addressesAABOBA = args.addressesAABOBA this.l1Bridge = args.l1Bridge this.ovmEth = args.ovmEth this.l1Wallet = args.l1Wallet @@ -90,11 +96,15 @@ export class OptimismEnv { this.l2Provider = args.l2Provider this.verifierProvider = args.verifierProvider this.replicaProvider = args.replicaProvider + this.bundlerUrl = args.bundlerUrl } static async new(): Promise { const addressesBASE = await getBASEDeployerAddresses() const addressesBOBA = await getBOBADeployerAddresses() + const addressesAABOBA = await getAABOBADeployerAddresses() + + const bundlerUrl = BUNDLER_URL const l1Bridge = await getL1Bridge( l1Wallet, @@ -130,6 +140,7 @@ export class OptimismEnv { return new OptimismEnv({ addressesBASE, addressesBOBA, + addressesAABOBA, messenger, messengerFast, ovmEth, @@ -147,6 +158,7 @@ export class OptimismEnv { verifierProvider, replicaProvider, l1Bridge, + bundlerUrl, }) } diff --git a/integration-tests/test/eth-l2/shared/utils.ts b/integration-tests/test/eth-l2/shared/utils.ts index f50fbd9efa..0c5086bd56 100644 --- a/integration-tests/test/eth-l2/shared/utils.ts +++ b/integration-tests/test/eth-l2/shared/utils.ts @@ -199,6 +199,26 @@ if (!process.env.BASE_URL) { export const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:8080/addresses.json' +if (!process.env.AA_BOBA_URL) { + console.log(`!!You did not set process.env.AA_BOBA_URL!!`) + console.log(`Setting to default value of http://127.0.0.1:8080/aa-addr.json`) +} else { + console.log(`process.env.AA_BOBA_URL set to:`, process.env.AA_BOBA_URL) +} + +export const AA_BOBA_URL = + process.env.AA_BOBA_URL || 'http://127.0.0.1:8080/aa-addr.json' + +if (!process.env.BUNDLER_URL) { + console.log(`!!You did not set process.env.BUNDLER_URL!!`) + console.log(`Setting to default value of http://localhost:3000/rpc`) +} else { + console.log(`process.env.BUNDLER_URL set to:`, process.env.BUNDLER_URL) +} + +export const BUNDLER_URL = + process.env.BUNDLER_URL || 'http://localhost:3000/rpc' + // Gets the bridge contract export const getL1Bridge = async (wallet: Wallet, bridgeAddress: string) => { const l1BridgeInterface = getContractInterface('L1StandardBridge') @@ -349,6 +369,14 @@ export const getBOBADeployerAddresses = async () => { return JSON.parse(result) } +export const getAABOBADeployerAddresses = async () => { + const options = { + uri: AA_BOBA_URL, + } + const result = await request.get(options) + return JSON.parse(result) +} + export const expectLogs = async ( receipt, emitterAbi, @@ -415,7 +443,7 @@ export const getFilteredLogIndex = async ( (log) => log.topics.length > 0 && log.topics[0] === eventTopic && - (!emitterAddress || log.address === emitterAddress) + (!emitterAddress || log.address.toLowerCase() === emitterAddress.toLowerCase()) ) return filteredLogs[0].logIndex diff --git a/ops/docker-compose-side.yml b/ops/docker-compose-side.yml index 9694efa62c..edb058595c 100644 --- a/ops/docker-compose-side.yml +++ b/ops/docker-compose-side.yml @@ -122,39 +122,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 + # ENTRYPOINT: "0x" + # BENEFICIARY: "0xcd3b766ccdd6ae721141f452c550ca635964ce71" + # HELPER: "0x" + # mem_limit: 300M + # logging: + # driver: "json-file" + # options: + # max-size: 10m + # max-file: "10" + networks: default: name: local-network diff --git a/ops/docker-compose.yml b/ops/docker-compose.yml index 7d4cb8b457..34efec5191 100644 --- a/ops/docker-compose.yml +++ b/ops/docker-compose.yml @@ -330,6 +330,8 @@ services: URL: http://dtl:8081/addresses.json BASE_URL: http://dtl:8081/addresses.json BOBA_URL: http://dtl:8081/boba-addr.json + AA_BOBA_URL: http://dtl:8081/aa-addr.json + BUNDLER_URL: http://bundler:3000/rpc ENABLE_GAS_REPORT: 1 NO_NETWORK: 1 RETRIES: 200 diff --git a/ops/docker/Dockerfile.packages b/ops/docker/Dockerfile.packages index 75c7e40a70..511f07bcd6 100644 --- a/ops/docker/Dockerfile.packages +++ b/ops/docker/Dockerfile.packages @@ -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 @@ -126,6 +126,10 @@ FROM packages as integration-tests WORKDIR /opt/optimism/ 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 WORKDIR /opt/optimism/integration-tests COPY ./ops/scripts/integration-tests.sh ./ CMD ["yarn", "test:integration"] @@ -174,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/integration-tests.sh b/ops/scripts/integration-tests.sh index 07146dedf0..4155de502d 100755 --- a/ops/scripts/integration-tests.sh +++ b/ops/scripts/integration-tests.sh @@ -15,6 +15,12 @@ if [[ ! -z "$BOBA_URL" ]]; then ADDRESSES=$(curl --fail --show-error --silent --retry-connrefused --retry $RETRIES --retry-delay 5 $BOBA_URL) echo $ADDRESSES | jq -r '.L2LiquidityPool' fi +echo "Calling: "$AA_BOBA_URL +if [[ ! -z "$AA_BOBA_URL" ]]; then + # get the addrs from the URL provided + ADDRESSES=$(curl --fail --show-error --silent --retry-connrefused --retry $RETRIES --retry-delay 5 $AA_BOBA_URL) + echo $ADDRESSES | jq -r '.L2_BOBA_EntryPoint' +fi echo "Calling: "$L2_URL # wait for the sequencer to be up curl \ diff --git a/ops_boba/monitor/package.json b/ops_boba/monitor/package.json index 322dad45c0..cf927b7216 100644 --- a/ops_boba/monitor/package.json +++ b/ops_boba/monitor/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "start": "node ./exec/run-monitor.js", - "test": "hardhat test --show-stack-traces" + "test": "hardhat test --show-stack-traces", + "test:coverage": "hardhat test --show-stack-traces" }, "license": "MIT", "dependencies": { diff --git a/packages/boba/account-abstraction/.solcover.js b/packages/boba/account-abstraction/.solcover.js index a64b8abd1a..186efeafd1 100644 --- a/packages/boba/account-abstraction/.solcover.js +++ b/packages/boba/account-abstraction/.solcover.js @@ -1,9 +1,10 @@ module.exports = { skipFiles: [ "test", - "bls/lib", + "samples/bls/lib", //solc-coverage fails to compile our Manager module. - "gnosis", - "samples/SimpleWalletForTokens.sol" + "samples/gnosis", + "utils/Exec.sol" ], + configureYulOptimizer: true, }; diff --git a/packages/boba/account-abstraction/.solhintignore b/packages/boba/account-abstraction/.solhintignore index c6681396b1..4972aa1be6 100644 --- a/packages/boba/account-abstraction/.solhintignore +++ b/packages/boba/account-abstraction/.solhintignore @@ -1 +1 @@ -contracts/bls/lib/ +contracts/samples/bls/lib/ diff --git a/packages/boba/account-abstraction/contracts/bls/BLSAccount.sol b/packages/boba/account-abstraction/contracts/bls/BLSAccount.sol deleted file mode 100644 index c4fc3d73ae..0000000000 --- a/packages/boba/account-abstraction/contracts/bls/BLSAccount.sol +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity ^0.8.12; - -import "../samples/SimpleAccount.sol"; -import "./IBLSAccount.sol"; - -/** - * Minimal BLS-based account that uses an aggregated signature. - * The account must maintain its own BLS public-key, and expose its trusted signature aggregator. - * Note that unlike the "standard" SimpleAccount, this account can't be called directly - * (normal SimpleAccount uses its "signer" address as both the ecrecover signer, and as a legitimate - * Ethereum sender address. Obviously, a BLS public is not a valid Ethereum sender address.) - */ -contract BLSAccount is SimpleAccount, IBLSAccount { - address public immutable aggregator; - uint256[4] private publicKey; - - constructor(IEntryPoint anEntryPoint, address anAggregator, uint256[4] memory aPublicKey) - SimpleAccount(anEntryPoint, address(0)) { - publicKey = aPublicKey; - aggregator = anAggregator; - } - - function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash, address userOpAggregator) - internal override view returns (uint256 deadline) { - - (userOp, userOpHash); - require(userOpAggregator == aggregator, "BLSAccount: wrong aggregator"); - return 0; - } - - event PublicKeyChanged(uint256[4] oldPublicKey, uint256[4] newPublicKey); - - function setBlsPublicKey(uint256[4] memory newPublicKey) external onlyOwner { - emit PublicKeyChanged(publicKey, newPublicKey); - publicKey = newPublicKey; - } - - function getAggregator() external view returns (address) { - return aggregator; - } - - function getBlsPublicKey() external override view returns (uint256[4] memory) { - return publicKey; - } -} - - -contract BLSAccountDeployer { - - function deployAccount(IEntryPoint anEntryPoint, address anAggregator, uint salt, uint256[4] memory aPublicKey) public returns (BLSAccount) { - return new BLSAccount{salt : bytes32(salt)}(anEntryPoint, anAggregator, aPublicKey); - } -} diff --git a/packages/boba/account-abstraction/contracts/bls/IBLSAccount.sol b/packages/boba/account-abstraction/contracts/bls/IBLSAccount.sol deleted file mode 100644 index adf8da5c2c..0000000000 --- a/packages/boba/account-abstraction/contracts/bls/IBLSAccount.sol +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity >=0.7.6; - -import "../interfaces/IAggregatedAccount.sol"; - -/** - * a BLS account should expose its own public key. - */ -interface IBLSAccount is IAggregatedAccount { - function getBlsPublicKey() external view returns (uint256[4] memory); -} diff --git a/packages/boba/account-abstraction/contracts/bundler/BundlerHelper.sol b/packages/boba/account-abstraction/contracts/bundler/BundlerHelper.sol deleted file mode 100644 index b6f9fb59e2..0000000000 --- a/packages/boba/account-abstraction/contracts/bundler/BundlerHelper.sol +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity ^0.8.15; - -import "../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; - } - paid = beneficiary.balance - preBalance; - if (paid < expectedPayment) { - revert(string.concat( - "didn't pay enough: paid ", paid.toString(), - " expected ", expectedPayment.toString(), - " gasPrice ", gasPrice.toString() - )); - } - } -} diff --git a/packages/boba/account-abstraction/contracts/core/BaseAccount.sol b/packages/boba/account-abstraction/contracts/core/BaseAccount.sol index 13c96ec8a6..6c06e9184e 100644 --- a/packages/boba/account-abstraction/contracts/core/BaseAccount.sol +++ b/packages/boba/account-abstraction/contracts/core/BaseAccount.sol @@ -7,6 +7,7 @@ pragma solidity ^0.8.12; import "../interfaces/IAccount.sol"; import "../interfaces/IEntryPoint.sol"; +import "./Helpers.sol"; /** * Basic account implementation. @@ -16,6 +17,10 @@ import "../interfaces/IEntryPoint.sol"; abstract contract BaseAccount is IAccount { using UserOperationLib for UserOperation; + //return value in case of signature failure, with no time-range. + // equivalent to _packValidationData(true,0,0); + uint256 constant internal SIG_VALIDATION_FAILED = 1; + /** * return the account nonce. * subclass should return a nonce value that is used both by _validateAndUpdateNonce, and by the external provider (to read the current nonce) @@ -32,10 +37,10 @@ abstract contract BaseAccount is IAccount { * Validate user's signature and nonce. * subclass doesn't need to override this method. Instead, it should override the specific internal validation methods. */ - function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, address aggregator, uint256 missingAccountFunds) - external override virtual returns (uint256 deadline) { + function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds) + external override virtual returns (uint256 validationData) { _requireFromEntryPoint(); - deadline = _validateSignature(userOp, userOpHash, aggregator); + validationData = _validateSignature(userOp, userOpHash); if (userOp.initCode.length == 0) { _validateAndUpdateNonce(userOp); } @@ -53,13 +58,17 @@ abstract contract BaseAccount is IAccount { * validate the signature is valid for this message. * @param userOp validate the userOp.signature field * @param userOpHash convenient field: the hash of the request, to check the signature against - * (also hashes the entrypoint and chain-id) - * @param aggregator the current aggregator. can be ignored by accounts that don't use aggregators - * @return deadline the last block timestamp this operation is valid, or zero if it is valid indefinitely. + * (also hashes the entrypoint and chain id) + * @return validationData signature and time-range of this operation + * <20-byte> sigAuthorizer - 0 for valid signature, 1 to mark signature failure, + * otherwise, an address of an "authorizer" contract. + * <6-byte> validUntil - last timestamp this operation is valid. 0 for "indefinite" + * <6-byte> validAfter - first timestamp this operation is valid + * If the account doesn't use time-range, it is enough to return SIG_VALIDATION_FAILED value (1) for signature failure. * Note that the validation code cannot use block.timestamp (or block.number) directly. */ - function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash, address aggregator) - internal virtual returns (uint256 deadline); + function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash) + internal virtual returns (uint256 validationData); /** * validate the current nonce matches the UserOperation nonce. @@ -84,29 +93,4 @@ abstract contract BaseAccount is IAccount { //ignore failure (its EntryPoint's job to verify, not account.) } } - - /** - * expose an api to modify the entryPoint. - * must be called by current "admin" of the account. - * @param newEntryPoint the new entrypoint to trust. - */ - function updateEntryPoint(address newEntryPoint) external { - _requireFromAdmin(); - _updateEntryPoint(newEntryPoint); - } - - /** - * ensure the caller is allowed "admin" operations (such as changing the entryPoint) - * default implementation trust the account itself (or any signer that passes "validateUserOp") - * to be the "admin" - */ - function _requireFromAdmin() internal view virtual { - require(msg.sender == address(this) || msg.sender == address(entryPoint()), "not admin"); - } - - /** - * update the current entrypoint. - * subclass should override and update current entrypoint - */ - function _updateEntryPoint(address) internal virtual; } diff --git a/packages/boba/account-abstraction/contracts/core/BasePaymaster.sol b/packages/boba/account-abstraction/contracts/core/BasePaymaster.sol index a094fe3962..1ae3c81c16 100644 --- a/packages/boba/account-abstraction/contracts/core/BasePaymaster.sol +++ b/packages/boba/account-abstraction/contracts/core/BasePaymaster.sol @@ -7,6 +7,7 @@ pragma solidity ^0.8.12; import "@openzeppelin/contracts/access/Ownable.sol"; import "../interfaces/IPaymaster.sol"; import "../interfaces/IEntryPoint.sol"; +import "./Helpers.sol"; /** * Helper class for creating a paymaster. @@ -15,19 +16,23 @@ import "../interfaces/IEntryPoint.sol"; */ abstract contract BasePaymaster is IPaymaster, Ownable { - IEntryPoint public entryPoint; + IEntryPoint immutable public entryPoint; constructor(IEntryPoint _entryPoint) { - setEntryPoint(_entryPoint); - } - - function setEntryPoint(IEntryPoint _entryPoint) public onlyOwner { entryPoint = _entryPoint; } + /// @inheritdoc IPaymaster function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) - external virtual override returns (bytes memory context, uint256 deadline); + external override returns (bytes memory context, uint256 validationData) { + _requireFromEntryPoint(); + return _validatePaymasterUserOp(userOp, userOpHash, maxCost); + } + + function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) + internal virtual returns (bytes memory context, uint256 validationData); + /// @inheritdoc IPaymaster function postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost) external override { _requireFromEntryPoint(); _postOp(mode, context, actualGasCost); @@ -102,6 +107,6 @@ abstract contract BasePaymaster is IPaymaster, Ownable { /// validate the call is made from a valid entrypoint function _requireFromEntryPoint() internal virtual { - require(msg.sender == address(entryPoint)); + require(msg.sender == address(entryPoint), "Sender not EntryPoint"); } } diff --git a/packages/boba/account-abstraction/contracts/core/EntryPoint.sol b/packages/boba/account-abstraction/contracts/core/EntryPoint.sol index 57e86a0ef4..a25fa7d949 100644 --- a/packages/boba/account-abstraction/contracts/core/EntryPoint.sol +++ b/packages/boba/account-abstraction/contracts/core/EntryPoint.sol @@ -7,18 +7,15 @@ pragma solidity ^0.8.12; /* solhint-disable avoid-low-level-calls */ /* solhint-disable no-inline-assembly */ -/* solhint-disable reason-string */ -/* solhint-disable avoid-tx-origin */ import "../interfaces/IAccount.sol"; import "../interfaces/IPaymaster.sol"; - -import "../interfaces/IAggregatedAccount.sol"; import "../interfaces/IEntryPoint.sol"; -import "../interfaces/ICreate2Deployer.sol"; + import "../utils/Exec.sol"; import "./StakeManager.sol"; import "./SenderCreator.sol"; +import "./Helpers.sol"; contract EntryPoint is IEntryPoint, StakeManager { @@ -29,20 +26,31 @@ contract EntryPoint is IEntryPoint, StakeManager { // internal value used during simulation: need to query aggregator. address private constant SIMULATE_FIND_AGGREGATOR = address(1); + // marker for inner call revert on out of gas + bytes32 private constant INNER_OUT_OF_GAS = hex'deaddead'; + + uint256 private constant REVERT_REASON_MAX_LEN = 2048; + + /** + * for simulation purposes, validateUserOp (and validatePaymasterUserOp) must return this value + * in case of signature failure, instead of revert. + */ + uint256 public constant SIG_VALIDATION_FAILED = 1; + /** * compensate the caller's beneficiary address with the collected fees of all UserOperations. * @param beneficiary the address to receive the fees * @param amount amount to transfer. */ function _compensate(address payable beneficiary, uint256 amount) internal { - require(beneficiary != address(0), "invalid beneficiary"); + require(beneficiary != address(0), "AA90 invalid beneficiary"); (bool success,) = beneficiary.call{value : amount}(""); - require(success); + require(success, "AA91 failed send to beneficiary"); } /** * execute a user op - * @param opIndex into into the opInfo array + * @param opIndex index into the opInfo array * @param userOp the userOp to execute * @param opInfo the opInfo filled by validatePrepayment for this userOp. * @return collected the total amount this userOp paid. @@ -54,15 +62,27 @@ contract EntryPoint is IEntryPoint, StakeManager { try this.innerHandleOp(userOp.callData, opInfo, context) returns (uint256 _actualGasCost) { collected = _actualGasCost; } catch { + bytes32 innerRevertCode; + assembly { + returndatacopy(0, 0, 32) + innerRevertCode := mload(0) + } + // handleOps was called with gas limit too low. abort entire bundle. + if (innerRevertCode == INNER_OUT_OF_GAS) { + //report paymaster, since if it is not deliberately caused by the bundler, + // it must be a revert caused by paymaster. + revert FailedOp(opIndex, "AA95 out of gas"); + } + uint256 actualGas = preGas - gasleft() + opInfo.preOpGas; collected = _handlePostOp(opIndex, IPaymaster.PostOpMode.postOpReverted, opInfo, context, actualGas); } } /** - * Execute a batch of UserOperation. + * Execute a batch of UserOperations. * no signature aggregator is used. - * if any account requires an aggregator (that is, it returned an "actualAggregator" when + * if any account requires an aggregator (that is, it returned an aggregator when * performing simulateValidation), then handleAggregatedOps() must be used instead. * @param ops the operations to execute * @param beneficiary the address to receive the fees @@ -74,7 +94,9 @@ contract EntryPoint is IEntryPoint, StakeManager { unchecked { for (uint256 i = 0; i < opslen; i++) { - _validatePrepayment(i, ops[i], opInfos[i], address(0)); + UserOpInfo memory opInfo = opInfos[i]; + (uint256 validationData, uint256 pmValidationData) = _validatePrepayment(i, ops[i], opInfo); + _validateAccountAndPaymasterValidationData(i, validationData, pmValidationData, address(0)); } uint256 collected = 0; @@ -100,7 +122,22 @@ contract EntryPoint is IEntryPoint, StakeManager { uint256 opasLen = opsPerAggregator.length; uint256 totalOps = 0; for (uint256 i = 0; i < opasLen; i++) { - totalOps += opsPerAggregator[i].userOps.length; + UserOpsPerAggregator calldata opa = opsPerAggregator[i]; + UserOperation[] calldata ops = opa.userOps; + IAggregator aggregator = opa.aggregator; + + //address(1) is special marker of "signature error" + require(address(aggregator) != address(1), "AA96 invalid aggregator"); + + if (address(aggregator) != address(0)) { + // solhint-disable-next-line no-empty-blocks + try aggregator.validateSignatures(ops, opa.signature) {} + catch { + revert SignatureValidationFailed(address(aggregator)); + } + } + + totalOps += ops.length; } UserOpInfo[] memory opInfos = new UserOpInfo[](totalOps); @@ -110,25 +147,21 @@ contract EntryPoint is IEntryPoint, StakeManager { UserOpsPerAggregator calldata opa = opsPerAggregator[a]; UserOperation[] calldata ops = opa.userOps; IAggregator aggregator = opa.aggregator; + uint256 opslen = ops.length; for (uint256 i = 0; i < opslen; i++) { - _validatePrepayment(opIndex, ops[i], opInfos[opIndex], address(aggregator)); + UserOpInfo memory opInfo = opInfos[opIndex]; + (uint256 validationData, uint256 paymasterValidationData) = _validatePrepayment(opIndex, ops[i], opInfo); + _validateAccountAndPaymasterValidationData(i, validationData, paymasterValidationData, address(aggregator)); opIndex++; } - - if (address(aggregator) != address(0)) { - // solhint-disable-next-line no-empty-blocks - try aggregator.validateSignatures(ops, opa.signature) {} - catch { - revert SignatureValidationFailed(address(aggregator)); - } - } } uint256 collected = 0; opIndex = 0; for (uint256 a = 0; a < opasLen; a++) { UserOpsPerAggregator calldata opa = opsPerAggregator[a]; + emit SignatureAggregatorChanged(address(opa.aggregator)); UserOperation[] calldata ops = opa.userOps; uint256 opslen = ops.length; @@ -137,11 +170,33 @@ contract EntryPoint is IEntryPoint, StakeManager { opIndex++; } } + emit SignatureAggregatorChanged(address(0)); _compensate(beneficiary, collected); } - //a memory copy of UserOp fields (except that dynamic byte arrays: callData, initCode and signature + /// @inheritdoc IEntryPoint + function simulateHandleOp(UserOperation calldata op, address target, bytes calldata targetCallData) external override { + + UserOpInfo memory opInfo; + _simulationOnlyValidations(op); + (uint256 validationData, uint256 paymasterValidationData) = _validatePrepayment(0, op, opInfo); + ValidationData memory data = _intersectTimeRange(validationData, paymasterValidationData); + + numberMarker(); + uint256 paid = _executeUserOp(0, op, opInfo); + numberMarker(); + bool targetSuccess; + bytes memory targetResult; + if (target != address(0)) { + (targetSuccess, targetResult) = target.call(targetCallData); + } + revert ExecutionResult(opInfo.preOpGas, paid, data.validAfter, data.validUntil, targetSuccess, targetResult); + } + + + // A memory copy of UserOp static fields only. + // Excluding: callData, initCode and signature. Replacing paymasterAndData with paymaster. struct MemoryUserOp { address sender; uint256 nonce; @@ -165,16 +220,27 @@ contract EntryPoint is IEntryPoint, StakeManager { * inner function to handle a UserOperation. * Must be declared "external" to open a call context, but it can only be called by handleOps. */ - function innerHandleOp(bytes calldata callData, UserOpInfo memory opInfo, bytes calldata context) external returns (uint256 actualGasCost) { + function innerHandleOp(bytes memory callData, UserOpInfo memory opInfo, bytes calldata context) external returns (uint256 actualGasCost) { uint256 preGas = gasleft(); - require(msg.sender == address(this)); + require(msg.sender == address(this), "AA92 internal call only"); MemoryUserOp memory mUserOp = opInfo.mUserOp; + uint callGasLimit = mUserOp.callGasLimit; + unchecked { + // handleOps was called with gas limit too low. abort entire bundle. + if (gasleft() < callGasLimit + mUserOp.verificationGasLimit + 5000) { + assembly { + mstore(0, INNER_OUT_OF_GAS) + revert(0, 32) + } + } + } + IPaymaster.PostOpMode mode = IPaymaster.PostOpMode.opSucceeded; if (callData.length > 0) { - - (bool success,bytes memory result) = address(mUserOp.sender).call{gas : mUserOp.callGasLimit}(callData); + bool success = Exec.call(mUserOp.sender, 0, callData, callGasLimit); if (!success) { + bytes memory result = Exec.getReturnData(REVERT_REASON_MAX_LEN); if (result.length > 0) { emit UserOperationRevertReason(opInfo.userOpHash, mUserOp.sender, mUserOp.nonce, result); } @@ -210,7 +276,7 @@ contract EntryPoint is IEntryPoint, StakeManager { mUserOp.maxPriorityFeePerGas = userOp.maxPriorityFeePerGas; bytes calldata paymasterAndData = userOp.paymasterAndData; if (paymasterAndData.length > 0) { - require(paymasterAndData.length >= 20, "invalid paymasterAndData"); + require(paymasterAndData.length >= 20, "AA93 invalid paymasterAndData"); mUserOp.paymaster = address(bytes20(paymasterAndData[: 20])); } else { mUserOp.paymaster = address(0); @@ -219,50 +285,60 @@ contract EntryPoint is IEntryPoint, StakeManager { /** * Simulate a call to account.validateUserOp and paymaster.validatePaymasterUserOp. - * @dev this method always revert. Successful result is SimulationResult error. other errors are failures. + * @dev this method always revert. Successful result is ValidationResult error. other errors are failures. * @dev The node must also verify it doesn't use banned opcodes, and that it doesn't reference storage outside the account's data. * @param userOp the user operation to validate. */ function simulateValidation(UserOperation calldata userOp) external { - uint256 preGas = gasleft(); - UserOpInfo memory outOpInfo; - (address aggregator, uint256 deadline) = _validatePrepayment(0, userOp, outOpInfo, SIMULATE_FIND_AGGREGATOR); - uint256 prefund = outOpInfo.prefund; - uint256 preOpGas = preGas - gasleft() + userOp.preVerificationGas; - DepositInfo memory depositInfo = getDepositInfo(outOpInfo.mUserOp.paymaster); - PaymasterInfo memory paymasterInfo = PaymasterInfo(depositInfo.stake, depositInfo.unstakeDelaySec); + _simulationOnlyValidations(userOp); + (uint256 validationData, uint256 paymasterValidationData) = _validatePrepayment(0, userOp, outOpInfo); + StakeInfo memory paymasterInfo = _getStakeInfo(outOpInfo.mUserOp.paymaster); + StakeInfo memory senderInfo = _getStakeInfo(outOpInfo.mUserOp.sender); + StakeInfo memory factoryInfo; + { + bytes calldata initCode = userOp.initCode; + address factory = initCode.length >= 20 ? address(bytes20(initCode[0 : 20])) : address(0); + factoryInfo = _getStakeInfo(factory); + } - if (aggregator != address(0)) { - depositInfo = getDepositInfo(aggregator); - AggregationInfo memory aggregationInfo = AggregationInfo(aggregator, depositInfo.stake, depositInfo.unstakeDelaySec); - revert SimulationResultWithAggregation(preOpGas, prefund, deadline, paymasterInfo, aggregationInfo); + ValidationData memory data = _intersectTimeRange(validationData, paymasterValidationData); + address aggregator = data.aggregator; + bool sigFailed = aggregator == address(1); + ReturnInfo memory returnInfo = ReturnInfo(outOpInfo.preOpGas, outOpInfo.prefund, + sigFailed, data.validAfter, data.validUntil, getMemoryBytesFromOffset(outOpInfo.contextOffset)); + if (aggregator != address(0) && aggregator != address(1)) { + AggregatorStakeInfo memory aggregatorInfo = AggregatorStakeInfo(aggregator, _getStakeInfo(aggregator)); + revert ValidationResultWithAggregation(returnInfo, senderInfo, factoryInfo, paymasterInfo, aggregatorInfo); } - revert SimulationResult(preOpGas, prefund, deadline, paymasterInfo); + revert ValidationResult(returnInfo, senderInfo, factoryInfo, paymasterInfo); + } - function _getRequiredPrefund(MemoryUserOp memory mUserOp) internal view returns (uint256 requiredPrefund) { + function _getRequiredPrefund(MemoryUserOp memory mUserOp) internal pure returns (uint256 requiredPrefund) { unchecked { //when using a Paymaster, the verificationGasLimit is used also to as a limit for the postOp call. // our security model might call postOp eventually twice uint256 mul = mUserOp.paymaster != address(0) ? 3 : 1; uint256 requiredGas = mUserOp.callGasLimit + mUserOp.verificationGasLimit * mul + mUserOp.preVerificationGas; - // TODO: copy logic of gasPrice? - requiredPrefund = requiredGas * getUserOpGasPrice(mUserOp); + requiredPrefund = requiredGas * mUserOp.maxFeePerGas; } } // create the sender's contract if needed. - function _createSenderIfNeeded(uint256 opIndex, MemoryUserOp memory mUserOp, bytes calldata initCode) internal { + function _createSenderIfNeeded(uint256 opIndex, UserOpInfo memory opInfo, bytes calldata initCode) internal { if (initCode.length != 0) { - if (mUserOp.sender.code.length != 0) revert FailedOp(opIndex, address(0), "sender already constructed"); - address sender1 = senderCreator.createSender(initCode); - if (sender1 == address(0)) revert FailedOp(opIndex, address(0), "initCode failed"); - if (sender1 != mUserOp.sender) revert FailedOp(opIndex, address(0), "sender doesn't match initCode address"); - if (sender1.code.length == 0) revert FailedOp(opIndex, address(0), "initCode failed to create sender"); + address sender = opInfo.mUserOp.sender; + if (sender.code.length != 0) revert FailedOp(opIndex, "AA10 sender already constructed"); + address sender1 = senderCreator.createSender{gas : opInfo.mUserOp.verificationGasLimit}(initCode); + if (sender1 == address(0)) revert FailedOp(opIndex, "AA13 initCode failed or OOG"); + if (sender1 != sender) revert FailedOp(opIndex, "AA14 initCode must return sender"); + if (sender1.code.length == 0) revert FailedOp(opIndex, "AA15 initCode must create sender"); + address factory = address(bytes20(initCode[0 : 20])); + emit AccountDeployed(opInfo.userOpHash, sender, factory, opInfo.mUserOp.paymaster); } } @@ -276,47 +352,68 @@ contract EntryPoint is IEntryPoint, StakeManager { revert SenderAddressResult(senderCreator.createSender(initCode)); } + function _simulationOnlyValidations(UserOperation calldata userOp) internal view { + // solhint-disable-next-line no-empty-blocks + try this._validateSenderAndPaymaster(userOp.initCode, userOp.sender, userOp.paymasterAndData) {} + catch Error(string memory revertReason) { + if (bytes(revertReason).length != 0) { + revert FailedOp(0, revertReason); + } + } + } + + /** + * Called only during simulation. + * This function always reverts to prevent warm/cold storage differentiation in simulation vs execution. + */ + function _validateSenderAndPaymaster(bytes calldata initCode, address sender, bytes calldata paymasterAndData) external view { + if (initCode.length == 0 && sender.code.length == 0) { + // it would revert anyway. but give a meaningful message + revert("AA20 account not deployed"); + } + if (paymasterAndData.length >= 20) { + address paymaster = address(bytes20(paymasterAndData[0 : 20])); + if (paymaster.code.length == 0) { + // it would revert anyway. but give a meaningful message + revert("AA30 paymaster not deployed"); + } + } + // always revert + revert(""); + } + /** * call account.validateUserOp. * revert (with FailedOp) in case validateUserOp reverts, or account didn't send required prefund. * decrement account's deposit if needed */ - function _validateAccountPrepayment(uint256 opIndex, UserOperation calldata op, UserOpInfo memory opInfo, address aggregator, uint256 requiredPrefund) - internal returns (uint256 gasUsedByValidateAccountPrepayment, address actualAggregator, uint256 deadline) { + function _validateAccountPrepayment(uint256 opIndex, UserOperation calldata op, UserOpInfo memory opInfo, uint256 requiredPrefund) + internal returns (uint256 gasUsedByValidateAccountPrepayment, uint256 validationData) { unchecked { uint256 preGas = gasleft(); MemoryUserOp memory mUserOp = opInfo.mUserOp; - _createSenderIfNeeded(opIndex, mUserOp, op.initCode); - if (aggregator == SIMULATE_FIND_AGGREGATOR) { - try IAggregatedAccount(mUserOp.sender).getAggregator() returns (address userOpAggregator) { - aggregator = actualAggregator = userOpAggregator; - } catch { - aggregator = actualAggregator = address(0); - } - } - uint256 missingAccountFunds = 0; address sender = mUserOp.sender; + _createSenderIfNeeded(opIndex, opInfo, op.initCode); address paymaster = mUserOp.paymaster; + numberMarker(); + uint256 missingAccountFunds = 0; if (paymaster == address(0)) { uint256 bal = balanceOf(sender); missingAccountFunds = bal > requiredPrefund ? 0 : requiredPrefund - bal; } - try IAccount(sender).validateUserOp{gas : mUserOp.verificationGasLimit}(op, opInfo.userOpHash, aggregator, missingAccountFunds) returns (uint256 _deadline) { - // solhint-disable-next-line not-rely-on-time - if (_deadline != 0 && _deadline < block.timestamp) { - revert FailedOp(opIndex, address(0), "expired"); - } - deadline = _deadline; + try IAccount(sender).validateUserOp{gas : mUserOp.verificationGasLimit}(op, opInfo.userOpHash, missingAccountFunds) + returns (uint256 _validationData) { + validationData = _validationData; } catch Error(string memory revertReason) { - revert FailedOp(opIndex, address(0), revertReason); + revert FailedOp(opIndex, string.concat("AA23 reverted: ", revertReason)); } catch { - revert FailedOp(opIndex, address(0), ""); + revert FailedOp(opIndex, "AA23 reverted (or OOG)"); } if (paymaster == address(0)) { DepositInfo storage senderInfo = deposits[sender]; uint256 deposit = senderInfo.deposit; if (requiredPrefund > deposit) { - revert FailedOp(opIndex, address(0), "account didn't pay prefund"); + revert FailedOp(opIndex, "AA21 didn't pay prefund"); } senderInfo.deposit = uint112(deposit - requiredPrefund); } @@ -325,40 +422,69 @@ contract EntryPoint is IEntryPoint, StakeManager { } /** - * in case the request has a paymaster: - * validate paymaster is staked and has enough deposit. - * call paymaster.validatePaymasterUserOp. - * revert with proper FailedOp in case paymaster reverts. - * decrement paymaster's deposit + * In case the request has a paymaster: + * Validate paymaster has enough deposit. + * Call paymaster.validatePaymasterUserOp. + * Revert with proper FailedOp in case paymaster reverts. + * Decrement paymaster's deposit */ - function _validatePaymasterPrepayment(uint256 opIndex, UserOperation calldata op, UserOpInfo memory opInfo, uint256 requiredPreFund, uint256 gasUsedByValidateAccountPrepayment) internal returns (bytes memory context, uint256 deadline) { + function _validatePaymasterPrepayment(uint256 opIndex, UserOperation calldata op, UserOpInfo memory opInfo, uint256 requiredPreFund, uint256 gasUsedByValidateAccountPrepayment) + internal returns (bytes memory context, uint256 validationData) { unchecked { MemoryUserOp memory mUserOp = opInfo.mUserOp; + uint256 verificationGasLimit = mUserOp.verificationGasLimit; + require(verificationGasLimit > gasUsedByValidateAccountPrepayment, "AA41 too little verificationGas"); + uint256 gas = verificationGasLimit - gasUsedByValidateAccountPrepayment; + address paymaster = mUserOp.paymaster; DepositInfo storage paymasterInfo = deposits[paymaster]; uint256 deposit = paymasterInfo.deposit; - bool staked = paymasterInfo.staked; - if (!staked) { - revert FailedOp(opIndex, paymaster, "not staked"); - } if (deposit < requiredPreFund) { - revert FailedOp(opIndex, paymaster, "paymaster deposit too low"); + revert FailedOp(opIndex, "AA31 paymaster deposit too low"); } paymasterInfo.deposit = uint112(deposit - requiredPreFund); - uint256 gas = mUserOp.verificationGasLimit - gasUsedByValidateAccountPrepayment; - try IPaymaster(paymaster).validatePaymasterUserOp{gas : gas}(op, opInfo.userOpHash, requiredPreFund) returns (bytes memory _context, uint256 _deadline){ - // solhint-disable-next-line not-rely-on-time - if (_deadline != 0 && _deadline < block.timestamp) { - revert FailedOp(opIndex, paymaster, "expired"); - } + try IPaymaster(paymaster).validatePaymasterUserOp{gas : gas}(op, opInfo.userOpHash, requiredPreFund) returns (bytes memory _context, uint256 _validationData){ context = _context; - deadline = _deadline; + validationData = _validationData; } catch Error(string memory revertReason) { - revert FailedOp(opIndex, paymaster, revertReason); + revert FailedOp(opIndex, string.concat("AA33 reverted: ", revertReason)); } catch { - revert FailedOp(opIndex, paymaster, ""); + revert FailedOp(opIndex, "AA33 reverted (or OOG)"); + } + } + } + + /** + * revert if either account validationData or paymaster validationData is expired + */ + function _validateAccountAndPaymasterValidationData(uint256 opIndex, uint256 validationData, uint256 paymasterValidationData, address expectedAggregator) internal view { + (address aggregator, bool outOfTimeRange) = _getValidationData(validationData); + if (expectedAggregator != aggregator) { + revert FailedOp(opIndex, "AA24 signature error"); + } + if (outOfTimeRange) { + revert FailedOp(opIndex, "AA22 expired or not due"); + } + //pmAggregator is not a real signature aggregator: we don't have logic to handle it as address. + // non-zero address means that the paymaster fails due to some signature check (which is ok only during estimation) + address pmAggregator; + (pmAggregator, outOfTimeRange) = _getValidationData(paymasterValidationData); + if (pmAggregator != address(0)) { + revert FailedOp(opIndex, "AA34 signature error"); + } + if (outOfTimeRange) { + revert FailedOp(opIndex, "AA32 paymaster expired or not due"); } } + + function _getValidationData(uint256 validationData) internal view returns (address aggregator, bool outOfTimeRange) { + if (validationData == 0) { + return (address(0), false); + } + ValidationData memory data = _parseValidationData(validationData); + // solhint-disable-next-line not-rely-on-time + outOfTimeRange = block.timestamp > data.validUntil || block.timestamp < data.validAfter; + aggregator = data.aggregator; } /** @@ -368,8 +494,8 @@ contract EntryPoint is IEntryPoint, StakeManager { * @param opIndex the index of this userOp into the "opInfos" array * @param userOp the userOp to validate */ - function _validatePrepayment(uint256 opIndex, UserOperation calldata userOp, UserOpInfo memory outOpInfo, address aggregator) - private returns (address actualAggregator, uint256 deadline) { + function _validatePrepayment(uint256 opIndex, UserOperation calldata userOp, UserOpInfo memory outOpInfo) + private returns (uint256 validationData, uint256 paymasterValidationData) { uint256 preGas = gasleft(); MemoryUserOp memory mUserOp = outOpInfo.mUserOp; @@ -380,31 +506,24 @@ contract EntryPoint is IEntryPoint, StakeManager { // and multiplied without causing overflow uint256 maxGasValues = mUserOp.preVerificationGas | mUserOp.verificationGasLimit | mUserOp.callGasLimit | userOp.maxFeePerGas | userOp.maxPriorityFeePerGas; - require(maxGasValues <= type(uint120).max, "gas values overflow"); + require(maxGasValues <= type(uint120).max, "AA94 gas values overflow"); uint256 gasUsedByValidateAccountPrepayment; (uint256 requiredPreFund) = _getRequiredPrefund(mUserOp); - (gasUsedByValidateAccountPrepayment, actualAggregator, deadline) = _validateAccountPrepayment(opIndex, userOp, outOpInfo, aggregator, requiredPreFund); + (gasUsedByValidateAccountPrepayment, validationData) = _validateAccountPrepayment(opIndex, userOp, outOpInfo, requiredPreFund); //a "marker" where account opcode validation is done and paymaster opcode validation is about to start // (used only by off-chain simulateValidation) numberMarker(); bytes memory context; if (mUserOp.paymaster != address(0)) { - uint paymasterDeadline; - (context, paymasterDeadline) = _validatePaymasterPrepayment(opIndex, userOp, outOpInfo, requiredPreFund, gasUsedByValidateAccountPrepayment); - if (paymasterDeadline != 0 && paymasterDeadline < deadline) { - deadline = paymasterDeadline; - } - } else { - context = ""; - + (context, paymasterValidationData) = _validatePaymasterPrepayment(opIndex, userOp, outOpInfo, requiredPreFund, gasUsedByValidateAccountPrepayment); } unchecked { uint256 gasUsed = preGas - gasleft(); if (userOp.verificationGasLimit < gasUsed) { - revert FailedOp(opIndex, mUserOp.paymaster, "Used more than verificationGasLimit"); + revert FailedOp(opIndex, "AA40 over verificationGasLimit"); } outOpInfo.prefund = requiredPreFund; outOpInfo.contextOffset = getOffsetOfMemoryBytes(context); @@ -416,7 +535,7 @@ contract EntryPoint is IEntryPoint, StakeManager { * process post-operation. * called just after the callData is executed. * if a paymaster is defined and its validation returned a non-empty context, its postOp is called. - * the excess amount is refunded to the account (or paymaster - if it is was used in the request) + * the excess amount is refunded to the account (or paymaster - if it was used in the request) * @param opIndex index in the batch * @param mode - whether is called from innerHandleOp, or outside (postOpReverted) * @param opInfo userOp fields and info collected during validation @@ -443,10 +562,10 @@ contract EntryPoint is IEntryPoint, StakeManager { // solhint-disable-next-line no-empty-blocks try IPaymaster(paymaster).postOp{gas : mUserOp.verificationGasLimit}(mode, context, actualGasCost) {} catch Error(string memory reason) { - revert FailedOp(opIndex, paymaster, reason); + revert FailedOp(opIndex, string.concat("AA50 postOp reverted: ", reason)); } catch { - revert FailedOp(opIndex, paymaster, "postOp revert"); + revert FailedOp(opIndex, "AA50 postOp revert"); } } } @@ -454,18 +573,18 @@ contract EntryPoint is IEntryPoint, StakeManager { actualGas += preGas - gasleft(); actualGasCost = actualGas * gasPrice; if (opInfo.prefund < actualGasCost) { - revert FailedOp(opIndex, paymaster, "prefund below actualGasCost"); + revert FailedOp(opIndex, "AA51 prefund below actualGasCost"); } uint256 refund = opInfo.prefund - actualGasCost; - internalIncrementDeposit(refundAddress, refund); + _incrementDeposit(refundAddress, refund); bool success = mode == IPaymaster.PostOpMode.opSucceeded; - emit UserOperationEvent(opInfo.userOpHash, mUserOp.sender, mUserOp.paymaster, mUserOp.nonce, actualGasCost, gasPrice, success); + emit UserOperationEvent(opInfo.userOpHash, mUserOp.sender, mUserOp.paymaster, mUserOp.nonce, success, actualGasCost, actualGas); } // unchecked } /** * the gas price this UserOp agrees to pay. - * relayer/miner might submit the TX with higher priorityFee, but the user should not + * relayer/block builder might submit the TX with higher priorityFee, but the user should not */ function getUserOpGasPrice(MemoryUserOp memory mUserOp) internal view returns (uint256) { unchecked { diff --git a/packages/boba/account-abstraction/contracts/core/Helpers.sol b/packages/boba/account-abstraction/contracts/core/Helpers.sol new file mode 100644 index 0000000000..d0bb0c9196 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/core/Helpers.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +/** + * returned data from validateUserOp. + * validateUserOp returns a uint256, with is created by `_packedValidationData` and parsed by `_parseValidationData` + * @param aggregator - address(0) - the account validated the signature by itself. + * address(1) - the account failed to validate the signature. + * otherwise - this is an address of a signature aggregator that must be used to validate the signature. + * @param validAfter - this UserOp is valid only after this timestamp. + * @param validaUntil - this UserOp is valid only up to this timestamp. + */ + struct ValidationData { + address aggregator; + uint48 validAfter; + uint48 validUntil; + } + +//extract sigFailed, validAfter, validUntil. +// also convert zero validUntil to type(uint48).max + function _parseValidationData(uint validationData) pure returns (ValidationData memory data) { + address aggregator = address(uint160(validationData)); + uint48 validUntil = uint48(validationData >> 160); + if (validUntil == 0) { + validUntil = type(uint48).max; + } + uint48 validAfter = uint48(validationData >> (48 + 160)); + return ValidationData(aggregator, validAfter, validUntil); + } + +// intersect account and paymaster ranges. + function _intersectTimeRange(uint256 validationData, uint256 paymasterValidationData) pure returns (ValidationData memory) { + ValidationData memory accountValidationData = _parseValidationData(validationData); + ValidationData memory pmValidationData = _parseValidationData(paymasterValidationData); + address aggregator = accountValidationData.aggregator; + if (aggregator == address(0)) { + aggregator = pmValidationData.aggregator; + } + uint48 validAfter = accountValidationData.validAfter; + uint48 validUntil = accountValidationData.validUntil; + uint48 pmValidAfter = pmValidationData.validAfter; + uint48 pmValidUntil = pmValidationData.validUntil; + + if (validAfter < pmValidAfter) validAfter = pmValidAfter; + if (validUntil > pmValidUntil) validUntil = pmValidUntil; + return ValidationData(aggregator, validAfter, validUntil); + } + +/** + * helper to pack the return value for validateUserOp + * @param data - the ValidationData to pack + */ + function _packValidationData(ValidationData memory data) pure returns (uint256) { + return uint160(data.aggregator) | (uint256(data.validUntil) << 160) | (uint256(data.validAfter) << (160 + 48)); + } + +/** + * helper to pack the return value for validateUserOp, when not using an aggregator + * @param sigFailed - true for signature failure, false for success + * @param validUntil last timestamp this UserOperation is valid (or zero for infinite) + * @param validAfter first timestamp this UserOperation is valid + */ + function _packValidationData(bool sigFailed, uint48 validUntil, uint48 validAfter) pure returns (uint256) { + return (sigFailed ? 1 : 0) | (uint256(validUntil) << 160) | (uint256(validAfter) << (160 + 48)); + } diff --git a/packages/boba/account-abstraction/contracts/core/SenderCreator.sol b/packages/boba/account-abstraction/contracts/core/SenderCreator.sol index ed256a486f..36fad7b91f 100644 --- a/packages/boba/account-abstraction/contracts/core/SenderCreator.sol +++ b/packages/boba/account-abstraction/contracts/core/SenderCreator.sol @@ -13,12 +13,12 @@ contract SenderCreator { * @return sender the returned address of the created account, or zero address on failure. */ function createSender(bytes calldata initCode) external returns (address sender) { - address initAddress = address(bytes20(initCode[0 : 20])); + address factory = address(bytes20(initCode[0 : 20])); bytes memory initCallData = initCode[20 :]; bool success; /* solhint-disable no-inline-assembly */ assembly { - success := call(gas(), initAddress, 0, add(initCallData, 0x20), mload(initCallData), 0, 32) + success := call(gas(), factory, 0, add(initCallData, 0x20), mload(initCallData), 0, 32) sender := mload(0) } if (!success) { diff --git a/packages/boba/account-abstraction/contracts/core/StakeManager.sol b/packages/boba/account-abstraction/contracts/core/StakeManager.sol index 52ceffe7d0..e5ca2b97dd 100644 --- a/packages/boba/account-abstraction/contracts/core/StakeManager.sol +++ b/packages/boba/account-abstraction/contracts/core/StakeManager.sol @@ -15,10 +15,18 @@ abstract contract StakeManager is IStakeManager { /// maps paymaster to their deposits and stakes mapping(address => DepositInfo) public deposits; + /// @inheritdoc IStakeManager function getDepositInfo(address account) public view returns (DepositInfo memory info) { return deposits[account]; } + // internal method to return just the stake info + function _getStakeInfo(address addr) internal view returns (StakeInfo memory info) { + DepositInfo storage depositInfo = deposits[addr]; + info.stake = depositInfo.stake; + info.unstakeDelaySec = depositInfo.unstakeDelaySec; + } + /// return the deposit (for gas payment) of the account function balanceOf(address account) public view returns (uint256) { return deposits[account].deposit; @@ -28,7 +36,7 @@ abstract contract StakeManager is IStakeManager { depositTo(msg.sender); } - function internalIncrementDeposit(address account, uint256 amount) internal { + function _incrementDeposit(address account, uint256 amount) internal { DepositInfo storage info = deposits[account]; uint256 newAmount = info.deposit + amount; require(newAmount <= type(uint112).max, "deposit overflow"); @@ -39,7 +47,7 @@ abstract contract StakeManager is IStakeManager { * add to the deposit of the given account */ function depositTo(address account) public payable { - internalIncrementDeposit(account, msg.value); + _incrementDeposit(account, msg.value); DepositInfo storage info = deposits[account]; emit Deposited(account, info.deposit); } @@ -47,23 +55,23 @@ abstract contract StakeManager is IStakeManager { /** * add to the account's stake - amount and delay * any pending unstake is first cancelled. - * @param _unstakeDelaySec the new lock duration before the deposit can be withdrawn. + * @param unstakeDelaySec the new lock duration before the deposit can be withdrawn. */ - function addStake(uint32 _unstakeDelaySec) public payable { + function addStake(uint32 unstakeDelaySec) public payable { DepositInfo storage info = deposits[msg.sender]; - require(_unstakeDelaySec > 0, "must specify unstake delay"); - require(_unstakeDelaySec >= info.unstakeDelaySec, "cannot decrease unstake time"); + require(unstakeDelaySec > 0, "must specify unstake delay"); + require(unstakeDelaySec >= info.unstakeDelaySec, "cannot decrease unstake time"); uint256 stake = info.stake + msg.value; require(stake > 0, "no stake specified"); - require(stake < type(uint112).max, "stake overflow"); + require(stake <= type(uint112).max, "stake overflow"); deposits[msg.sender] = DepositInfo( info.deposit, true, uint112(stake), - _unstakeDelaySec, + unstakeDelaySec, 0 ); - emit StakeLocked(msg.sender, stake, _unstakeDelaySec); + emit StakeLocked(msg.sender, stake, unstakeDelaySec); } /** @@ -74,7 +82,7 @@ abstract contract StakeManager is IStakeManager { DepositInfo storage info = deposits[msg.sender]; require(info.unstakeDelaySec != 0, "not staked"); require(info.staked, "already unstaking"); - uint64 withdrawTime = uint64(block.timestamp) + info.unstakeDelaySec; + uint48 withdrawTime = uint48(block.timestamp) + info.unstakeDelaySec; info.withdrawTime = withdrawTime; info.staked = false; emit StakeUnlocked(msg.sender, withdrawTime); diff --git a/packages/boba/account-abstraction/contracts/gnosis/EIP4337Fallback.sol b/packages/boba/account-abstraction/contracts/gnosis/EIP4337Fallback.sol deleted file mode 100644 index 7715f72b2c..0000000000 --- a/packages/boba/account-abstraction/contracts/gnosis/EIP4337Fallback.sol +++ /dev/null @@ -1,33 +0,0 @@ -//SPDX-License-Identifier: GPL -pragma solidity ^0.8.7; - -/* solhint-disable no-inline-assembly */ - -import "@gnosis.pm/safe-contracts/contracts/handler/DefaultCallbackHandler.sol"; -import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol"; -import "../interfaces/IAccount.sol"; -import "./EIP4337Manager.sol"; - -contract EIP4337Fallback is DefaultCallbackHandler, IAccount { - address immutable public eip4337manager; - constructor(address _eip4337manager) { - eip4337manager = _eip4337manager; - } - - /** - * handler is called from the Safe. delegate actual work to EIP4337Manager - */ - function validateUserOp(UserOperation calldata, bytes32, address, uint256) override external returns (uint256 deadline){ - //delegate entire msg.data (including the appended "msg.sender") to the EIP4337Manager - // will work only for GnosisSafe contracts - GnosisSafe safe = GnosisSafe(payable(msg.sender)); - (bool success, bytes memory ret) = safe.execTransactionFromModuleReturnData(eip4337manager, 0, msg.data, Enum.Operation.DelegateCall); - if (!success) { - assembly { - revert(add(ret, 32), mload(ret)) - } - } - return 0; - } - -} diff --git a/packages/boba/account-abstraction/contracts/gnosis/GnosisSafeProxy4337.sol b/packages/boba/account-abstraction/contracts/gnosis/GnosisSafeProxy4337.sol deleted file mode 100644 index e7617aaf00..0000000000 --- a/packages/boba/account-abstraction/contracts/gnosis/GnosisSafeProxy4337.sol +++ /dev/null @@ -1,24 +0,0 @@ -//SPDX-License-Identifier: GPL -pragma solidity ^0.8.7; - -/* solhint-disable avoid-low-level-calls */ - -import "./EIP4337Manager.sol"; -import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxy.sol"; - -/** - * Create a proxy to a GnosisSafe, which accepts calls through Account-Abstraction. - * The created GnosisSafe has a single owner. - * It is possible to add more owners, but currently, it can only be accessed via Account-Abstraction - * if the owners threshold is exactly 1. - */ -contract SafeProxy4337 is GnosisSafeProxy { - constructor( - address singleton, EIP4337Manager aaModule, - address owner - ) GnosisSafeProxy(singleton) { - (bool success,bytes memory ret) = address(aaModule).delegatecall(abi.encodeCall( - EIP4337Manager.setupEIP4337, (singleton, aaModule, owner))); - require(success, string(ret)); - } -} diff --git a/packages/boba/account-abstraction/contracts/interfaces/IAccount.sol b/packages/boba/account-abstraction/contracts/interfaces/IAccount.sol index 3ea8d90864..1600de3d71 100644 --- a/packages/boba/account-abstraction/contracts/interfaces/IAccount.sol +++ b/packages/boba/account-abstraction/contracts/interfaces/IAccount.sol @@ -8,20 +8,27 @@ interface IAccount { /** * Validate user's signature and nonce * the entryPoint will make the call to the recipient only if this validation call returns successfully. + * signature failure should be reported by returning SIG_VALIDATION_FAILED (1). + * This allows making a "simulation call" without a valid signature + * Other failures (e.g. nonce mismatch, or invalid signature format) should still revert to signal failure. * * @dev Must validate caller is the entryPoint. * Must validate the signature and nonce * @param userOp the operation that is about to be executed. * @param userOpHash hash of the user's request data. can be used as the basis for signature. - * @param aggregator the aggregator used to validate the signature. NULL for non-aggregated signature accounts. * @param missingAccountFunds missing funds on the account's deposit in the entrypoint. * This is the minimum amount to transfer to the sender(entryPoint) to be able to make the call. * The excess is left as a deposit in the entrypoint, for future calls. * can be withdrawn anytime using "entryPoint.withdrawTo()" * In case there is a paymaster in the request (or the current deposit is high enough), this value will be zero. - * @return deadline the last block timestamp this operation is valid, or zero if it is valid indefinitely. + * @return validationData packaged ValidationData structure. use `_packValidationData` and `_unpackValidationData` to encode and decode + * <20-byte> sigAuthorizer - 0 for valid signature, 1 to mark signature failure, + * otherwise, an address of an "authorizer" contract. + * <6-byte> validUntil - last timestamp this operation is valid. 0 for "indefinite" + * <6-byte> validAfter - first timestamp this operation is valid + * If an account doesn't use time-range, it is enough to return SIG_VALIDATION_FAILED value (1) for signature failure. * Note that the validation code cannot use block.timestamp (or block.number) directly. */ - function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, address aggregator, uint256 missingAccountFunds) - external returns (uint256 deadline); + function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds) + external returns (uint256 validationData); } diff --git a/packages/boba/account-abstraction/contracts/interfaces/IAggregatedAccount.sol b/packages/boba/account-abstraction/contracts/interfaces/IAggregatedAccount.sol deleted file mode 100644 index ca189bdd48..0000000000 --- a/packages/boba/account-abstraction/contracts/interfaces/IAggregatedAccount.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity ^0.8.12; - -import "./UserOperation.sol"; -import "./IAccount.sol"; -import "./IAggregator.sol"; - -/** - * Aggregated account, that support IAggregator. - * - the validateUserOp will be called only after the aggregator validated this account (with all other accounts of this aggregator). - * - the validateUserOp MUST valiate the aggregator parameter, and MAY ignore the userOp.signature field. - */ -interface IAggregatedAccount is IAccount { - - /** - * return the address of the signature aggregator the account supports. - */ - function getAggregator() external view returns (address); -} diff --git a/packages/boba/account-abstraction/contracts/interfaces/IAggregator.sol b/packages/boba/account-abstraction/contracts/interfaces/IAggregator.sol index 84aed04152..086c6f3224 100644 --- a/packages/boba/account-abstraction/contracts/interfaces/IAggregator.sol +++ b/packages/boba/account-abstraction/contracts/interfaces/IAggregator.sol @@ -16,8 +16,8 @@ interface IAggregator { /** * validate signature of a single userOp - * This method is called by EntryPoint.simulateUserOperation() if the account has an aggregator. - * First it validates the signature over the userOp. then it return data to be used when creating the handleOps: + * This method is should be called by bundler after EntryPoint.simulateValidation() returns (reverts) with ValidationResultWithAggregation + * First it validates the signature over the userOp. Then it returns data to be used when creating the handleOps. * @param userOp the userOperation received from the user. * @return sigForUserOp the value to put into the signature field of the userOp when calling handleOps. * (usually empty, unless account and aggregator support some kind of "multisig" @@ -30,7 +30,7 @@ interface IAggregator { * This method is called off-chain to calculate the signature to pass with handleOps() * bundler MAY use optimized custom code perform this aggregation * @param userOps array of UserOperations to collect the signatures from. - * @return aggregatesSignature the aggregated signature + * @return aggregatedSignature the aggregated signature */ - function aggregateSignatures(UserOperation[] calldata userOps) external view returns (bytes memory aggregatesSignature); + function aggregateSignatures(UserOperation[] calldata userOps) external view returns (bytes memory aggregatedSignature); } diff --git a/packages/boba/account-abstraction/contracts/interfaces/ICreate2Deployer.sol b/packages/boba/account-abstraction/contracts/interfaces/ICreate2Deployer.sol deleted file mode 100644 index cc7b436b90..0000000000 --- a/packages/boba/account-abstraction/contracts/interfaces/ICreate2Deployer.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity ^0.8.12; - -/** - * create2-based deployer (eip-2470) - */ -interface ICreate2Deployer { - function deploy(bytes memory initCode, bytes32 salt) external returns (address); -} - diff --git a/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol b/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol index 6a3b4b5240..c25288b38b 100644 --- a/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol +++ b/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol @@ -20,12 +20,21 @@ interface IEntryPoint is IStakeManager { * @param userOpHash - unique identifier for the request (hash its entire content, except signature). * @param sender - the account that generates this request. * @param paymaster - if non-null, the paymaster that pays for this request. - * @param nonce - the nonce value from the request - * @param actualGasCost - the total cost (in gas) of this request. - * @param actualGasPrice - the actual gas price the sender agreed to pay. + * @param nonce - the nonce value from the request. * @param success - true if the sender transaction succeeded, false if reverted. + * @param actualGasCost - actual amount paid (by account or paymaster) for this UserOperation. + * @param actualGasUsed - total gas used by this UserOperation (including preVerification, creation, validation and execution). */ - event UserOperationEvent(bytes32 indexed userOpHash, address indexed sender, address indexed paymaster, uint256 nonce, uint256 actualGasCost, uint256 actualGasPrice, bool success); + event UserOperationEvent(bytes32 indexed userOpHash, address indexed sender, address indexed paymaster, uint256 nonce, bool success, uint256 actualGasCost, uint256 actualGasUsed); + + /** + * account "sender" was deployed. + * @param userOpHash the userOp that deployed this account. UserOperationEvent will follow. + * @param sender the account that is deployed + * @param factory the factory used to deploy this account (in the initCode) + * @param paymaster the paymaster used by this UserOp + */ + event AccountDeployed(bytes32 indexed userOpHash, address indexed sender, address factory, address paymaster); /** * An event emitted if the UserOperation "callData" reverted with non-zero length @@ -36,23 +45,61 @@ interface IEntryPoint is IStakeManager { */ event UserOperationRevertReason(bytes32 indexed userOpHash, address indexed sender, uint256 nonce, bytes revertReason); + /** + * signature aggregator used by the following UserOperationEvents within this bundle. + */ + event SignatureAggregatorChanged(address indexed aggregator); + /** * 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 paymaster - if paymaster.validatePaymasterUserOp fails, this will be the paymaster's address. if validateUserOp failed, - * this value will be zero (since it failed before accessing the paymaster) * @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 account/paymaster reverts. + * Useful for mitigating DoS attempts against batchers or for troubleshooting of factory/account/paymaster reverts. */ - error FailedOp(uint256 opIndex, address paymaster, string reason); + error FailedOp(uint256 opIndex, string reason); /** * error case when a signature aggregator fails to verify the aggregated signature it had created. */ error SignatureValidationFailed(address aggregator); + /** + * 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); + + /** + * return value of getSenderAddress + */ + error SenderAddressResult(address sender); + + /** + * return value of simulateHandleOp + */ + error ExecutionResult(uint256 preOpGas, uint256 paid, uint48 validAfter, uint48 validUntil, bool targetSuccess, bytes targetResult); + //UserOps handled, per aggregator struct UserOpsPerAggregator { UserOperation[] userOps; @@ -66,7 +113,7 @@ interface IEntryPoint is IStakeManager { /** * Execute a batch of UserOperation. * no signature aggregator is used. - * if any account requires an aggregator (that is, it returned an "actualAggregator" when + * if any account requires an aggregator (that is, it returned an aggregator when * performing simulateValidation), then handleAggregatedOps() must be used instead. * @param ops the operations to execute * @param beneficiary the address to receive the fees @@ -91,51 +138,37 @@ interface IEntryPoint is IStakeManager { /** * Simulate a call to account.validateUserOp and paymaster.validatePaymasterUserOp. - * @dev this method always revert. Successful result is SimulationResult error. other errors are failures. + * @dev this method always revert. Successful result is ValidationResult error. other errors are failures. * @dev The node must also verify it doesn't use banned opcodes, and that it doesn't reference storage outside the account's data. * @param userOp the user operation to validate. */ function simulateValidation(UserOperation calldata userOp) external; /** - * Successful result from simulateValidation. + * gas and return values during simulation * @param preOpGas the gas used for validation (including preValidationGas) * @param prefund the required prefund for this operation - * @param deadline until what time this userOp is valid (the minimum value of account and paymaster's deadline) - * @param paymasterInfo stake information about the paymaster (if any) + * @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) */ - error SimulationResult(uint256 preOpGas, uint256 prefund, uint256 deadline, PaymasterInfo paymasterInfo); - - /** - * returned paymaster info. - * If the UserOperation contains a paymaster, these fields are filled with the paymaster's stake value and delay. - * A bundler must verify these values are above the minimal required values, or else reject the UserOperation. - */ - struct PaymasterInfo { - uint256 paymasterStake; - uint256 paymasterUnstakeDelay; + struct ReturnInfo { + uint256 preOpGas; + uint256 prefund; + bool sigFailed; + uint48 validAfter; + uint48 validUntil; + bytes paymasterContext; } - - /** - * Successful result from simulateValidation, if the account returns a signature aggregator - * @param preOpGas the gas used for validation (including preValidationGas) - * @param prefund the required prefund for this operation - * @param deadline until what time this userOp is valid (the minimum value of account and paymaster's deadline) - * @param paymasterInfo stake information about the paymaster (if any) - * @param aggregationInfo signature aggregation info (if the account requires signature aggregator) - * bundler MUST use it to verify the signature, or reject the UserOperation - */ - error SimulationResultWithAggregation(uint256 preOpGas, uint256 prefund, uint256 deadline, PaymasterInfo paymasterInfo, AggregationInfo aggregationInfo); - /** * returned aggregated signature info. * the aggregator returned by the account, and its current stake. */ - struct AggregationInfo { - address actualAggregator; - uint256 aggregatorStake; - uint256 aggregatorUnstakeDelay; + struct AggregatorStakeInfo { + address aggregator; + StakeInfo stakeInfo; } /** @@ -146,10 +179,20 @@ interface IEntryPoint is IStakeManager { */ function getSenderAddress(bytes memory initCode) external; + /** - * return value of getSenderAddress + * simulate full execution of a UserOperation (including both validation and target execution) + * this method will always revert with "ExecutionResult". + * it performs full validation of the UserOperation, but ignores signature error. + * an optional target address is called after the userop succeeds, and its value is returned + * (before the entire call is reverted) + * Note that in order to collect the the success/failure of the target call, it must be executed + * with trace enabled to track the emitted events. + * @param op the UserOperation to simulate + * @param target if nonzero, a target address to call after userop simulation. If called, the targetSuccess and targetResult + * are set to the return from that call. + * @param targetCallData callData to pass to target address */ - error SenderAddressResult(address sender); - + function simulateHandleOp(UserOperation calldata op, address target, bytes calldata targetCallData) external; } diff --git a/packages/boba/account-abstraction/contracts/interfaces/IPaymaster.sol b/packages/boba/account-abstraction/contracts/interfaces/IPaymaster.sol index 51e2eaa0e1..af50367acf 100644 --- a/packages/boba/account-abstraction/contracts/interfaces/IPaymaster.sol +++ b/packages/boba/account-abstraction/contracts/interfaces/IPaymaster.sol @@ -9,8 +9,14 @@ import "./UserOperation.sol"; */ interface IPaymaster { + enum PostOpMode { + opSucceeded, // user op succeeded + opReverted, // user op reverted. still has to pay for gas. + postOpReverted //user op succeeded, but caused postOp to revert. Now it's a 2nd call, after user's op was deliberately reverted. + } + /** - * payment validation: check if paymaster agree to pay. + * payment validation: check if paymaster agrees to pay. * Must verify sender is the entryPoint. * Revert to reject this request. * Note that bundlers will reject this method if it changes the state, unless the paymaster is trusted (whitelisted) @@ -19,12 +25,16 @@ interface IPaymaster { * @param userOpHash hash of the user's request data. * @param maxCost the maximum cost of this transaction (based on maximum gas and gas price from userOp) * @return context value to send to a postOp - * zero length to signify postOp is not required. - * @return deadline the last block timestamp this operation is valid, or zero if it is valid indefinitely. + * zero length to signify postOp is not required. + * @return validationData signature and time-range of this operation, encoded the same as the return value of validateUserOperation + * <20-byte> sigAuthorizer - 0 for valid signature, 1 to mark signature failure, + * otherwise, an address of an "authorizer" contract. + * <6-byte> validUntil - last timestamp this operation is valid. 0 for "indefinite" + * <6-byte> validAfter - first timestamp this operation is valid * Note that the validation code cannot use block.timestamp (or block.number) directly. */ function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) - external returns (bytes memory context, uint256 deadline); + external returns (bytes memory context, uint256 validationData); /** * post-operation handler. @@ -38,10 +48,4 @@ interface IPaymaster { * @param actualGasCost - actual gas used so far (without this postOp call). */ function postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost) external; - - enum PostOpMode { - opSucceeded, // user op succeeded - opReverted, // user op reverted. still has to pay for gas. - postOpReverted //user op succeeded, but caused postOp to revert. Now its a 2nd call, after user's op was deliberately reverted. - } } diff --git a/packages/boba/account-abstraction/contracts/interfaces/IStakeManager.sol b/packages/boba/account-abstraction/contracts/interfaces/IStakeManager.sol index dc299279eb..c19c1bab88 100644 --- a/packages/boba/account-abstraction/contracts/interfaces/IStakeManager.sol +++ b/packages/boba/account-abstraction/contracts/interfaces/IStakeManager.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.12; /** * manage deposits and stakes. * deposit is just a balance used to pay for UserOperations (either by a paymaster or an account) - * stake is value locked for at least "unstakeDelay" by a paymaster. + * stake is value locked for at least "unstakeDelay" by the staked entity. */ interface IStakeManager { @@ -19,11 +19,11 @@ interface IStakeManager { uint256 amount ); - /// Emitted once a stake is scheduled for withdrawal + /// Emitted when stake or unstake delay are modified event StakeLocked( address indexed account, uint256 totalStaked, - uint256 withdrawTime + uint256 unstakeDelaySec ); /// Emitted once a stake is scheduled for withdrawal @@ -39,28 +39,35 @@ interface IStakeManager { ); /** - * @param deposit the account's deposit - * @param staked true if this account is staked as a paymaster - * @param stake actual amount of ether staked for this paymaster. - * @param unstakeDelaySec minimum delay to withdraw the stake. must be above the global unstakeDelaySec + * @param deposit the entity's deposit + * @param staked true if this entity is staked. + * @param stake actual amount of ether staked for this entity. + * @param unstakeDelaySec minimum delay to withdraw the stake. * @param withdrawTime - first block timestamp where 'withdrawStake' will be callable, or zero if already locked - * @dev sizes were chosen so that (deposit,staked) fit into one cell (used during handleOps) + * @dev sizes were chosen so that (deposit,staked, stake) fit into one cell (used during handleOps) * and the rest fit into a 2nd cell. - * 112 bit allows for 2^15 eth - * 64 bit for full timestamp - * 32 bit allow 150 years for unstake delay + * 112 bit allows for 10^15 eth + * 48 bit for full timestamp + * 32 bit allows 150 years for unstake delay */ struct DepositInfo { uint112 deposit; bool staked; uint112 stake; uint32 unstakeDelaySec; - uint64 withdrawTime; + uint48 withdrawTime; + } + + //API struct used by getStakeInfo and simulateValidation + struct StakeInfo { + uint256 stake; + uint256 unstakeDelaySec; } + /// @return info - full deposit information of given account function getDepositInfo(address account) external view returns (DepositInfo memory info); - /// return the deposit (for gas payment) of the account + /// @return the deposit (for gas payment) of the account function balanceOf(address account) external view returns (uint256); /** diff --git a/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol b/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol index dd25cbd392..dfff42791f 100644 --- a/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol +++ b/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol @@ -5,15 +5,16 @@ pragma solidity ^0.8.12; /** * User Operation struct - * @param sender the sender account of this request + * @param sender the sender account of this request. * @param nonce unique value the sender uses to verify it is not a replay. - * @param initCode if set, the account contract will be created by this constructor + * @param initCode if set, the account contract will be created by this constructor/ * @param callData the method call to execute on this account. - * @param verificationGasLimit gas used for validateUserOp and validatePaymasterUserOp + * @param callGasLimit the gas limit passed to the callData method call. + * @param verificationGasLimit gas used for validateUserOp and validatePaymasterUserOp. * @param preVerificationGas gas not calculated by the handleOps method, but added to the gas paid. Covers batch overhead. - * @param maxFeePerGas same as EIP-1559 gas parameter - * @param maxPriorityFeePerGas same as EIP-1559 gas parameter - * @param paymasterAndData if set, this field hold the paymaster address and "paymaster-specific-data". the paymaster will pay for the transaction instead of the sender + * @param maxFeePerGas same as EIP-1559 gas parameter. + * @param maxPriorityFeePerGas same as EIP-1559 gas parameter. + * @param paymasterAndData if set, this field holds the paymaster address and paymaster-specific data. the paymaster will pay for the transaction instead of the sender. * @param signature sender-verified signature over the entire request, the EntryPoint address and the chain ID. */ struct UserOperation { @@ -31,6 +32,9 @@ pragma solidity ^0.8.12; bytes signature; } +/** + * Utility functions helpful when working with UserOperation structs. + */ library UserOperationLib { function getSender(UserOperation calldata userOp) internal pure returns (address) { @@ -40,7 +44,7 @@ library UserOperationLib { return address(uint160(data)); } - //relayer/miner might submit the TX with higher priorityFee, but the user should not + //relayer/block builder might submit the TX with higher priorityFee, but the user should not // pay above what he signed for. function gasPrice(UserOperation calldata userOp) internal view returns (uint256) { unchecked { diff --git a/packages/boba/account-abstraction/contracts/samples/BobaDepositPaymaster.sol b/packages/boba/account-abstraction/contracts/samples/BobaDepositPaymaster.sol index 473b7558b2..44f75ac4f5 100644 --- a/packages/boba/account-abstraction/contracts/samples/BobaDepositPaymaster.sol +++ b/packages/boba/account-abstraction/contracts/samples/BobaDepositPaymaster.sol @@ -10,12 +10,11 @@ pragma solidity ^0.8.12; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "@openzeppelin/contracts/access/Ownable.sol"; import "../core/BasePaymaster.sol"; import "./IBobaStraw.sol"; /** - * A token-based paymaster that accepts token deposit + * A token-based paymaster that accepts token deposits * The deposit is only a safeguard: the user pays with his token balance. * only if the user didn't approve() the paymaster, or if the token balance is not enough, the deposit will be used. * thus the required deposit is to cover just one method call. @@ -93,6 +92,10 @@ contract BobaDepositPaymaster is BasePaymaster { } } + /** + * @return amount - the amount of given token deposited to the Paymaster. + * @return _unlockBlock - the block height at which the deposit can be withdrawn. + */ function depositInfo(IERC20 token, address account) public view returns (uint256 amount, uint256 _unlockBlock) { amount = balances[token][account]; _unlockBlock = unlockBlock[account]; @@ -152,8 +155,8 @@ contract BobaDepositPaymaster is BasePaymaster { * Note that the sender's balance is not checked. If it fails to pay from its balance, * this deposit will be used to compensate the paymaster for the transaction. */ - function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) - external view override returns (bytes memory context, uint256 deadline) { + function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) + internal view override returns (bytes memory context, uint256 validationData) { (userOpHash); // verificationGasLimit is dual-purposed, as gas limit for postOp. make sure it is high enough diff --git a/packages/boba/account-abstraction/contracts/samples/BobaVerifyingPaymaster.sol b/packages/boba/account-abstraction/contracts/samples/BobaVerifyingPaymaster.sol index 4575bf289d..e43440b770 100644 --- a/packages/boba/account-abstraction/contracts/samples/BobaVerifyingPaymaster.sol +++ b/packages/boba/account-abstraction/contracts/samples/BobaVerifyingPaymaster.sol @@ -6,6 +6,7 @@ pragma solidity ^0.8.12; /* solhint-disable reason-string */ +/* solhint-disable no-inline-assembly */ import "../core/BasePaymaster.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; @@ -15,15 +16,19 @@ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; * The paymaster trusts an external signer to sign the transaction. * The calling user must pass the UserOp to that external signer first, which performs * whatever off-chain verification before signing the UserOp. - * Note that this signature is NOT a replacement for wallet signature: - * - the paymaster signs to agree to PAY for GAS. - * - the wallet signs to prove identity and wallet ownership. + * Note that this signature is NOT a replacement for account-specific signature: + * - the paymaster checks a signature to agree to PAY for GAS. + * - the account checks a signature to prove identity and account ownership. */ contract BobaVerifyingPaymaster is BasePaymaster { using ECDSA for bytes32; using UserOperationLib for UserOperation; + uint256 private constant VALID_TIMESTAMP_OFFSET = 20; + + uint256 private constant SIGNATURE_OFFSET = 84; + address public immutable verifyingSigner; address public bobaDepositPaymaster; address public approvedToken; @@ -37,6 +42,25 @@ contract BobaVerifyingPaymaster is BasePaymaster { approvedToken = _approvedToken; } + mapping(address => uint256) public senderNonce; + + function pack(UserOperation calldata userOp) internal pure returns (bytes memory ret) { + // lighter signature scheme. must match UserOp.ts#packUserOp + bytes calldata pnd = userOp.paymasterAndData; + // copy directly the userOp from calldata up to (but not including) the paymasterAndData. + // this encoding depends on the ABI encoding of calldata, but is much lighter to copy + // than referencing each field separately. + assembly { + let ofs := userOp + let len := sub(sub(pnd.offset, ofs), 32) + ret := mload(0x40) + mstore(0x40, add(ret, add(len, 32))) + mstore(ret, len) + calldatacopy(add(ret, 32), ofs, len) + } + } + + /** * return the hash we're going to sign off-chain (and validate on-chain) * this method is called by the off-chain service, to sign the request. @@ -44,45 +68,52 @@ contract BobaVerifyingPaymaster is BasePaymaster { * note that this signature covers all fields of the UserOperation, except the "paymasterAndData", * which will carry the signature itself. */ - function getHash(UserOperation calldata userOp) - public pure returns (bytes32) { + function getHash(UserOperation calldata userOp, uint48 validUntil, uint48 validAfter) + public view returns (bytes32) { //can't use userOp.hash(), since it contains also the paymasterAndData itself. + return keccak256(abi.encode( - userOp.getSender(), - userOp.nonce, - keccak256(userOp.initCode), - keccak256(userOp.callData), - userOp.callGasLimit, - userOp.verificationGasLimit, - userOp.preVerificationGas, - userOp.maxFeePerGas, - userOp.maxPriorityFeePerGas + pack(userOp), + block.chainid, + address(this), + senderNonce[userOp.getSender()], + validUntil, + validAfter )); } /** * verify our external signer signed this request. * the "paymasterAndData" is expected to be the paymaster and a signature over the entire request params + * paymasterAndData[:20] : address(this) + * paymasterAndData[20:84] : abi.encode(validUntil, validAfter) + * paymasterAndData[84:] : signature */ - function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund) - external view override returns (bytes memory context, uint256 deadline) { + function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund) + internal override returns (bytes memory context, uint256 validationData) { (requiredPreFund); - bytes32 hash = getHash(userOp); - bytes calldata paymasterAndData = userOp.paymasterAndData; - uint256 sigLength = paymasterAndData.length - 20; + (uint48 validUntil, uint48 validAfter, bytes calldata signature) = parsePaymasterAndData(userOp.paymasterAndData); //ECDSA library supports both 64 and 65-byte long signatures. // we only "require" it here so that the revert reason on invalid signature will be of "VerifyingPaymaster", and not "ECDSA" - require(sigLength == 64 || sigLength == 65, "VerifyingPaymaster: invalid signature length in paymasterAndData"); + require(signature.length == 64 || signature.length == 65, "VerifyingPaymaster: invalid signature length in paymasterAndData"); + bytes32 hash = ECDSA.toEthSignedMessageHash(getHash(userOp, validUntil, validAfter)); + senderNonce[userOp.getSender()]++; - //ignore signature mismatch of from==ZERO_ADDRESS (for eth_callUserOp validation purposes) - // solhint-disable-next-line avoid-tx-origin - require(verifyingSigner == hash.toEthSignedMessageHash().recover(paymasterAndData[20 :]) || tx.origin == address(0), "VerifyingPaymaster: wrong signature"); + //don't revert on signature failure: return SIG_VALIDATION_FAILED + if (verifyingSigner != ECDSA.recover(hash, signature)) { + return ("",_packValidationData(true,validUntil,validAfter)); + } require(_validateCallDataApprove(userOp.callData) || _validateCallDataDeposit(userOp.callData), "VerifyingPaymaster: invalid operation"); //no need for other on-chain validation: entire UserOp should have been checked // by the external service prior to signing it. - return ("", 0); + return ("",_packValidationData(false,validUntil,validAfter)); + } + + function parsePaymasterAndData(bytes calldata paymasterAndData) public pure returns(uint48 validUntil, uint48 validAfter, bytes calldata signature) { + (validUntil, validAfter) = abi.decode(paymasterAndData[VALID_TIMESTAMP_OFFSET:SIGNATURE_OFFSET],(uint48, uint48)); + signature = paymasterAndData[SIGNATURE_OFFSET:]; } function _validateCallDataApprove(bytes calldata opCallData) internal view returns(bool) { diff --git a/packages/boba/account-abstraction/contracts/samples/DepositPaymaster.sol b/packages/boba/account-abstraction/contracts/samples/DepositPaymaster.sol index 2cca1998c6..84bfffcec6 100644 --- a/packages/boba/account-abstraction/contracts/samples/DepositPaymaster.sol +++ b/packages/boba/account-abstraction/contracts/samples/DepositPaymaster.sol @@ -6,12 +6,11 @@ pragma solidity ^0.8.12; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "@openzeppelin/contracts/access/Ownable.sol"; import "../core/BasePaymaster.sol"; import "./IOracle.sol"; /** - * A token-based paymaster that accepts token deposit + * A token-based paymaster that accepts token deposits * The deposit is only a safeguard: the user pays with his token balance. * only if the user didn't approve() the paymaster, or if the token balance is not enough, the deposit will be used. * thus the required deposit is to cover just one method call. @@ -46,7 +45,7 @@ contract DepositPaymaster is BasePaymaster { * owner of the paymaster should add supported tokens */ function addToken(IERC20 token, IOracle tokenPriceOracle) external onlyOwner { - require(oracles[token] == NULL_ORACLE); + require(oracles[token] == NULL_ORACLE, "Token already set"); oracles[token] = tokenPriceOracle; } @@ -70,6 +69,10 @@ contract DepositPaymaster is BasePaymaster { } } + /** + * @return amount - the amount of given token deposited to the Paymaster. + * @return _unlockBlock - the block height at which the deposit can be withdrawn. + */ function depositInfo(IERC20 token, address account) public view returns (uint256 amount, uint256 _unlockBlock) { amount = balances[token][account]; _unlockBlock = unlockBlock[account]; @@ -122,8 +125,8 @@ contract DepositPaymaster is BasePaymaster { * Note that the sender's balance is not checked. If it fails to pay from its balance, * this deposit will be used to compensate the paymaster for the transaction. */ - function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) - external view override returns (bytes memory context, uint256 deadline) { + function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) + internal view override returns (bytes memory context, uint256 validationData) { (userOpHash); // verificationGasLimit is dual-purposed, as gas limit for postOp. make sure it is high enough diff --git a/packages/boba/account-abstraction/contracts/samples/GPODepositPaymaster.sol b/packages/boba/account-abstraction/contracts/samples/GPODepositPaymaster.sol index 4b7bc1e985..4297a36b7f 100644 --- a/packages/boba/account-abstraction/contracts/samples/GPODepositPaymaster.sol +++ b/packages/boba/account-abstraction/contracts/samples/GPODepositPaymaster.sol @@ -6,7 +6,6 @@ pragma solidity ^0.8.12; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "@openzeppelin/contracts/access/Ownable.sol"; import "../core/BasePaymaster.sol"; import "./IBobaGasPriceOracle.sol"; @@ -56,6 +55,10 @@ contract GPODepositPaymaster is BasePaymaster { } } + /** + * @return amount - the amount of given token deposited to the Paymaster. + * @return _unlockBlock - the block height at which the deposit can be withdrawn. + */ function depositInfo(address account) public view returns (uint256 amount, uint256 _unlockBlock) { amount = balances[account]; _unlockBlock = unlockBlock[account]; @@ -107,8 +110,8 @@ contract GPODepositPaymaster is BasePaymaster { * Note that the sender's balance is not checked. If it fails to pay from its balance, * this deposit will be used to compensate the paymaster for the transaction. */ - function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) - external view override returns (bytes memory context, uint256 deadline) { + function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) + internal view override returns (bytes memory context, uint256 validationData) { (userOpHash); // verificationGasLimit is dual-purposed, as gas limit for postOp. make sure it is high enough diff --git a/packages/boba/account-abstraction/contracts/samples/ManualDepositPaymaster.sol b/packages/boba/account-abstraction/contracts/samples/ManualDepositPaymaster.sol index a737c71fe5..a6e288cbf3 100644 --- a/packages/boba/account-abstraction/contracts/samples/ManualDepositPaymaster.sol +++ b/packages/boba/account-abstraction/contracts/samples/ManualDepositPaymaster.sol @@ -6,12 +6,11 @@ pragma solidity ^0.8.12; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "@openzeppelin/contracts/access/Ownable.sol"; import "../core/BasePaymaster.sol"; import "./IOracle.sol"; /** - * A token-based paymaster that accepts token deposit + * A token-based paymaster that accepts token deposits * The deposit is only a safeguard: the user pays with his token balance. * only if the user didn't approve() the paymaster, or if the token balance is not enough, the deposit will be used. * thus the required deposit is to cover just one method call. @@ -82,6 +81,10 @@ contract ManualDepositPaymaster is BasePaymaster { } } + /** + * @return amount - the amount of given token deposited to the Paymaster. + * @return _unlockBlock - the block height at which the deposit can be withdrawn. + */ function depositInfo(IERC20 token, address account) public view returns (uint256 amount, uint256 _unlockBlock) { amount = balances[token][account]; _unlockBlock = unlockBlock[account]; @@ -156,8 +159,8 @@ contract ManualDepositPaymaster is BasePaymaster { * Note that the sender's balance is not checked. If it fails to pay from its balance, * this deposit will be used to compensate the paymaster for the transaction. */ - function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) - external view override returns (bytes memory context, uint256 deadline) { + function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) + internal view override returns (bytes memory context, uint256 validationData) { (userOpHash); // verificationGasLimit is dual-purposed, as gas limit for postOp. make sure it is high enough diff --git a/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol b/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol index a2d543813f..65fe9e68f2 100644 --- a/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol +++ b/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol @@ -5,8 +5,11 @@ pragma solidity ^0.8.12; /* solhint-disable no-inline-assembly */ /* solhint-disable reason-string */ -import "../core/BaseAccount.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; + +import "../core/BaseAccount.sol"; /** * minimal account. @@ -14,61 +17,63 @@ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; * has execute, eth handling methods * has a single signer that can send requests through the entryPoint. */ -contract SimpleAccount is BaseAccount { +contract SimpleAccount is BaseAccount, UUPSUpgradeable, Initializable { using ECDSA for bytes32; + //filler member, to push the nonce and owner to the same slot + // the "Initializeble" class takes 2 bytes in the first slot + bytes28 private _filler; + //explicit sizes of nonce, to fit a single storage cell with "owner" uint96 private _nonce; address public owner; + IEntryPoint private immutable _entryPoint; + + event SimpleAccountInitialized(IEntryPoint indexed entryPoint, address indexed owner); + + modifier onlyOwner() { + _onlyOwner(); + _; + } + + /// @inheritdoc BaseAccount function nonce() public view virtual override returns (uint256) { return _nonce; } + /// @inheritdoc BaseAccount function entryPoint() public view virtual override returns (IEntryPoint) { return _entryPoint; } - IEntryPoint private _entryPoint; - - event EntryPointChanged(address indexed oldEntryPoint, address indexed newEntryPoint); // solhint-disable-next-line no-empty-blocks receive() external payable {} - constructor(IEntryPoint anEntryPoint, address anOwner) { + constructor(IEntryPoint anEntryPoint) { _entryPoint = anEntryPoint; - owner = anOwner; - } - - modifier onlyOwner() { - _onlyOwner(); - _; + _disableInitializers(); } function _onlyOwner() internal view { - //directly from EOA owner, or through the entryPoint (which gets redirected through execFromEntryPoint) + //directly from EOA owner, or through the account itself (which gets redirected through execute()) require(msg.sender == owner || msg.sender == address(this), "only owner"); } /** - * transfer eth value to a destination address - */ - function transfer(address payable dest, uint256 amount) external onlyOwner { - dest.transfer(amount); - } - - /** - * execute a transaction (called directly from owner, not by entryPoint) + * execute a transaction (called directly from owner, or by entryPoint) */ - function exec(address dest, uint256 value, bytes calldata func) external onlyOwner { + function execute(address dest, uint256 value, bytes calldata func) external { + _requireFromEntryPointOrOwner(); _call(dest, value, func); } /** - * execute a sequence of transaction + * execute a sequence of transactions */ - function execBatch(address[] calldata dest, bytes[] calldata func) external onlyOwner { + function executeBatch(address[] calldata dest, bytes[] calldata func) external { + _requireFromEntryPointOrOwner(); require(dest.length == func.length, "wrong array lengths"); for (uint256 i = 0; i < dest.length; i++) { _call(dest[i], 0, func[i]); @@ -76,35 +81,22 @@ contract SimpleAccount is BaseAccount { } /** - * change entry-point: - * an account must have a method for replacing the entryPoint, in case the the entryPoint is - * upgraded to a newer version. + * @dev The _entryPoint member is immutable, to reduce gas consumption. To upgrade EntryPoint, + * a new implementation of SimpleAccount must be deployed with the new EntryPoint address, then upgrading + * the implementation by calling `upgradeTo()` */ - function _updateEntryPoint(address newEntryPoint) internal override { - emit EntryPointChanged(address(_entryPoint), newEntryPoint); - _entryPoint = IEntryPoint(payable(newEntryPoint)); - } - - function _requireFromAdmin() internal view override { - _onlyOwner(); + function initialize(address anOwner) public virtual initializer { + _initialize(anOwner); } - /** - * validate the userOp is correct. - * revert if it doesn't. - * - must only be called from the entryPoint. - * - make sure the signature is of our supported signer. - * - validate current nonce matches request nonce, and increment it. - * - pay prefund, in case current deposit is not enough - */ - function _requireFromEntryPoint() internal override view { - require(msg.sender == address(entryPoint()), "account: not from EntryPoint"); + function _initialize(address anOwner) internal virtual { + owner = anOwner; + emit SimpleAccountInitialized(_entryPoint, owner); } - // called by entryPoint, only after validateUserOp succeeded. - function execFromEntryPoint(address dest, uint256 value, bytes calldata func) external { - _requireFromEntryPoint(); - _call(dest, value, func); + // Require the function call went through EntryPoint or owner + function _requireFromEntryPointOrOwner() internal view { + require(msg.sender == address(entryPoint()) || msg.sender == owner, "account: not Owner or EntryPoint"); } /// implement template method of BaseAccount @@ -113,12 +105,11 @@ contract SimpleAccount is BaseAccount { } /// implement template method of BaseAccount - function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash, address) - internal override virtual returns (uint256 deadline) { + function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash) + internal override virtual returns (uint256 validationData) { bytes32 hash = userOpHash.toEthSignedMessageHash(); - //ignore signature mismatch of from==ZERO_ADDRESS (for eth_callUserOp validation purposes) - // solhint-disable-next-line avoid-tx-origin - require(owner == hash.recover(userOp.signature) || tx.origin == address(0), "account: wrong signature"); + if (owner != hash.recover(userOp.signature)) + return SIG_VALIDATION_FAILED; return 0; } @@ -142,9 +133,7 @@ contract SimpleAccount is BaseAccount { * deposit more funds for this account in the entryPoint */ function addDeposit() public payable { - - (bool req,) = address(entryPoint()).call{value : msg.value}(""); - require(req); + entryPoint().depositTo{value : msg.value}(address(this)); } /** @@ -155,5 +144,10 @@ contract SimpleAccount is BaseAccount { function withdrawDepositTo(address payable withdrawAddress, uint256 amount) public onlyOwner { entryPoint().withdrawTo(withdrawAddress, amount); } + + function _authorizeUpgrade(address newImplementation) internal view override { + (newImplementation); + _onlyOwner(); + } } diff --git a/packages/boba/account-abstraction/contracts/samples/SimpleAccountDeployer.sol b/packages/boba/account-abstraction/contracts/samples/SimpleAccountDeployer.sol deleted file mode 100644 index 4f59ecf6af..0000000000 --- a/packages/boba/account-abstraction/contracts/samples/SimpleAccountDeployer.sol +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity ^0.8.12; - -import "./SimpleAccount.sol"; -import "@openzeppelin/contracts/utils/Create2.sol"; -/** - * A sampler deployer contract for SimpleAccount - * A UserOperations "initCode" holds the address of the deployer, and a method call (to deployAccount, in this sample deployer). - * The deployer's deployAccount returns the target account address even if it is already installed. - * This way, the entryPoint.getSenderAddress() can be called either before or after the account is created. - */ -contract SimpleAccountDeployer { - - /** - * create an account, and return its address. - * returns the address even if the account is already deployed. - * Note that during UserOperation execution, this method is called only if the account is not deployed. - * This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation - */ - function deployAccount(IEntryPoint entryPoint, address owner, uint salt) public returns (SimpleAccount ret) { - address addr = getAddress(entryPoint, owner, salt); - uint codeSize = addr.code.length; - if (codeSize > 0) { - return SimpleAccount(payable(addr)); - } - ret = new SimpleAccount{salt : bytes32(salt)}(entryPoint, owner); - } - - /** - * calculate the counterfactual address of this account as it would be returned by deployAccount() - */ - function getAddress(IEntryPoint entryPoint, address owner, uint salt) public view returns (address) { - return Create2.computeAddress(bytes32(salt), keccak256(abi.encodePacked( - type(SimpleAccount).creationCode, - abi.encode(entryPoint, owner)) - )); - } -} diff --git a/packages/boba/account-abstraction/contracts/samples/SimpleAccountFactory.sol b/packages/boba/account-abstraction/contracts/samples/SimpleAccountFactory.sol new file mode 100644 index 0000000000..25f37f1524 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/samples/SimpleAccountFactory.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "@openzeppelin/contracts/utils/Create2.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import "./SimpleAccount.sol"; + +/** + * A sample factory contract for SimpleAccount + * A UserOperations "initCode" holds the address of the factory, and a method call (to createAccount, in this sample factory). + * The factory's createAccount returns the target account address even if it is already installed. + * This way, the entryPoint.getSenderAddress() can be called either before or after the account is created. + */ +contract SimpleAccountFactory { + SimpleAccount public immutable accountImplementation; + + constructor(IEntryPoint _entryPoint) { + accountImplementation = new SimpleAccount(_entryPoint); + } + + /** + * create an account, and return its address. + * returns the address even if the account is already deployed. + * Note that during UserOperation execution, this method is called only if the account is not deployed. + * This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation + */ + function createAccount(address owner,uint256 salt) public returns (SimpleAccount ret) { + address addr = getAddress(owner, salt); + uint codeSize = addr.code.length; + if (codeSize > 0) { + return SimpleAccount(payable(addr)); + } + ret = SimpleAccount(payable(new ERC1967Proxy{salt : bytes32(salt)}( + address(accountImplementation), + abi.encodeCall(SimpleAccount.initialize, (owner)) + ))); + } + + /** + * calculate the counterfactual address of this account as it would be returned by createAccount() + */ + function getAddress(address owner,uint256 salt) public view returns (address) { + return Create2.computeAddress(bytes32(salt), keccak256(abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode( + address(accountImplementation), + abi.encodeCall(SimpleAccount.initialize, (owner)) + ) + ))); + } +} diff --git a/packages/boba/account-abstraction/contracts/samples/SimpleAccountForTokens.sol b/packages/boba/account-abstraction/contracts/samples/SimpleAccountForTokens.sol deleted file mode 100644 index 2e773e21d3..0000000000 --- a/packages/boba/account-abstraction/contracts/samples/SimpleAccountForTokens.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity ^0.8.12; - -import "./SimpleAccount.sol"; -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -//in order to be created with tokens, the account has to have allowance to the paymaster in advance. -// the simplest strategy is assign the allowance in the constructor or init function -contract SimpleAccountForTokens is SimpleAccount { - - constructor(IEntryPoint _entryPoint, address _owner, IERC20 token, address paymaster) SimpleAccount(_entryPoint, _owner) { - token.approve(paymaster, type(uint256).max); - } -} diff --git a/packages/boba/account-abstraction/contracts/samples/SimpleAccountUpgradeable.sol b/packages/boba/account-abstraction/contracts/samples/SimpleAccountUpgradeable.sol deleted file mode 100644 index 33ee904493..0000000000 --- a/packages/boba/account-abstraction/contracts/samples/SimpleAccountUpgradeable.sol +++ /dev/null @@ -1,165 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity ^0.8.12; - -/* solhint-disable avoid-low-level-calls */ -/* solhint-disable no-inline-assembly */ -/* solhint-disable reason-string */ - -import "../core/BaseAccount.sol"; -import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; - -/** - * minimal account. - * this is sample minimal account. - * has execute, eth handling methods - * has a single signer that can send requests through the entryPoint. - */ -contract SimpleAccountUpgradeable is BaseAccount, Initializable { - using ECDSA for bytes32; - - //explicit sizes of nonce, to fit a single storage cell with "owner" - uint96 private _nonce; - address public owner; - - function nonce() public view virtual override returns (uint256) { - return _nonce; - } - - function entryPoint() public view virtual override returns (IEntryPoint) { - return _entryPoint; - } - - IEntryPoint private _entryPoint; - - event EntryPointChanged(address indexed oldEntryPoint, address indexed newEntryPoint); - - // solhint-disable-next-line no-empty-blocks - receive() external payable {} - - function initialize(IEntryPoint anEntryPoint, address anOwner) public initializer { - require(anOwner != address(0), "Owner cannot be zero address"); - _entryPoint = anEntryPoint; - owner = anOwner; - } - - modifier onlyOwner() { - _onlyOwner(); - _; - } - - function _onlyOwner() internal view { - //directly from EOA owner, or through the entryPoint (which gets redirected through execFromEntryPoint) - require(msg.sender == owner || msg.sender == address(this), "only owner"); - } - - function transferOwnership(address newOwner) public onlyOwner { - require(newOwner != address(0), "New owner cannot be the zero address"); - owner = newOwner; - } - - /** - * transfer eth value to a destination address - */ - function transfer(address payable dest, uint256 amount) external onlyOwner { - dest.transfer(amount); - } - - /** - * execute a transaction (called directly from owner, not by entryPoint) - */ - function exec(address dest, uint256 value, bytes calldata func) external onlyOwner { - _call(dest, value, func); - } - - /** - * execute a sequence of transaction - */ - function execBatch(address[] calldata dest, bytes[] calldata func) external onlyOwner { - require(dest.length == func.length, "wrong array lengths"); - for (uint256 i = 0; i < dest.length; i++) { - _call(dest[i], 0, func[i]); - } - } - - /** - * change entry-point: - * an account must have a method for replacing the entryPoint, in case the the entryPoint is - * upgraded to a newer version. - */ - function _updateEntryPoint(address newEntryPoint) internal override { - emit EntryPointChanged(address(_entryPoint), newEntryPoint); - _entryPoint = IEntryPoint(payable(newEntryPoint)); - } - - function _requireFromAdmin() internal view override { - _onlyOwner(); - } - - /** - * validate the userOp is correct. - * revert if it doesn't. - * - must only be called from the entryPoint. - * - make sure the signature is of our supported signer. - * - validate current nonce matches request nonce, and increment it. - * - pay prefund, in case current deposit is not enough - */ - function _requireFromEntryPoint() internal override view { - require(msg.sender == address(entryPoint()), "account: not from EntryPoint"); - } - - // called by entryPoint, only after validateUserOp succeeded. - function execFromEntryPoint(address dest, uint256 value, bytes calldata func) external { - _requireFromEntryPoint(); - _call(dest, value, func); - } - - /// implement template method of BaseAccount - function _validateAndUpdateNonce(UserOperation calldata userOp) internal override { - require(_nonce++ == userOp.nonce, "account: invalid nonce"); - } - - /// implement template method of BaseAccount - function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash, address) - internal override virtual returns (uint256 deadline) { - bytes32 hash = userOpHash.toEthSignedMessageHash(); - //ignore signature mismatch of from==ZERO_ADDRESS (for eth_callUserOp validation purposes) - // solhint-disable-next-line avoid-tx-origin - require(owner == hash.recover(userOp.signature) || tx.origin == address(0), "account: wrong signature"); - return 0; - } - - function _call(address target, uint256 value, bytes memory data) internal { - (bool success, bytes memory result) = target.call{value : value}(data); - if (!success) { - assembly { - revert(add(result, 32), mload(result)) - } - } - } - - /** - * check current account deposit in the entryPoint - */ - function getDeposit() public view returns (uint256) { - return entryPoint().balanceOf(address(this)); - } - - /** - * deposit more funds for this account in the entryPoint - */ - function addDeposit() public payable { - - (bool req,) = address(entryPoint()).call{value : msg.value}(""); - require(req); - } - - /** - * withdraw value from the account's deposit - * @param withdrawAddress target to send to - * @param amount to withdraw - */ - function withdrawDepositTo(address payable withdrawAddress, uint256 amount) public onlyOwner { - entryPoint().withdrawTo(withdrawAddress, amount); - } -} diff --git a/packages/boba/account-abstraction/contracts/samples/TestAggregatedAccount.sol b/packages/boba/account-abstraction/contracts/samples/TestAggregatedAccount.sol deleted file mode 100644 index 9c85c1daff..0000000000 --- a/packages/boba/account-abstraction/contracts/samples/TestAggregatedAccount.sol +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity ^0.8.12; - -import "../interfaces/IAggregatedAccount.sol"; -import "../core/BaseAccount.sol"; -import "./SimpleAccount.sol"; -import "../interfaces/UserOperation.sol"; - -/** - * test aggregated-signature account. - * works only with TestAggregatedSignature, which doesn't really check signature, but nonce sum - * a true aggregated account should expose data (e.g. its public key) to the aggregator. - */ -contract TestAggregatedAccount is SimpleAccount, IAggregatedAccount { - address public immutable aggregator; - - constructor(IEntryPoint anEntryPoint, address anAggregator) - SimpleAccount(anEntryPoint, address(0)) { - aggregator = anAggregator; - } - - function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash, address userOpAggregator) - internal override view returns (uint256 deadline) { - (userOp, userOpHash); - require(userOpAggregator == aggregator, "wrong aggregator"); - return 0; - } - - function getAggregator() external override view returns (address) { - return aggregator; - } -} diff --git a/packages/boba/account-abstraction/contracts/samples/TokenPaymaster.sol b/packages/boba/account-abstraction/contracts/samples/TokenPaymaster.sol index d7d55fe209..452080716f 100644 --- a/packages/boba/account-abstraction/contracts/samples/TokenPaymaster.sol +++ b/packages/boba/account-abstraction/contracts/samples/TokenPaymaster.sol @@ -4,14 +4,13 @@ pragma solidity ^0.8.12; /* solhint-disable reason-string */ import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import "./SimpleAccount.sol"; import "../core/BasePaymaster.sol"; /** - * A sample paymaster that define itself as a token to pay for gas. + * A sample paymaster that defines itself as a token to pay for gas. * The paymaster IS the token to use, since a paymaster cannot use an external contract. * Also, the exchange rate has to be fixed, since it can't reference an external Uniswap or other exchange contract. - * subclass should override "getTokenValueOfEth to provide actual token exchange rate, settable by the owner. + * subclass should override "getTokenValueOfEth" to provide actual token exchange rate, settable by the owner. * Known Limitation: this paymaster is exploitable when put into a batch with multiple ops (of different accounts): * - while a single op can't exploit the paymaster (if postOp fails to withdraw the tokens, the user's op is reverted, * and then we know we can withdraw the tokens), multiple ops with different senders (all using this paymaster) @@ -24,10 +23,10 @@ contract TokenPaymaster is BasePaymaster, ERC20 { //calculated cost of the postOp uint256 constant public COST_OF_POST = 15000; - address public theDeployer; + address public immutable theFactory; - constructor(address accountDeployer, string memory _symbol, IEntryPoint _entryPoint) ERC20(_symbol, _symbol) BasePaymaster(_entryPoint) { - theDeployer = accountDeployer; + constructor(address accountFactory, string memory _symbol, IEntryPoint _entryPoint) ERC20(_symbol, _symbol) BasePaymaster(_entryPoint) { + theFactory = accountFactory; //make it non-empty _mint(address(this), 1); @@ -36,7 +35,11 @@ contract TokenPaymaster is BasePaymaster, ERC20 { } - //helpers for owner, to mint and withdraw tokens. + /** + * helpers for owner, to mint and withdraw tokens. + * @param recipient - the address that will receive the minted tokens. + * @param amount - the amount it will receive. + */ function mintTokens(address recipient, uint256 amount) external onlyOwner { _mint(recipient, amount); } @@ -54,7 +57,7 @@ contract TokenPaymaster is BasePaymaster, ERC20 { _approve(address(this), newOwner, type(uint).max); } - //TODO: this method assumes a fixed ratio of token-to-eth. subclass should override to supply oracle + //Note: this method assumes a fixed ratio of token-to-eth. subclass should override to supply oracle // or a setter. function getTokenValueOfEth(uint256 valueEth) internal view virtual returns (uint256 valueToken) { return valueEth / 100; @@ -62,13 +65,12 @@ contract TokenPaymaster is BasePaymaster, ERC20 { /** * validate the request: - * if this is a constructor call, make sure it is a known account (that is, a contract that - * we trust that in its constructor will set + * if this is a constructor call, make sure it is a known account. * verify the sender has enough tokens. * (since the paymaster is also the token, there is no notion of "approval") */ - function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund) - external view override returns (bytes memory context, uint256 deadline) { + function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund) + internal view override returns (bytes memory context, uint256 validationData) { uint256 tokenPrefund = getTokenValueOfEth(requiredPreFund); // verificationGasLimit is dual-purposed, as gas limit for postOp. make sure it is high enough @@ -87,12 +89,10 @@ contract TokenPaymaster is BasePaymaster, ERC20 { } // when constructing an account, validate constructor code and parameters - // this code highly dependent on the deployer we use. - // our deployer has a method deploy(bytes,salt) + // we trust our factory (and that it doesn't have any other public methods) function _validateConstructor(UserOperation calldata userOp) internal virtual view { - //we trust a specific deployer contract - address deployer = address(bytes20(userOp.initCode[0 : 20])); - require(deployer == theDeployer, "TokenPaymaster: wrong account deployer"); + address factory = address(bytes20(userOp.initCode[0 : 20])); + require(factory == theFactory, "TokenPaymaster: wrong account factory"); } /** diff --git a/packages/boba/account-abstraction/contracts/samples/VerifyingPaymaster.sol b/packages/boba/account-abstraction/contracts/samples/VerifyingPaymaster.sol index d81b9e3a76..556ba5c231 100644 --- a/packages/boba/account-abstraction/contracts/samples/VerifyingPaymaster.sol +++ b/packages/boba/account-abstraction/contracts/samples/VerifyingPaymaster.sol @@ -2,18 +2,18 @@ pragma solidity ^0.8.12; /* solhint-disable reason-string */ +/* solhint-disable no-inline-assembly */ import "../core/BasePaymaster.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; - /** * A sample paymaster that uses external service to decide whether to pay for the UserOp. * The paymaster trusts an external signer to sign the transaction. * The calling user must pass the UserOp to that external signer first, which performs * whatever off-chain verification before signing the UserOp. - * Note that this signature is NOT a replacement for wallet signature: - * - the paymaster signs to agree to PAY for GAS. - * - the wallet signs to prove identity and account ownership. + * Note that this signature is NOT a replacement for the account-specific signature: + * - the paymaster checks a signature to agree to PAY for GAS. + * - the account checks a signature to prove identity and account ownership. */ contract VerifyingPaymaster is BasePaymaster { @@ -22,10 +22,32 @@ contract VerifyingPaymaster is BasePaymaster { address public immutable verifyingSigner; + uint256 private constant VALID_TIMESTAMP_OFFSET = 20; + + uint256 private constant SIGNATURE_OFFSET = 84; + constructor(IEntryPoint _entryPoint, address _verifyingSigner) BasePaymaster(_entryPoint) { verifyingSigner = _verifyingSigner; } + mapping(address => uint256) public senderNonce; + + function pack(UserOperation calldata userOp) internal pure returns (bytes memory ret) { + // lighter signature scheme. must match UserOp.ts#packUserOp + bytes calldata pnd = userOp.paymasterAndData; + // copy directly the userOp from calldata up to (but not including) the paymasterAndData. + // this encoding depends on the ABI encoding of calldata, but is much lighter to copy + // than referencing each field separately. + assembly { + let ofs := userOp + let len := sub(sub(pnd.offset, ofs), 32) + ret := mload(0x40) + mstore(0x40, add(ret, add(len, 32))) + mstore(ret, len) + calldatacopy(add(ret, 32), ofs, len) + } + } + /** * return the hash we're going to sign off-chain (and validate on-chain) * this method is called by the off-chain service, to sign the request. @@ -33,44 +55,50 @@ contract VerifyingPaymaster is BasePaymaster { * note that this signature covers all fields of the UserOperation, except the "paymasterAndData", * which will carry the signature itself. */ - function getHash(UserOperation calldata userOp) - public pure returns (bytes32) { + function getHash(UserOperation calldata userOp, uint48 validUntil, uint48 validAfter) + public view returns (bytes32) { //can't use userOp.hash(), since it contains also the paymasterAndData itself. + return keccak256(abi.encode( - userOp.getSender(), - userOp.nonce, - keccak256(userOp.initCode), - keccak256(userOp.callData), - userOp.callGasLimit, - userOp.verificationGasLimit, - userOp.preVerificationGas, - userOp.maxFeePerGas, - userOp.maxPriorityFeePerGas + pack(userOp), + block.chainid, + address(this), + senderNonce[userOp.getSender()], + validUntil, + validAfter )); } /** * verify our external signer signed this request. * the "paymasterAndData" is expected to be the paymaster and a signature over the entire request params + * paymasterAndData[:20] : address(this) + * paymasterAndData[20:84] : abi.encode(validUntil, validAfter) + * paymasterAndData[84:] : signature */ - function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund) - external view override returns (bytes memory context, uint256 deadline) { + function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund) + internal override returns (bytes memory context, uint256 validationData) { (requiredPreFund); - bytes32 hash = getHash(userOp); - bytes calldata paymasterAndData = userOp.paymasterAndData; - uint256 sigLength = paymasterAndData.length - 20; + (uint48 validUntil, uint48 validAfter, bytes calldata signature) = parsePaymasterAndData(userOp.paymasterAndData); //ECDSA library supports both 64 and 65-byte long signatures. // we only "require" it here so that the revert reason on invalid signature will be of "VerifyingPaymaster", and not "ECDSA" - require(sigLength == 64 || sigLength == 65, "VerifyingPaymaster: invalid signature length in paymasterAndData"); + require(signature.length == 64 || signature.length == 65, "VerifyingPaymaster: invalid signature length in paymasterAndData"); + bytes32 hash = ECDSA.toEthSignedMessageHash(getHash(userOp, validUntil, validAfter)); + senderNonce[userOp.getSender()]++; - //ignore signature mismatch of from==ZERO_ADDRESS (for eth_callUserOp validation purposes) - // solhint-disable-next-line avoid-tx-origin - require(verifyingSigner == hash.toEthSignedMessageHash().recover(paymasterAndData[20 :]) || tx.origin == address(0), "VerifyingPaymaster: wrong signature"); + //don't revert on signature failure: return SIG_VALIDATION_FAILED + if (verifyingSigner != ECDSA.recover(hash, signature)) { + return ("",_packValidationData(true,validUntil,validAfter)); + } //no need for other on-chain validation: entire UserOp should have been checked // by the external service prior to signing it. - return ("", 0); + return ("",_packValidationData(false,validUntil,validAfter)); } + function parsePaymasterAndData(bytes calldata paymasterAndData) public pure returns(uint48 validUntil, uint48 validAfter, bytes calldata signature) { + (validUntil, validAfter) = abi.decode(paymasterAndData[VALID_TIMESTAMP_OFFSET:SIGNATURE_OFFSET],(uint48, uint48)); + signature = paymasterAndData[SIGNATURE_OFFSET:]; + } } diff --git a/packages/boba/account-abstraction/contracts/samples/bls/BLSAccount.sol b/packages/boba/account-abstraction/contracts/samples/bls/BLSAccount.sol new file mode 100644 index 0000000000..efc64ce152 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/samples/bls/BLSAccount.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "../SimpleAccount.sol"; +import "./IBLSAccount.sol"; + +/** + * Minimal BLS-based account that uses an aggregated signature. + * The account must maintain its own BLS public key, and expose its trusted signature aggregator. + * Note that unlike the "standard" SimpleAccount, this account can't be called directly + * (normal SimpleAccount uses its "signer" address as both the ecrecover signer, and as a legitimate + * Ethereum sender address. Obviously, a BLS public key is not a valid Ethereum sender address.) + */ +contract BLSAccount is SimpleAccount, IBLSAccount { + address public immutable aggregator; + uint256[4] private publicKey; + + // The constructor is used only for the "implementation" and only sets immutable values. + // Mutable value slots for proxy accounts are set by the 'initialize' function. + constructor(IEntryPoint anEntryPoint, address anAggregator) SimpleAccount(anEntryPoint) { + aggregator = anAggregator; + } + + /** + * The initializer for the BLSAccount instance. + * @param aPublicKey public key from a BLS keypair that will have a full ownership and control of this account. + */ + function initialize(uint256[4] memory aPublicKey) public virtual initializer { + super._initialize(address(0)); + _setBlsPublicKey(aPublicKey); + } + + function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash) + internal override view returns (uint256 validationData) { + + (userOp, userOpHash); + if (userOp.initCode.length != 0) { + // BLSSignatureAggregator.getUserOpPublicKey() assumes that during account creation, the public key is + // the suffix of the initCode. + // The account MUST validate it + bytes32 pubKeyHash = keccak256(abi.encode(getBlsPublicKey())); + require(keccak256(userOp.initCode[userOp.initCode.length - 128 :]) == pubKeyHash, "wrong pubkey"); + } + return _packValidationData(ValidationData(aggregator, 0,0)); + } + + /** + * Allows the owner to set or change the BLS key. + * @param newPublicKey public key from a BLS keypair that will have a full ownership and control of this account. + */ + function setBlsPublicKey(uint256[4] memory newPublicKey) public onlyOwner { + _setBlsPublicKey(newPublicKey); + } + + function _setBlsPublicKey(uint256[4] memory newPublicKey) internal { + emit PublicKeyChanged(publicKey, newPublicKey); + publicKey = newPublicKey; + } + + /// @inheritdoc IBLSAccount + function getBlsPublicKey() public override view returns (uint256[4] memory) { + return publicKey; + } +} diff --git a/packages/boba/account-abstraction/contracts/samples/bls/BLSAccountFactory.sol b/packages/boba/account-abstraction/contracts/samples/bls/BLSAccountFactory.sol new file mode 100644 index 0000000000..4096a3839b --- /dev/null +++ b/packages/boba/account-abstraction/contracts/samples/bls/BLSAccountFactory.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "@openzeppelin/contracts/utils/Create2.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import "../../interfaces/IEntryPoint.sol"; +import "./BLSAccount.sol"; + +/* solhint-disable no-inline-assembly */ + +/** + * Based on SimpleAccountFactory. + * Cannot be a subclass since both constructor and createAccount depend on the + * constructor and initializer of the actual account contract. + */ +contract BLSAccountFactory { + BLSAccount public immutable accountImplementation; + + constructor(IEntryPoint entryPoint, address aggregator){ + accountImplementation = new BLSAccount(entryPoint, aggregator); + } + + /** + * create an account, and return its address. + * returns the address even if the account is already deployed. + * Note that during UserOperation execution, this method is called only if the account is not deployed. + * This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation + * Also note that our BLSSignatureAggregator requires that the public key is the last parameter + */ + function createAccount(uint256 salt, uint256[4] calldata aPublicKey) public returns (BLSAccount) { + + // the BLSSignatureAggregator depends on the public-key being the last 4 uint256 of msg.data. + uint slot; + assembly {slot := aPublicKey} + require(slot == msg.data.length - 128, "wrong pubkey offset"); + + address addr = getAddress(salt, aPublicKey); + uint codeSize = addr.code.length; + if (codeSize > 0) { + return BLSAccount(payable(addr)); + } + return BLSAccount(payable(new ERC1967Proxy{salt : bytes32(salt)}( + address(accountImplementation), + abi.encodeCall(BLSAccount.initialize, aPublicKey) + ))); + } + + /** + * calculate the counterfactual address of this account as it would be returned by createAccount() + */ + function getAddress(uint256 salt, uint256[4] memory aPublicKey) public view returns (address) { + return Create2.computeAddress(bytes32(salt), keccak256(abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode( + address(accountImplementation), + abi.encodeCall(BLSAccount.initialize, (aPublicKey)) + ) + ))); + } +} diff --git a/packages/boba/account-abstraction/contracts/bls/BLSHelper.sol b/packages/boba/account-abstraction/contracts/samples/bls/BLSHelper.sol similarity index 97% rename from packages/boba/account-abstraction/contracts/bls/BLSHelper.sol rename to packages/boba/account-abstraction/contracts/samples/bls/BLSHelper.sol index 31920a4ab0..fdb60e0ffa 100644 --- a/packages/boba/account-abstraction/contracts/bls/BLSHelper.sol +++ b/packages/boba/account-abstraction/contracts/samples/bls/BLSHelper.sol @@ -16,7 +16,7 @@ library BLSHelper { * @param _pp the modulus of the curve * @return ret the sum of all points */ - function sum(XY[] memory points, uint _pp) internal pure returns (XY memory ret){ + function sum(XY[] memory points, uint256 _pp) internal pure returns (XY memory ret){ uint x = points[0].x; uint y = points[0].y; uint z = 1; @@ -29,7 +29,7 @@ library BLSHelper { ret.y = y; } - /// @dev Adds two points (x1, y1, z1) and (x2 y2, z2). + /// @dev Adds two points (x1, y1, z1) and (x2, y2, z2). /// @param _x1 coordinate x of P1 /// @param _y1 coordinate y of P1 /// @param _z1 coordinate z of P1 @@ -134,7 +134,7 @@ library BLSHelper { return q; } - /// @dev Doubles a points (x, y, z). + /// @dev Doubles a point (x, y, z). /// @param _x coordinate x of P1 /// @param _y coordinate y of P1 /// @param _z coordinate z of P1 diff --git a/packages/boba/account-abstraction/contracts/bls/BLSSignatureAggregator.sol b/packages/boba/account-abstraction/contracts/samples/bls/BLSSignatureAggregator.sol similarity index 74% rename from packages/boba/account-abstraction/contracts/bls/BLSSignatureAggregator.sol rename to packages/boba/account-abstraction/contracts/samples/bls/BLSSignatureAggregator.sol index 0ffe0373b2..60ea291b51 100644 --- a/packages/boba/account-abstraction/contracts/bls/BLSSignatureAggregator.sol +++ b/packages/boba/account-abstraction/contracts/samples/bls/BLSSignatureAggregator.sol @@ -2,12 +2,11 @@ pragma solidity >=0.8.4 <0.9.0; pragma abicoder v2; -import "../interfaces/IAggregator.sol"; -import "../interfaces/IEntryPoint.sol"; +import "../../interfaces/IAggregator.sol"; +import "../../interfaces/IEntryPoint.sol"; import {BLSOpen} from "./lib/BLSOpen.sol"; import "./IBLSAccount.sol"; import "./BLSHelper.sol"; -import "hardhat/console.sol"; /** * A BLS-based signature aggregator, to validate aggregated signature of multiple UserOps if BLSAccount @@ -17,12 +16,20 @@ contract BLSSignatureAggregator is IAggregator { bytes32 public constant BLS_DOMAIN = keccak256("eip4337.bls.domain"); + //copied from BLS.sol + uint256 public constant N = 21888242871839275222246405745257275088696311157297823662689037894645226208583; + + /** + * @return publicKey - the public key from a BLS keypair the Aggregator will use to verify this UserOp; + * normally public key will be queried from the deployed BLSAccount itself; + * the public key will be read from the 'initCode' if the account is not deployed yet; + */ function getUserOpPublicKey(UserOperation memory userOp) public view returns (uint256[4] memory publicKey) { bytes memory initCode = userOp.initCode; if (initCode.length > 0) { publicKey = getTrailingPublicKey(initCode); } else { - return IBLSAccount(userOp.sender).getBlsPublicKey(); + return IBLSAccount(userOp.sender).getBlsPublicKey{gas : 50000}(); } } @@ -31,7 +38,7 @@ contract BLSSignatureAggregator is IAggregator { */ function getTrailingPublicKey(bytes memory data) public pure returns (uint256[4] memory publicKey) { uint len = data.length; - require(len > 32 * 4, "data to short for sig"); + require(len > 32 * 4, "data too short for sig"); /* solhint-disable-next-line no-inline-assembly */ assembly { @@ -44,6 +51,7 @@ contract BLSSignatureAggregator is IAggregator { } } + /// @inheritdoc IAggregator function validateSignatures(UserOperation[] calldata userOps, bytes calldata signature) external view override { require(signature.length == 64, "BLS: invalid signature"); @@ -55,11 +63,9 @@ contract BLSSignatureAggregator is IAggregator { for (uint256 i = 0; i < userOpsLen; i++) { UserOperation memory userOp = userOps[i]; - IBLSAccount blsAccount = IBLSAccount(userOp.sender); - - blsPublicKeys[i] = blsAccount.getBlsPublicKey{gas : 30000}(); + blsPublicKeys[i] = getUserOpPublicKey(userOp); - messages[i] = _userOpToMessage(userOp, keccak256(abi.encode(blsPublicKeys[i]))); + messages[i] = _userOpToMessage(userOp, _getPublicKeyHash(blsPublicKeys[i])); } require(BLSOpen.verifyMultiple(blsSignature, blsPublicKeys, messages), "BLS: validateSignatures failed"); } @@ -86,11 +92,11 @@ contract BLSSignatureAggregator is IAggregator { /** * return the BLS "message" for the given UserOp. - * the account checks the signature over this value using its public-key + * the account checks the signature over this value using its public key */ function userOpToMessage(UserOperation memory userOp) public view returns (uint256[2] memory) { - bytes32 hashPublicKey = _getUserOpPubkeyHash(userOp); - return _userOpToMessage(userOp, hashPublicKey); + bytes32 publicKeyHash = _getPublicKeyHash(getUserOpPublicKey(userOp)); + return _userOpToMessage(userOp, publicKeyHash); } function _userOpToMessage(UserOperation memory userOp, bytes32 publicKeyHash) internal view returns (uint256[2] memory) { @@ -98,23 +104,22 @@ contract BLSSignatureAggregator is IAggregator { return BLSOpen.hashToPoint(BLS_DOMAIN, abi.encodePacked(userOpHash)); } - //return the public-key hash of a userOp. - function _getUserOpPubkeyHash(UserOperation memory userOp) internal view returns (bytes32 hashPublicKey) { - return keccak256(abi.encode(getUserOpPublicKey(userOp))); - } - + // helper for test function getUserOpHash(UserOperation memory userOp) public view returns (bytes32) { - bytes32 hashPublicKey = _getUserOpPubkeyHash(userOp); - return _getUserOpHash(userOp, hashPublicKey); + bytes32 publicKeyHash = _getPublicKeyHash(getUserOpPublicKey(userOp)); + return _getUserOpHash(userOp, publicKeyHash); } - function _getUserOpHash(UserOperation memory userOp, bytes32 hashPublicKey) internal view returns (bytes32) { - return keccak256(abi.encode(internalUserOpHash(userOp), hashPublicKey, address(this), block.chainid)); + function _getUserOpHash(UserOperation memory userOp, bytes32 publicKeyHash) internal view returns (bytes32) { + return keccak256(abi.encode(internalUserOpHash(userOp), publicKeyHash, address(this), block.chainid)); } + function _getPublicKeyHash(uint256[4] memory publicKey) internal pure returns(bytes32) { + return keccak256(abi.encode(publicKey)); + } /** * validate signature of a single userOp - * This method is called after EntryPoint.simulateUserOperation() returns an aggregator. + * This method is called after EntryPoint.simulateValidation() returns an aggregator. * First it validates the signature over the userOp. then it return data to be used when creating the handleOps: * @param userOp the userOperation received from the user. * @return sigForUserOp the value to put into the signature field of the userOp when calling handleOps. @@ -124,26 +129,24 @@ contract BLSSignatureAggregator is IAggregator { external view returns (bytes memory sigForUserOp) { uint256[2] memory signature = abi.decode(userOp.signature, (uint256[2])); uint256[4] memory pubkey = getUserOpPublicKey(userOp); - uint256[2] memory message = userOpToMessage(userOp); + uint256[2] memory message = _userOpToMessage(userOp, _getPublicKeyHash(pubkey)); require(BLSOpen.verifySingle(signature, pubkey, message), "BLS: wrong sig"); return ""; } - //copied from BLS.sol - uint256 public constant N = 21888242871839275222246405745257275088696311157297823662689037894645226208583; /** * aggregate multiple signatures into a single value. * This method is called off-chain to calculate the signature to pass with handleOps() * bundler MAY use optimized custom code perform this aggregation * @param userOps array of UserOperations to collect the signatures from. - * @return aggregatesSignature the aggregated signature + * @return aggregatedSignature the aggregated signature */ - function aggregateSignatures(UserOperation[] calldata userOps) external pure returns (bytes memory aggregatesSignature) { + function aggregateSignatures(UserOperation[] calldata userOps) external pure returns (bytes memory aggregatedSignature) { BLSHelper.XY[] memory points = new BLSHelper.XY[](userOps.length); for (uint i = 0; i < points.length; i++) { - (uint x, uint y) = abi.decode(userOps[i].signature, (uint, uint)); + (uint256 x, uint256 y) = abi.decode(userOps[i].signature, (uint256, uint256)); points[i] = BLSHelper.XY(x, y); } BLSHelper.XY memory sum = BLSHelper.sum(points, N); @@ -152,7 +155,7 @@ contract BLSSignatureAggregator is IAggregator { /** * allow staking for this aggregator - * there is no limit on stake or delay, but it is not a problem, since it is a permissionless + * there is no limit on stake or delay, but it is not a problem, since it is a permissionless * signature aggregator, which doesn't support unstaking. */ function addStake(IEntryPoint entryPoint, uint32 delay) external payable { diff --git a/packages/boba/account-abstraction/contracts/samples/bls/IBLSAccount.sol b/packages/boba/account-abstraction/contracts/samples/bls/IBLSAccount.sol new file mode 100644 index 0000000000..c34da4fbf8 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/samples/bls/IBLSAccount.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity >=0.7.6; + +import "../../interfaces/IAccount.sol"; + +/** + * a BLS account should expose its own public key. + */ +interface IBLSAccount is IAccount { + event PublicKeyChanged(uint256[4] oldPublicKey, uint256[4] newPublicKey); + + /** + * @return public key from a BLS keypair that is used to verify the BLS signature, both separately and aggregated. + */ + function getBlsPublicKey() external view returns (uint256[4] memory); +} diff --git a/packages/boba/account-abstraction/contracts/bls/lib/BLSOpen.sol b/packages/boba/account-abstraction/contracts/samples/bls/lib/BLSOpen.sol similarity index 100% rename from packages/boba/account-abstraction/contracts/bls/lib/BLSOpen.sol rename to packages/boba/account-abstraction/contracts/samples/bls/lib/BLSOpen.sol diff --git a/packages/boba/account-abstraction/contracts/bls/lib/hubble-contracts/contracts/libs/BLS.sol b/packages/boba/account-abstraction/contracts/samples/bls/lib/hubble-contracts/contracts/libs/BLS.sol similarity index 100% rename from packages/boba/account-abstraction/contracts/bls/lib/hubble-contracts/contracts/libs/BLS.sol rename to packages/boba/account-abstraction/contracts/samples/bls/lib/hubble-contracts/contracts/libs/BLS.sol diff --git a/packages/boba/account-abstraction/contracts/bls/lib/hubble-contracts/contracts/libs/BNPairingPrecompileCostEstimator.sol b/packages/boba/account-abstraction/contracts/samples/bls/lib/hubble-contracts/contracts/libs/BNPairingPrecompileCostEstimator.sol similarity index 100% rename from packages/boba/account-abstraction/contracts/bls/lib/hubble-contracts/contracts/libs/BNPairingPrecompileCostEstimator.sol rename to packages/boba/account-abstraction/contracts/samples/bls/lib/hubble-contracts/contracts/libs/BNPairingPrecompileCostEstimator.sol diff --git a/packages/boba/account-abstraction/contracts/bls/lib/hubble-contracts/contracts/libs/ModExp.sol b/packages/boba/account-abstraction/contracts/samples/bls/lib/hubble-contracts/contracts/libs/ModExp.sol similarity index 100% rename from packages/boba/account-abstraction/contracts/bls/lib/hubble-contracts/contracts/libs/ModExp.sol rename to packages/boba/account-abstraction/contracts/samples/bls/lib/hubble-contracts/contracts/libs/ModExp.sol diff --git a/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Fallback.sol b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Fallback.sol new file mode 100644 index 0000000000..3d1c8bd434 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Fallback.sol @@ -0,0 +1,81 @@ +//SPDX-License-Identifier: GPL +pragma solidity ^0.8.7; + +/* solhint-disable no-inline-assembly */ + +import "@gnosis.pm/safe-contracts/contracts/handler/DefaultCallbackHandler.sol"; +import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol"; +import "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "../../interfaces/IAccount.sol"; +import "./EIP4337Manager.sol"; + +using ECDSA for bytes32; + +/** + * The GnosisSafe enables adding custom functions implementation to the Safe by setting a 'fallbackHandler'. + * This 'fallbackHandler' adds an implementation of 'validateUserOp' to the GnosisSafe. + * Note that the implementation of the 'validateUserOp' method is located in the EIP4337Manager. + * Upon receiving the 'validateUserOp', a Safe with EIP4337Fallback enabled makes a 'delegatecall' to EIP4337Manager. + */ +contract EIP4337Fallback is DefaultCallbackHandler, IAccount, IERC1271 { + bytes4 internal constant ERC1271_MAGIC_VALUE = 0x1626ba7e; + + address immutable public eip4337manager; + constructor(address _eip4337manager) { + eip4337manager = _eip4337manager; + } + + /** + * delegate the contract call to the EIP4337Manager + */ + function delegateToManager() internal returns (bytes memory) { + // delegate entire msg.data (including the appended "msg.sender") to the EIP4337Manager + // will work only for GnosisSafe contracts + GnosisSafe safe = GnosisSafe(payable(msg.sender)); + (bool success, bytes memory ret) = safe.execTransactionFromModuleReturnData(eip4337manager, 0, msg.data, Enum.Operation.DelegateCall); + if (!success) { + assembly { + revert(add(ret, 32), mload(ret)) + } + } + return ret; + } + + /** + * called from the Safe. delegate actual work to EIP4337Manager + */ + function validateUserOp(UserOperation calldata, bytes32, uint256) override external returns (uint256 deadline){ + bytes memory ret = delegateToManager(); + return abi.decode(ret, (uint256)); + } + + /** + * called from the Safe. delegate actual work to EIP4337Manager + */ + function executeAndRevert( + address, + uint256, + bytes memory, + Enum.Operation + ) external { + delegateToManager(); + } + + function isValidSignature( + bytes32 _hash, + bytes memory _signature + ) external override view returns (bytes4) { + bytes32 hash = _hash.toEthSignedMessageHash(); + address recovered = hash.recover(_signature); + + GnosisSafe safe = GnosisSafe(payable(address(msg.sender))); + + // Validate signatures + if (safe.isOwner(recovered)) { + return ERC1271_MAGIC_VALUE; + } else { + return 0xffffffff; + } + } +} diff --git a/packages/boba/account-abstraction/contracts/gnosis/EIP4337Manager.sol b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Manager.sol similarity index 55% rename from packages/boba/account-abstraction/contracts/gnosis/EIP4337Manager.sol rename to packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Manager.sol index 282d60c370..bc3468638e 100644 --- a/packages/boba/account-abstraction/contracts/gnosis/EIP4337Manager.sol +++ b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Manager.sol @@ -7,8 +7,12 @@ pragma solidity ^0.8.7; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol"; +import "@gnosis.pm/safe-contracts/contracts/base/Executor.sol"; +import "@gnosis.pm/safe-contracts/contracts/examples/libraries/GnosisSafeStorage.sol"; import "./EIP4337Fallback.sol"; -import "../core/EntryPoint.sol"; +import "../../interfaces/IAccount.sol"; +import "../../interfaces/IEntryPoint.sol"; +import "../../utils/Exec.sol"; using ECDSA for bytes32; @@ -17,13 +21,19 @@ import "../core/EntryPoint.sol"; * Called (through the fallback module) using "delegate" from the GnosisSafe as an "IAccount", * so must implement validateUserOp * holds an immutable reference to the EntryPoint - * Inherits GnosisSafeStorage so that it can reference the memory storage + * Inherits GnosisSafe so that it can reference the memory storage */ -contract EIP4337Manager is GnosisSafe, IAccount { +contract EIP4337Manager is IAccount, GnosisSafeStorage, Executor { address public immutable eip4337Fallback; address public immutable entryPoint; + // return value in case of signature failure, with no time-range. + // equivalent to _packValidationData(true,0,0); + uint256 constant internal SIG_VALIDATION_FAILED = 1; + + address internal constant SENTINEL_MODULES = address(0x1); + constructor(address anEntryPoint) { entryPoint = anEntryPoint; eip4337Fallback = address(new EIP4337Fallback(address(this))); @@ -32,58 +42,84 @@ contract EIP4337Manager is GnosisSafe, IAccount { /** * delegate-called (using execFromModule) through the fallback, so "real" msg.sender is attached as last 20 bytes */ - function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, address /*aggregator*/, uint256 missingAccountFunds) - external override returns (uint256 deadline) { - address _msgSender = address(bytes20(msg.data[msg.data.length - 20 :])); - require(_msgSender == entryPoint, "account: not from entrypoint"); + function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds) + external override returns (uint256 validationData) { + address msgSender = address(bytes20(msg.data[msg.data.length - 20 :])); + require(msgSender == entryPoint, "account: not from entrypoint"); GnosisSafe pThis = GnosisSafe(payable(address(this))); bytes32 hash = userOpHash.toEthSignedMessageHash(); address recovered = hash.recover(userOp.signature); require(threshold == 1, "account: only threshold 1"); - require(pThis.isOwner(recovered), "account: wrong signature"); + if (!pThis.isOwner(recovered)) { + validationData = SIG_VALIDATION_FAILED; + } if (userOp.initCode.length == 0) { - require(nonce++ == userOp.nonce, "account: invalid nonce"); + require(uint256(nonce) == userOp.nonce, "account: invalid nonce"); + nonce = bytes32(uint256(nonce) + 1); } if (missingAccountFunds > 0) { - //TODO: MAY pay more than the minimum, to deposit for future transactions - (bool success,) = payable(_msgSender).call{value : missingAccountFunds}(""); + //Note: MAY pay more than the minimum, to deposit for future transactions + (bool success,) = payable(msgSender).call{value : missingAccountFunds}(""); (success); //ignore failure (its EntryPoint's job to verify, not account.) } - return 0; } + /** + * Execute a call but also revert if the execution fails. + * The default behavior of the Safe is to not revert if the call fails, + * which is challenging for integrating with ERC4337 because then the + * EntryPoint wouldn't know to emit the UserOperationRevertReason event, + * which the frontend/client uses to capture the reason for the failure. + */ + function executeAndRevert( + address to, + uint256 value, + bytes memory data, + Enum.Operation operation + ) external { + address msgSender = address(bytes20(msg.data[msg.data.length - 20 :])); + require(msgSender == entryPoint, "account: not from entrypoint"); + require(msg.sender == eip4337Fallback, "account: not from EIP4337Fallback"); + + bool success = execute( + to, + value, + data, + operation, + type(uint256).max + ); + + bytes memory returnData = Exec.getReturnData(type(uint256).max); + // Revert with the actual reason string + // Adopted from: https://github.com/Uniswap/v3-periphery/blob/464a8a49611272f7349c970e0fadb7ec1d3c1086/contracts/base/Multicall.sol#L16-L23 + if (!success) { + if (returnData.length < 68) revert(); + assembly { + returnData := add(returnData, 0x04) + } + revert(abi.decode(returnData, (string))); + } + } + + /** * set up a safe as EIP-4337 enabled. - * called from the GnosisSafeProxy4337 during construction time + * called from the GnosisSafeAccountFactory during construction time * - enable 3 modules (this module, fallback and the entrypoint) * - this method is called with delegateCall, so the module (usually itself) is passed as parameter, and "this" is the safe itself */ - function setupEIP4337( - address singleton, - EIP4337Manager manager, - address owner + function setup4337Modules( + EIP4337Manager manager //the manager (this contract) ) external { - address eip4337fallback = manager.eip4337Fallback(); - - address[] memory owners = new address[](1); - owners[0] = owner; - uint threshold = 1; - - execute(singleton, 0, abi.encodeCall(GnosisSafe.setup, ( - owners, threshold, - address(0), "", //no delegate call - eip4337fallback, - address(0), 0, payable(0) //no payment receiver - )), - Enum.Operation.DelegateCall, gasleft() - ); - - _enableModule(manager.entryPoint()); - _enableModule(eip4337fallback); + GnosisSafe safe = GnosisSafe(payable(address(this))); + require(!safe.isModuleEnabled(manager.entryPoint()), "setup4337Modules: entrypoint already enabled"); + require(!safe.isModuleEnabled(manager.eip4337Fallback()), "setup4337Modules: eip4337Fallback already enabled"); + safe.enableModule(manager.entryPoint()); + safe.enableModule(manager.eip4337Fallback()); } /** @@ -94,7 +130,6 @@ contract EIP4337Manager is GnosisSafe, IAccount { * @param newManager the new EIP4337Manager, usually with a new EntryPoint */ function replaceEIP4337Manager(address prevModule, EIP4337Manager oldManager, EIP4337Manager newManager) public { - GnosisSafe pThis = GnosisSafe(payable(address(this))); address oldFallback = oldManager.eip4337Fallback(); require(pThis.isModuleEnabled(oldFallback), "replaceEIP4337Manager: oldManager is not active"); @@ -105,7 +140,6 @@ contract EIP4337Manager is GnosisSafe, IAccount { pThis.enableModule(newManager.entryPoint()); pThis.enableModule(eip4337fallback); - pThis.setFallbackHandler(eip4337fallback); validateEip4337(pThis, newManager); @@ -118,67 +152,39 @@ contract EIP4337Manager is GnosisSafe, IAccount { */ function validateEip4337(GnosisSafe safe, EIP4337Manager manager) public { - // this prevent mistaken replaceEIP4337Manager to disable the module completely. + // this prevents mistaken replaceEIP4337Manager to disable the module completely. // minimal signature that pass "recover" bytes memory sig = new bytes(65); sig[64] = bytes1(uint8(27)); sig[2] = bytes1(uint8(1)); sig[35] = bytes1(uint8(1)); - UserOperation memory userOp = UserOperation(address(safe), 0, "", "", 0, 1000000, 0, 0, 0, "", sig); + UserOperation memory userOp = UserOperation(address(safe), uint256(nonce), "", "", 0, 1000000, 0, 0, 0, "", sig); UserOperation[] memory userOps = new UserOperation[](1); userOps[0] = userOp; IEntryPoint _entryPoint = IEntryPoint(payable(manager.entryPoint())); try _entryPoint.handleOps(userOps, payable(msg.sender)) { revert("validateEip4337: handleOps must fail"); } catch (bytes memory error) { - if (keccak256(error) != keccak256(abi.encodeWithSignature("FailedOp(uint256,address,string)", 0, address(0), "account: wrong signature"))) { + if (keccak256(error) != keccak256(abi.encodeWithSignature("FailedOp(uint256,string)", 0, "AA24 signature error"))) { revert(string(error)); } } } - - function delegateCall(address to, bytes memory data) internal { - bool success; - assembly { - success := delegatecall(sub(0, 1), to, add(data, 0x20), mload(data), 0, 0) - } - require(success, "delegate failed"); - } - - /// copied from GnosisSafe ModuleManager, FallbackManager - /// enableModule is "external authorizeOnly", can't be used during construction using a "delegatecall" - - /// @dev Allows to add a module to the whitelist. - /// this is a variant of enableModule that is used only during construction - /// @notice Enables the module `module` for the Safe. - /// @param module Module to be whitelisted. - function _enableModule(address module) private { - - // Module address cannot be null or sentinel. - require(module != address(0) && module != SENTINEL_MODULES, "GS101"); - // Module cannot be added twice. - require(modules[module] == address(0), "GS102"); - modules[module] = modules[SENTINEL_MODULES]; - modules[SENTINEL_MODULES] = module; - emit EnabledModule(module); - } - /** * enumerate modules, and find the currently active EIP4337 manager (and previous module) * @return prev prev module, needed by replaceEIP4337Manager * @return manager the current active EIP4337Manager */ function getCurrentEIP4337Manager(GnosisSafe safe) public view returns (address prev, address manager) { - prev = address(SENTINEL_MODULES); (address[] memory modules,) = safe.getModulesPaginated(SENTINEL_MODULES, 100); for (uint i = 0; i < modules.length; i++) { address module = modules[i]; - (bool success,bytes memory ret) = module.staticcall(abi.encodeWithSignature("eip4337manager()")); - if (success) { - manager = abi.decode(ret, (address)); - return (prev, manager); + try EIP4337Fallback(module).eip4337manager() returns (address _manager) { + return (prev, _manager); } + // solhint-disable-next-line no-empty-blocks + catch {} prev = module; } return (address(0), address(0)); diff --git a/packages/boba/account-abstraction/contracts/samples/gnosis/GnosisAccountFactory.sol b/packages/boba/account-abstraction/contracts/samples/gnosis/GnosisAccountFactory.sol new file mode 100644 index 0000000000..459846a7f8 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/samples/gnosis/GnosisAccountFactory.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "@openzeppelin/contracts/utils/Create2.sol"; +import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol"; +import "./EIP4337Manager.sol"; + +/** + * A wrapper factory contract to deploy GnosisSafe as an ERC-4337 account contract. + */ +contract GnosisSafeAccountFactory { + + GnosisSafeProxyFactory public immutable proxyFactory; + address public immutable safeSingleton; + EIP4337Manager public immutable eip4337Manager; + + constructor(GnosisSafeProxyFactory _proxyFactory, address _safeSingleton, EIP4337Manager _eip4337Manager) { + proxyFactory = _proxyFactory; + safeSingleton = _safeSingleton; + eip4337Manager = _eip4337Manager; + } + + function createAccount(address owner,uint256 salt) public returns (address) { + address addr = getAddress(owner, salt); + uint codeSize = addr.code.length; + if (codeSize > 0) { + return addr; + } + return address(proxyFactory.createProxyWithNonce( + safeSingleton, getInitializer(owner), salt)); + } + + function getInitializer(address owner) internal view returns (bytes memory) { + address[] memory owners = new address[](1); + owners[0] = owner; + uint threshold = 1; + address eip4337fallback = eip4337Manager.eip4337Fallback(); + + bytes memory setup4337Modules = abi.encodeCall( + EIP4337Manager.setup4337Modules, (eip4337Manager)); + + return abi.encodeCall(GnosisSafe.setup, ( + owners, threshold, + address (eip4337Manager), setup4337Modules, + eip4337fallback, + address(0), 0, payable(0) //no payment receiver + )); + } + + /** + * calculate the counterfactual address of this account as it would be returned by createAccount() + * (uses the same "create2 signature" used by GnosisSafeProxyFactory.createProxyWithNonce) + */ + function getAddress(address owner,uint256 salt) public view returns (address) { + bytes memory initializer = getInitializer(owner); + //copied from deployProxyWithNonce + bytes32 salt2 = keccak256(abi.encodePacked(keccak256(initializer), salt)); + bytes memory deploymentData = abi.encodePacked(proxyFactory.proxyCreationCode(), uint256(uint160(safeSingleton))); + return Create2.computeAddress(bytes32(salt2), keccak256(deploymentData), address (proxyFactory)); + } +} diff --git a/packages/boba/account-abstraction/contracts/test/BrokenBlsAccount.sol b/packages/boba/account-abstraction/contracts/test/BrokenBlsAccount.sol new file mode 100644 index 0000000000..2e3cf2bc2f --- /dev/null +++ b/packages/boba/account-abstraction/contracts/test/BrokenBlsAccount.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "@openzeppelin/contracts/utils/Create2.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import "../samples/SimpleAccount.sol"; +import "../samples/bls/IBLSAccount.sol"; + +/** + * for testing: a BLS account that fails to return its public-key (completely ignores its publickey) + * this is a copy of the normal bls account, but it returns a public-key unrelated to the one it is constructed with. + */ +contract BrokenBLSAccount is SimpleAccount, IBLSAccount { + address public immutable aggregator; + + // The constructor is used only for the "implementation" and only sets immutable values. + // Mutable values slots for proxy accounts are set by the 'initialize' function. + constructor(IEntryPoint anEntryPoint, address anAggregator) SimpleAccount(anEntryPoint) { + aggregator = anAggregator; + } + + function initialize(uint256[4] memory aPublicKey) public virtual initializer { + (aPublicKey); + super._initialize(address(0)); + } + + function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash) + internal override view returns (uint256 validationData) { + + (userOp, userOpHash); + return _packValidationData(ValidationData(aggregator, 0,0)); + } + + function getBlsPublicKey() external override pure returns (uint256[4] memory) { + uint256[4] memory pubkey; + return pubkey; + } +} + + +/** + * Based n SimpleAccountFactory + * can't be a subclass, since both constructor and createAccount depend on the + * actual wallet contract constructor and initializer + */ +contract BrokenBLSAccountFactory { + BrokenBLSAccount public immutable accountImplementation; + + constructor(IEntryPoint entryPoint, address aggregator){ + accountImplementation = new BrokenBLSAccount(entryPoint, aggregator); + } + + /** + * create an account, and return its address. + * returns the address even if the account is already deployed. + * Note that during UserOperation execution, this method is called only if the account is not deployed. + * This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation + * Also note that out BLSSignatureAggregator requires that the public-key is the last parameter + */ + function createAccount(uint salt, uint256[4] memory aPublicKey) public returns (BrokenBLSAccount) { + + address addr = getAddress(salt, aPublicKey); + uint codeSize = addr.code.length; + if (codeSize > 0) { + return BrokenBLSAccount(payable(addr)); + } + return BrokenBLSAccount(payable(new ERC1967Proxy{salt : bytes32(salt)}( + address(accountImplementation), + abi.encodeCall(BrokenBLSAccount.initialize, aPublicKey) + ))); + } + + /** + * calculate the counterfactual address of this account as it would be returned by createAccount() + */ + function getAddress(uint salt, uint256[4] memory aPublicKey) public view returns (address) { + return Create2.computeAddress(bytes32(salt), keccak256(abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode( + address(accountImplementation), + abi.encodeCall(BrokenBLSAccount.initialize, (aPublicKey)) + ) + ))); + } +} diff --git a/packages/boba/account-abstraction/contracts/test/MaliciousAccount.sol b/packages/boba/account-abstraction/contracts/test/MaliciousAccount.sol new file mode 100644 index 0000000000..d42e918ec2 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/test/MaliciousAccount.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; +import "../interfaces/IAccount.sol"; +import "../interfaces/IEntryPoint.sol"; +import "../core/EntryPoint.sol"; + +contract MaliciousAccount is IAccount { + IEntryPoint private ep; + constructor(IEntryPoint _ep) payable { + ep = _ep; + } + function validateUserOp(UserOperation calldata userOp, bytes32, uint256 missingAccountFunds) + external returns (uint256 validationData) { + ep.depositTo{value : missingAccountFunds}(address(this)); + // Now calculate basefee per EntryPoint.getUserOpGasPrice() and compare it to the basefe we pass off-chain as nonce + uint256 requiredGas = userOp.callGasLimit + userOp.verificationGasLimit + userOp.preVerificationGas; + uint256 gasPrice = missingAccountFunds / requiredGas; + uint256 basefee = gasPrice - userOp.maxPriorityFeePerGas; + require (basefee == userOp.nonce, "Revert after first validation"); + return 0; + } +} diff --git a/packages/boba/account-abstraction/contracts/test/TestAggregatedAccount.sol b/packages/boba/account-abstraction/contracts/test/TestAggregatedAccount.sol new file mode 100644 index 0000000000..23bf2954de --- /dev/null +++ b/packages/boba/account-abstraction/contracts/test/TestAggregatedAccount.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "../samples/SimpleAccount.sol"; + +/** + * test aggregated-signature account. + * works only with TestAggregatedSignature, which doesn't really check signature, but nonce sum + * a true aggregated account should expose data (e.g. its public key) to the aggregator. + */ +contract TestAggregatedAccount is SimpleAccount { + address public immutable aggregator; + + // The constructor is used only for the "implementation" and only sets immutable values. + // Mutable value slots for proxy accounts are set by the 'initialize' function. + constructor(IEntryPoint anEntryPoint, address anAggregator) SimpleAccount(anEntryPoint) { + aggregator = anAggregator; + } + + /// @inheritdoc SimpleAccount + function initialize(address) public virtual override initializer { + super._initialize(address(0)); + } + + function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash) + internal override view returns (uint256 validationData) { + (userOp, userOpHash); + return _packValidationData(ValidationData(aggregator, 0, 0)); + } +} diff --git a/packages/boba/account-abstraction/contracts/test/TestAggregatedAccountFactory.sol b/packages/boba/account-abstraction/contracts/test/TestAggregatedAccountFactory.sol new file mode 100644 index 0000000000..edaba58cc9 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/test/TestAggregatedAccountFactory.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "@openzeppelin/contracts/utils/Create2.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import "./TestAggregatedAccount.sol"; + +/** + * Based on SimpleAccountFactory. + * Cannot be a subclass since both constructor and createAccount depend on the + * constructor and initializer of the actual account contract. + */ +contract TestAggregatedAccountFactory { + TestAggregatedAccount public immutable accountImplementation; + + constructor(IEntryPoint anEntryPoint, address anAggregator){ + accountImplementation = new TestAggregatedAccount(anEntryPoint, anAggregator); + } + + /** + * create an account, and return its address. + * returns the address even if the account is already deployed. + * Note that during UserOperation execution, this method is called only if the account is not deployed. + * This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation + */ + function createAccount(address owner,uint256 salt) public returns (TestAggregatedAccount ret) { + address addr = getAddress(owner, salt); + uint codeSize = addr.code.length; + if (codeSize > 0) { + return TestAggregatedAccount(payable(addr)); + } + ret = TestAggregatedAccount(payable(new ERC1967Proxy{salt : bytes32(salt)}( + address(accountImplementation), + abi.encodeCall(TestAggregatedAccount.initialize, (owner)) + ))); + } + + /** + * calculate the counterfactual address of this account as it would be returned by createAccount() + */ + function getAddress(address owner,uint256 salt) public view returns (address) { + return Create2.computeAddress(bytes32(salt), keccak256(abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode( + address(accountImplementation), + abi.encodeCall(TestAggregatedAccount.initialize, (owner)) + ) + ))); + } +} diff --git a/packages/boba/account-abstraction/contracts/test/TestCounter.sol b/packages/boba/account-abstraction/contracts/test/TestCounter.sol index b0e724073c..0ac2c888a2 100644 --- a/packages/boba/account-abstraction/contracts/test/TestCounter.sol +++ b/packages/boba/account-abstraction/contracts/test/TestCounter.sol @@ -7,7 +7,10 @@ contract TestCounter { function count() public { counters[msg.sender] = counters[msg.sender] + 1; + } + function countFail() public pure { + revert("count failed"); } function justemit() public { diff --git a/packages/boba/account-abstraction/contracts/test/TestExpirePaymaster.sol b/packages/boba/account-abstraction/contracts/test/TestExpirePaymaster.sol index 18c7ad84a6..51036a1cf5 100644 --- a/packages/boba/account-abstraction/contracts/test/TestExpirePaymaster.sol +++ b/packages/boba/account-abstraction/contracts/test/TestExpirePaymaster.sol @@ -4,17 +4,19 @@ pragma solidity ^0.8.12; import "../core/BasePaymaster.sol"; /** - * test expiry mechanism: paymasterData is encoded "deadline" timestamp + * test expiry mechanism: paymasterData encodes the "validUntil" and validAfter" times */ contract TestExpirePaymaster is BasePaymaster { // solhint-disable no-empty-blocks constructor(IEntryPoint _entryPoint) BasePaymaster(_entryPoint) {} - function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint maxCost) external virtual override view - returns (bytes memory context, uint256 deadline) { + function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) + internal virtual override view + returns (bytes memory context, uint256 validationData) { (userOp, userOpHash, maxCost); - deadline = uint256(bytes32(userOp.paymasterAndData[20:])); + (uint48 validAfter, uint48 validUntil) = abi.decode(userOp.paymasterAndData[20 :], (uint48, uint48)); + validationData = _packValidationData(false, validUntil, validAfter); context = ""; } } diff --git a/packages/boba/account-abstraction/contracts/test/TestExpiryAccount.sol b/packages/boba/account-abstraction/contracts/test/TestExpiryAccount.sol index bf807765fb..294f4aaf57 100644 --- a/packages/boba/account-abstraction/contracts/test/TestExpiryAccount.sol +++ b/packages/boba/account-abstraction/contracts/test/TestExpiryAccount.sol @@ -5,30 +5,45 @@ import "../samples/SimpleAccount.sol"; /** * A test account, for testing expiry. - * add "temporary" owners, each with a deadline time for each. + * add "temporary" owners, each with a time range (since..till) times for each. * NOTE: this is not a full "session key" implementation: a real session key should probably limit * other things, like target contracts and methods to be called. + * also, the "since" value is not really useful, only for testing the entrypoint. */ contract TestExpiryAccount is SimpleAccount { using ECDSA for bytes32; - mapping(address => uint256) public ownerDeadlines; + mapping(address => uint48) public ownerAfter; + mapping(address => uint48) public ownerUntil; - constructor(IEntryPoint anEntryPoint, address anOwner) SimpleAccount(anEntryPoint, anOwner) { - addTemporaryOwner(anOwner, type(uint256).max); + // solhint-disable-next-line no-empty-blocks + constructor(IEntryPoint anEntryPoint) SimpleAccount(anEntryPoint) {} + + function initialize(address anOwner) public virtual override initializer { + super._initialize(anOwner); + addTemporaryOwner(anOwner, 0, type(uint48).max); } - function addTemporaryOwner(address owner, uint256 deadline) public onlyOwner { - ownerDeadlines[owner] = deadline; + // As this is a test contract, no need for proxy, so no need to disable init + // solhint-disable-next-line no-empty-blocks + function _disableInitializers() internal override {} + + function addTemporaryOwner(address owner, uint48 _after, uint48 _until) public onlyOwner { + require(_until > _after, "wrong until/after"); + ownerAfter[owner] = _after; + ownerUntil[owner] = _until; } /// implement template method of BaseAccount - function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash, address) - internal override view returns (uint256 deadline) { + function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash) + internal override view returns (uint256 validationData) { bytes32 hash = userOpHash.toEthSignedMessageHash(); address signer = hash.recover(userOp.signature); - deadline = ownerDeadlines[signer]; - require(deadline != 0, "account: wrong signature"); - //not testing deadline (since we can't). just return it. + uint48 _until = ownerUntil[signer]; + uint48 _after = ownerAfter[signer]; + + //we have "until" value for all valid owners. so zero means "invalid signature" + bool sigFailed = _until == 0; + return _packValidationData(sigFailed, _until, _after); } } diff --git a/packages/boba/account-abstraction/contracts/test/TestHelpers.sol b/packages/boba/account-abstraction/contracts/test/TestHelpers.sol new file mode 100644 index 0000000000..2ddb83c77b --- /dev/null +++ b/packages/boba/account-abstraction/contracts/test/TestHelpers.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "../core/Helpers.sol"; + +contract TestHelpers { + + function parseValidationData(uint validationData) public pure returns (ValidationData memory) { + return _parseValidationData(validationData); + } + + function intersectTimeRange(uint256 validationData, uint256 paymasterValidationData) public pure returns (ValidationData memory) { + return _intersectTimeRange(validationData, paymasterValidationData); + } + + function packValidationDataStruct(ValidationData memory data) public pure returns (uint256) { + return _packValidationData(data); + } + + function packValidationData(bool sigFailed, uint48 validUntil, uint48 validAfter) public pure returns (uint256) { + return _packValidationData(sigFailed, validUntil, validAfter); + } +} diff --git a/packages/boba/account-abstraction/contracts/test/TestPaymasterAcceptAll.sol b/packages/boba/account-abstraction/contracts/test/TestPaymasterAcceptAll.sol index a045b754bb..a556049bda 100644 --- a/packages/boba/account-abstraction/contracts/test/TestPaymasterAcceptAll.sol +++ b/packages/boba/account-abstraction/contracts/test/TestPaymasterAcceptAll.sol @@ -9,16 +9,18 @@ import "../core/BasePaymaster.sol"; contract TestPaymasterAcceptAll is BasePaymaster { constructor(IEntryPoint _entryPoint) BasePaymaster(_entryPoint) { - // to support "deterministic address" deployer + // to support "deterministic address" factory // solhint-disable avoid-tx-origin if (tx.origin != msg.sender) { - _transferOwnership(tx.origin); + transferOwnership(tx.origin); } + } - function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint maxCost) external virtual override view - returns (bytes memory context, uint256 deadline) { + function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) + internal virtual override view + returns (bytes memory context, uint256 validationData) { (userOp, userOpHash, maxCost); - return ("", 0); + return ("", maxCost == 12345 ? 1 : 0); } } diff --git a/packages/boba/account-abstraction/contracts/test/TestRevertAccount.sol b/packages/boba/account-abstraction/contracts/test/TestRevertAccount.sol new file mode 100644 index 0000000000..d7c376c49f --- /dev/null +++ b/packages/boba/account-abstraction/contracts/test/TestRevertAccount.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.12; +/* solhint-disable no-inline-assembly */ + +import "../samples/SimpleAccount.sol"; +contract TestRevertAccount is IAccount { + IEntryPoint private ep; + constructor(IEntryPoint _ep) payable { + ep = _ep; + } + + function validateUserOp(UserOperation calldata, bytes32, uint256 missingAccountFunds) + external override returns (uint256 validationData) { + ep.depositTo{value : missingAccountFunds}(address(this)); + return 0; + } + + function revertLong(uint256 length) public pure{ + assembly { + revert(0, length) + } + } +} diff --git a/packages/boba/account-abstraction/contracts/samples/TestSignatureAggregator.sol b/packages/boba/account-abstraction/contracts/test/TestSignatureAggregator.sol similarity index 74% rename from packages/boba/account-abstraction/contracts/samples/TestSignatureAggregator.sol rename to packages/boba/account-abstraction/contracts/test/TestSignatureAggregator.sol index cde77ce2f7..97b9eb5f9b 100644 --- a/packages/boba/account-abstraction/contracts/samples/TestSignatureAggregator.sol +++ b/packages/boba/account-abstraction/contracts/test/TestSignatureAggregator.sol @@ -4,9 +4,8 @@ pragma solidity ^0.8.12; /* solhint-disable reason-string */ import "../interfaces/IAggregator.sol"; -import "hardhat/console.sol"; -import "./SimpleAccount.sol"; -import "../core/EntryPoint.sol"; +import "../interfaces/IEntryPoint.sol"; +import "../samples/SimpleAccount.sol"; /** * test signature aggregator. @@ -14,18 +13,19 @@ import "../core/EntryPoint.sol"; */ contract TestSignatureAggregator is IAggregator { + /// @inheritdoc IAggregator function validateSignatures(UserOperation[] calldata userOps, bytes calldata signature) external pure override { uint sum = 0; for (uint i = 0; i < userOps.length; i++) { uint nonce = userOps[i].nonce; sum += nonce; - // console.log('%s validate sender=%s nonce %s', i, address(senderAccount), nonce); } require(signature.length == 32, "TestSignatureValidator: sig must be uint"); (uint sig) = abi.decode(signature, (uint)); require(sig == sum, "TestSignatureValidator: aggregated signature mismatch (nonce sum)"); } + /// @inheritdoc IAggregator function validateUserOpSignature(UserOperation calldata) external pure returns (bytes memory) { return ""; @@ -34,7 +34,7 @@ contract TestSignatureAggregator is IAggregator { /** * dummy test aggregator: sum all nonce values of UserOps. */ - function aggregateSignatures(UserOperation[] calldata userOps) external pure returns (bytes memory aggregatesSignature) { + function aggregateSignatures(UserOperation[] calldata userOps) external pure returns (bytes memory aggregatedSignature) { uint sum = 0; for (uint i = 0; i < userOps.length; i++) { sum += userOps[i].nonce; @@ -42,6 +42,11 @@ contract TestSignatureAggregator is IAggregator { return abi.encode(sum); } + /** + * Calls the 'addStake' method of the EntryPoint. Forwards the entire msg.value to this call. + * @param entryPoint - the EntryPoint to send the stake to. + * @param delay - the new lock duration before the deposit can be withdrawn. + */ function addStake(IEntryPoint entryPoint, uint32 delay) external payable { entryPoint.addStake{value: msg.value}(delay); } diff --git a/packages/boba/account-abstraction/contracts/test/TestWarmColdAccount.sol b/packages/boba/account-abstraction/contracts/test/TestWarmColdAccount.sol new file mode 100644 index 0000000000..76f35bc807 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/test/TestWarmColdAccount.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.12; +/* solhint-disable no-inline-assembly */ + +import "../interfaces/IEntryPoint.sol"; +import "../interfaces/IAccount.sol"; + +// Using eip-2929 (https://eips.ethereum.org/EIPS/eip-2929) warm/cold storage access gas costs to detect simulation vs execution +// COLD_ACCOUNT_ACCESS_COST == 2600, COLD_SLOAD_COST == 2100, WARM_STORAGE_READ_COST == 100 +contract TestWarmColdAccount is IAccount { + IEntryPoint private ep; + uint public state = 1; + constructor(IEntryPoint _ep) payable { + ep = _ep; + } + + function validateUserOp(UserOperation calldata userOp, bytes32, uint256 missingAccountFunds) + external override returns (uint256 validationData) { + ep.depositTo{value : missingAccountFunds}(address(this)); + if (userOp.nonce == 1) { + // can only succeed if storage is already warm + this.touchStorage{gas: 1000}(); + } else if (userOp.nonce == 2) { + address paymaster = address(bytes20(userOp.paymasterAndData[: 20])); + // can only succeed if storage is already warm + this.touchPaymaster{gas: 1000}(paymaster); + } + return 0; + } + + function touchStorage() public view returns (uint256) { + return state; + } + + function touchPaymaster(address paymaster) public view returns (uint256) { + return paymaster.code.length; + } +} diff --git a/packages/boba/account-abstraction/contracts/utils/Exec.sol b/packages/boba/account-abstraction/contracts/utils/Exec.sol index 60395d6127..69d653d938 100644 --- a/packages/boba/account-abstraction/contracts/utils/Exec.sol +++ b/packages/boba/account-abstraction/contracts/utils/Exec.sol @@ -3,6 +3,9 @@ pragma solidity >=0.7.5 <0.9.0; // solhint-disable no-inline-assembly +/** + * Utility functions helpful when making different kinds of contract calls in Solidity. + */ library Exec { function call( @@ -37,12 +40,16 @@ library Exec { } // get returned data from last call or calldelegate - function getReturnData() internal pure returns (bytes memory returnData) { + function getReturnData(uint256 maxLen) internal pure returns (bytes memory returnData) { assembly { + let len := returndatasize() + if gt(len, maxLen) { + len := maxLen + } let ptr := mload(0x40) - mstore(0x40, add(ptr, add(returndatasize(), 0x20))) - mstore(ptr, returndatasize()) - returndatacopy(add(ptr, 0x20), 0, returndatasize()) + mstore(0x40, add(ptr, add(len, 0x20))) + mstore(ptr, len) + returndatacopy(add(ptr, 0x20), 0, len) returnData := ptr } } @@ -54,10 +61,10 @@ library Exec { } } - function callAndRevert(address to, bytes memory data) internal { + function callAndRevert(address to, bytes memory data, uint256 maxLen) internal { bool success = call(to,0,data,gasleft()); if (!success) { - revertWithData(getReturnData()); + revertWithData(getReturnData(maxLen)); } } } diff --git a/packages/boba/account-abstraction/deploy/1-deploy-helper.ts b/packages/boba/account-abstraction/deploy/1-deploy_entrypoint.ts similarity index 67% rename from packages/boba/account-abstraction/deploy/1-deploy-helper.ts rename to packages/boba/account-abstraction/deploy/1-deploy_entrypoint.ts index e9fd495b80..0df707aed1 100644 --- a/packages/boba/account-abstraction/deploy/1-deploy-helper.ts +++ b/packages/boba/account-abstraction/deploy/1-deploy_entrypoint.ts @@ -1,7 +1,7 @@ import { DeployFunction, DeploymentSubmission } from 'hardhat-deploy/types' import { ethers } from 'hardhat' import { Contract, ContractFactory } from 'ethers' -import BundlerHelperJson from '../artifacts/contracts/bundler/BundlerHelper.sol/BundlerHelper.json' +import EntryPointJson from '../artifacts/contracts/core/EntryPoint.sol/EntryPoint.json' import { DeterministicDeployer } from '../src/DeterministicDeployer' const sleep = async (ms: number): Promise => { @@ -70,30 +70,25 @@ export const registerBobaAddress = async ( console.log(`✓ Registered address for ${name}`) } -let Factory__BundlerHelper: ContractFactory +let Factory__EntryPoint: ContractFactory const deployFn: DeployFunction = async (hre) => { - Factory__BundlerHelper = new ContractFactory( - BundlerHelperJson.abi, - BundlerHelperJson.bytecode, + Factory__EntryPoint = new ContractFactory( + EntryPointJson.abi, + EntryPointJson.bytecode, (hre as any).deployConfig.deployer_l2 ) - - // specify network here to deploy on mainnet/goerli or other networks - // for example: const dep = new DeterministicDeployer((hre as any).deployConfig.l2Provider, (hre as any).deployConfig.deployer_l2, "boba_mainnet") - // third parameter for DeterministicDeployer is network const dep = new DeterministicDeployer((hre as any).deployConfig.l2Provider, (hre as any).deployConfig.deployer_l2, 'local') - const BundlerHelperAddress = await dep.deterministicDeploy(Factory__BundlerHelper.bytecode) - console.log('Deployed BundlerHelper at', BundlerHelperAddress) + const EntryPointAddress = await dep.deterministicDeploy(Factory__EntryPoint.bytecode) + console.log('Deployed EntryPoint at', EntryPointAddress) - const BundlerHelperDeploymentSubmission: DeploymentSubmission = { - address: BundlerHelperAddress, - abi: BundlerHelperJson.abi + const EntryPointDeploymentSubmission: DeploymentSubmission = { + address: EntryPointAddress, + abi: EntryPointJson.abi } - await hre.deployments.save('BundlerHelper', BundlerHelperDeploymentSubmission) - - await registerBobaAddress((hre as any).deployConfig.addressManager, 'L2_Boba_BundlerHelper', BundlerHelperAddress ) + await hre.deployments.save('EntryPoint', EntryPointDeploymentSubmission) + await registerBobaAddress( (hre as any).deployConfig.addressManager, 'L2_Boba_EntryPoint', EntryPointAddress ) } export default deployFn -deployFn.tags = ['BundlerHelper'] +deployFn.tags = ['EntryPoint'] diff --git a/packages/boba/account-abstraction/deploy/3-deploy-deposit-paymaster.ts b/packages/boba/account-abstraction/deploy/2-deploy-deposit-paymaster.ts similarity index 97% rename from packages/boba/account-abstraction/deploy/3-deploy-deposit-paymaster.ts rename to packages/boba/account-abstraction/deploy/2-deploy-deposit-paymaster.ts index 2f5fba08ed..f6c46584f2 100644 --- a/packages/boba/account-abstraction/deploy/3-deploy-deposit-paymaster.ts +++ b/packages/boba/account-abstraction/deploy/2-deploy-deposit-paymaster.ts @@ -1,7 +1,7 @@ import { DeployFunction, DeploymentSubmission } from 'hardhat-deploy/types' import { ethers } from 'hardhat' import { Contract, ContractFactory } from 'ethers' -import { registerBobaAddress } from './1-deploy-helper' +import { registerBobaAddress } from './1-deploy_entrypoint' import BobaDepositPaymasterJson from '../artifacts/contracts/samples/BobaDepositPaymaster.sol/BobaDepositPaymaster.json' import { DeterministicDeployer } from '../src/DeterministicDeployer' diff --git a/packages/boba/account-abstraction/deploy/2-deploy_entrypoint.ts b/packages/boba/account-abstraction/deploy/2-deploy_entrypoint.ts deleted file mode 100644 index 5656f559da..0000000000 --- a/packages/boba/account-abstraction/deploy/2-deploy_entrypoint.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { DeployFunction, DeploymentSubmission } from 'hardhat-deploy/types' -import { ethers } from 'hardhat' -import { Contract, ContractFactory } from 'ethers' -import { registerBobaAddress } from './1-deploy-helper' -import EntryPointJson from '../artifacts/contracts/core/EntryPoint.sol/EntryPoint.json' -import { DeterministicDeployer } from '../src/DeterministicDeployer' - -let Factory__EntryPoint: ContractFactory - -const deployFn: DeployFunction = async (hre) => { - Factory__EntryPoint = new ContractFactory( - EntryPointJson.abi, - EntryPointJson.bytecode, - (hre as any).deployConfig.deployer_l2 - ) - const dep = new DeterministicDeployer((hre as any).deployConfig.l2Provider, (hre as any).deployConfig.deployer_l2, 'local') - const EntryPointAddress = await dep.deterministicDeploy(Factory__EntryPoint.bytecode) - console.log('Deployed EntryPoint at', EntryPointAddress) - - const EntryPointDeploymentSubmission: DeploymentSubmission = { - address: EntryPointAddress, - abi: EntryPointJson.abi - } - await hre.deployments.save('EntryPoint', EntryPointDeploymentSubmission) - await registerBobaAddress( (hre as any).deployConfig.addressManager, 'L2_Boba_EntryPoint', EntryPointAddress ) -} - -export default deployFn -deployFn.tags = ['EntryPoint'] diff --git a/packages/boba/account-abstraction/deploy/4-deploy-verifying-paymaster.ts b/packages/boba/account-abstraction/deploy/3-deploy-verifying-paymaster.ts similarity index 97% rename from packages/boba/account-abstraction/deploy/4-deploy-verifying-paymaster.ts rename to packages/boba/account-abstraction/deploy/3-deploy-verifying-paymaster.ts index 4bc84a0299..ff5242d7aa 100644 --- a/packages/boba/account-abstraction/deploy/4-deploy-verifying-paymaster.ts +++ b/packages/boba/account-abstraction/deploy/3-deploy-verifying-paymaster.ts @@ -1,7 +1,7 @@ import { DeployFunction, DeploymentSubmission } from 'hardhat-deploy/types' import { ethers } from 'hardhat' import { Contract, ContractFactory } from 'ethers' -import { registerBobaAddress } from './1-deploy-helper' +import { registerBobaAddress } from './1-deploy_entrypoint' import BobaVerifyingPaymasterJson from '../artifacts/contracts/samples/BobaVerifyingPaymaster.sol/BobaVerifyingPaymaster.json' import { DeterministicDeployer } from '../src/DeterministicDeployer' diff --git a/packages/boba/account-abstraction/deploy/5-dump-addresses.ts b/packages/boba/account-abstraction/deploy/4-dump-addresses.ts similarity index 93% rename from packages/boba/account-abstraction/deploy/5-dump-addresses.ts rename to packages/boba/account-abstraction/deploy/4-dump-addresses.ts index d916ad4638..397a9fdfbf 100644 --- a/packages/boba/account-abstraction/deploy/5-dump-addresses.ts +++ b/packages/boba/account-abstraction/deploy/4-dump-addresses.ts @@ -9,7 +9,7 @@ const deployFn: DeployFunction = async (hre) => { for (const key in deployments) { if (deployments.hasOwnProperty(key)) { - if (key == 'EntryPoint' || key == 'BundlerHelper') { + if (key == 'EntryPoint') { contracts['L2_BOBA_'+key] = deployments[key].address } else { contracts['L2_'+key] = deployments[key].address diff --git a/packages/boba/account-abstraction/eip/EIPS/eip-4337.md b/packages/boba/account-abstraction/eip/EIPS/eip-4337.md index 6006e3d017..4e9550864b 100644 --- a/packages/boba/account-abstraction/eip/EIPS/eip-4337.md +++ b/packages/boba/account-abstraction/eip/EIPS/eip-4337.md @@ -1,49 +1,50 @@ --- eip: 4337 -title: Account Abstraction via Entry Point Contract specification +title: Account Abstraction Using Alt Mempool description: An account abstraction proposal which completely avoids consensus-layer protocol changes, instead relying on higher-layer infrastructure. author: Vitalik Buterin (@vbuterin), Yoav Weiss (@yoavw), Kristof Gazso (@kristofgazso), Namra Patel (@namrapatel), Dror Tirosh (@drortirosh), Shahaf Nacson (@shahafn), Tjaden Hess (@tjade273) discussions-to: https://ethereum-magicians.org/t/erc-4337-account-abstraction-via-entry-point-contract-specification/7160 +status: Draft type: Standards Track category: ERC -status: Draft created: 2021-09-29 --- ## Abstract -An account abstraction proposal which completely avoids the need for consensus-layer protocol changes. Instead of adding new protocol features and changing the bottom-layer transaction type, this proposal instead introduces a higher-layer pseudo-transaction object called a `UserOperation`. Users send `UserOperation` objects into a separate mempool. A special class of actor called bundlers (either miners, or users that can send transactions to miners through a bundle marketplace) package up a set of these objects into a transaction making a `handleOps` call to a special contract, and that transaction then gets included in a block. +An account abstraction proposal which completely avoids the need for consensus-layer protocol changes. Instead of adding new protocol features and changing the bottom-layer transaction type, this proposal instead introduces a higher-layer pseudo-transaction object called a `UserOperation`. Users send `UserOperation` objects into a separate mempool. A special class of actor called bundlers (either block builders, or users that can send transactions to block builders through a bundle marketplace) package up a set of these objects into a transaction making a `handleOps` call to a special contract, and that transaction then gets included in a block. ## Motivation -See also ["Implementing Account Abstraction as Part of Eth 1.x"](https://ethereum-magicians.org/t/implementing-account-abstraction-as-part-of-eth1-x/4020) and the links therein for historical work and motivation, and [EIP-2938](./eip-2938.md) for a consensus layer proposal for implementing the same goal. +See also `https://ethereum-magicians.org/t/implementing-account-abstraction-as-part-of-eth1-x/4020` and the links therein for historical work and motivation, and [EIP-2938](./eip-2938.md) for a consensus layer proposal for implementing the same goal. This proposal takes a different approach, avoiding any adjustments to the consensus layer. It seeks to achieve the following goals: -* **Achieve the key goal of account abstraction**: allow users to use smart contract wallets containing arbitrary verification logic instead of EOAs as their primary account. Completely remove any need at all for users to also have EOAs (as status quo SC wallets and EIP-3074 both require) +* **Achieve the key goal of account abstraction**: allow users to use smart contract wallets containing arbitrary verification logic instead of EOAs as their primary account. Completely remove any need at all for users to also have EOAs (as status quo SC wallets and [EIP-3074](./eip-3074.md) both require) * **Decentralization** - * Allow any bundler (think: miner) to participate in the process of including account-abstracted user operations + * Allow any bundler (think: block builder) to participate in the process of including account-abstracted user operations * Work with all activity happening over a public mempool; users do not need to know the direct communication addresses (eg. IP, onion) of any specific actors * Avoid trust assumptions on bundlers * **Do not require any Ethereum consensus changes**: Ethereum consensus layer development is focusing on the merge and later on scalability-oriented features, and there may not be any opportunity for further protocol changes for a long time. Hence, to increase the chance of faster adoption, this proposal avoids Ethereum consensus changes. * **Try to support other use cases** * Privacy-preserving applications - * Atomic multi-operations (similar goal to EIP-3074) - * Pay tx fees with ERC-20 tokens, allow developers to pay fees for their users, and [EIP-3074](./eip-3074.md)-like **sponsored transaction** use cases more generally + * Atomic multi-operations (similar goal to [EIP-3074](./eip-3074.md)) + * Pay tx fees with [EIP-20](./eip-20.md) tokens, allow developers to pay fees for their users, and [EIP-3074](./eip-3074.md)-like **sponsored transaction** use cases more generally * Support aggregated signature (e.g. BLS) -## Definitions +## Specification -* **UserOperation** - a structure that describes a transaction to be sent on behalf of a user. To avoid confusion, it is named "transaction". +### Definitions + +* **UserOperation** - a structure that describes a transaction to be sent on behalf of a user. To avoid confusion, it is named "UserOperation" instead of "transaction." * Like a transaction, it contains "sender", "to", "calldata", "maxFeePerGas", "maxPriorityFee", "signature", "nonce" - * unlike transaction, it contains several other fields, desribed below + * unlike transaction, it contains several other fields, described below * also, the "nonce" and "signature" fields usage is not defined by the protocol, but by each account implementation * **Sender** - the account contract sending a user operation. * **EntryPoint** - a singleton contract to execute bundles of UserOperations. Bundlers/Clients whitelist the supported entrypoint. -* **Bundler** - a node (block builder) that bundles multiple UserOperations and create an EntryPoint.handleOps() transaction. +* **Bundler** - a node (block builder) that bundles multiple UserOperations and create an EntryPoint.handleOps() transaction. Note that not all block-builders on the network are required to be bundlers * **Aggregator** - a helper contract trusted by accounts to validate an aggregated signature. Bundlers/Clients whitelist the supported aggregators. -## Specification To avoid Ethereum consensus changes, we do not attempt to create new transaction types for account-abstracted transactions. Instead, users package up the action they want their account to take in an ABI-encoded struct called a `UserOperation`: @@ -56,18 +57,18 @@ To avoid Ethereum consensus changes, we do not attempt to create new transaction | `callGasLimit` | `uint256` | The amount of gas to allocate the main execution call | | `verificationGasLimit` | `uint256` | The amount of gas to allocate for the verification step | | `preVerificationGas` | `uint256` | The amount of gas to pay for to compensate the bundler for pre-verification execution and calldata | -| `maxFeePerGas` | `uint256` | Maximum fee per gas (similar to EIP 1559 `max_fee_per_gas`) | -| `maxPriorityFeePerGas` | `uint256` | Maximum priority fee per gas (similar to EIP 1559 `max_priority_fee_per_gas`) | +| `maxFeePerGas` | `uint256` | Maximum fee per gas (similar to [EIP-1559](./eip-1559.md) `max_fee_per_gas`) | +| `maxPriorityFeePerGas` | `uint256` | Maximum priority fee per gas (similar to EIP-1559 `max_priority_fee_per_gas`) | | `paymasterAndData` | `bytes` | Address of paymaster sponsoring the transaction, followed by extra data to send to the paymaster (empty for self-sponsored transaction) | | `signature` | `bytes` | Data passed into the account along with the nonce during the verification step | -Users send `UserOperation` objects to a dedicated user operation mempool. A specialized class of actors called **bundlers** (either miners running special-purpose code, or users that can relay transactions to miners eg. through a bundle marketplace such as Flashbots that can guarantee next-block-or-never inclusion) listen in on the user operation mempool, and create **bundle transactions**. A bundle transaction packages up multiple `UserOperation` objects into a single `handleOps` call to a pre-published global **entry point contract**. +Users send `UserOperation` objects to a dedicated user operation mempool. A specialized class of actors called **bundlers** (either block builders running special-purpose code, or users that can relay transactions to block builders eg. through a bundle marketplace such as Flashbots that can guarantee next-block-or-never inclusion) listen in on the user operation mempool, and create **bundle transactions**. A bundle transaction packages up multiple `UserOperation` objects into a single `handleOps` call to a pre-published global **entry point contract**. To prevent replay attacks (both cross-chain and multiple `EntryPoint` implementations), the `signature` should depend on `chainid` and the `EntryPoint` address. The core interface of the entry point contract is as follows: -```c++ +```solidity function handleOps(UserOperation[] calldata ops, address payable beneficiary); function handleAggregatedOps( @@ -75,27 +76,38 @@ function handleAggregatedOps( address payable beneficiary ); + struct UserOpsPerAggregator { UserOperation[] userOps; IAggregator aggregator; bytes signature; } - function simulateValidation(UserOperation calldata userOp); -error SimulationResult(uint256 preOpGas, uint256 prefund, uint256 deadline, PaymasterInfo paymasterInfo); +error ValidationResult(ReturnInfo returnInfo, + StakeInfo senderInfo, StakeInfo factoryInfo, StakeInfo paymasterInfo); -error SimulationResultWithAggregation(uint256 preOpGas, uint256 prefund, uint256 deadline, PaymasterInfo paymasterInfo, AggregationInfo aggregationInfo); +error ValidationResultWithAggregation(ReturnInfo returnInfo, + StakeInfo senderInfo, StakeInfo factoryInfo, StakeInfo paymasterInfo, + AggregatorStakeInfo aggregatorInfo); -struct PaymasterInfo { - uint256 paymasterStake; - uint256 paymasterUnstakeDelay; +struct ReturnInfo { + uint256 preOpGas; + uint256 prefund; + bool sigFailed; + uint48 validAfter; + uint48 validUntil; + bytes paymasterContext; } -struct AggregationInfo { +struct StakeInfo { + uint256 stake; + uint256 unstakeDelaySec; +} + +struct AggregatorStakeInfo { address actualAggregator; - uint256 aggregatorStake; - uint256 aggregatorUnstakeDelay; + StakeInfo stakeInfo; } ``` @@ -104,34 +116,28 @@ The core interface required for an account to have is: ```solidity interface IAccount { function validateUserOp - (UserOperation calldata userOp, bytes32 userOpHash, address aggregator, uint256 missingAccountFunds) - external returns (uint256 deadline); + (UserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds) + external returns (uint256 validationData); } ``` -The account + +The `userOpHash` is a hash over the userOp (except signature), entryPoint and chainId. The account: + * MUST validate the caller is a trusted EntryPoint -* The userOpHash is a hash over the userOp (except signature), entryPoint and chainId -* If the account does not support signature aggregation, it MUST validate the signature is a valid signature of the `requestId` +* If the account does not support signature aggregation, it MUST validate the signature is a valid signature of the `userOpHash`, and + SHOULD return SIG_VALIDATION_FAILED (and not revert) on signature mismatch. Any other error should revert. * MUST pay the entryPoint (caller) at least the "missingAccountFunds" (which might be zero, in case current account's deposit is high enough) * The account MAY pay more than this minimum, to cover future transactions (it can always issue `withdrawTo` to retrieve it) -* The `aggregator` SHOULD be ignored for accounts that don't use an aggregator -* The return value `deadline` is either zero (meaning "indefinitely"), or the last timestamp this request is deemed valid. +* The return value MUST be packed of `authorizer`, `validUntil` and `validAfter` timestamps. + * authorizer - 0 for valid signature, 1 to mark signature failure. Otherwise, an address of an authorizer contract. This ERC defines "signature aggregator" as authorizer. + * `validUntil` is 6-byte timestamp value, or zero for "infinite". The UserOp is valid only up to this time. + * `validAfter` is 6-byte timestamp. The UserOp is valid only after this time. -An account that works with aggregated signature should have the interface: -```solidity -interface IAggregatedAccount is IAccount { - - function getAggregator() view returns (address); -} -``` -* **getAggregator()** returns the aggregator this account supports. -* **validateUserOp()** (inherited from IAccount interface) MUST verify the `aggregator` parameter is valid and the same as `getAggregator` -* The account should also support aggregator-specific getter (e.g. `getAggregationInfo()`). - This method should export the account's public-key to the aggregator, and possibly more info - (note that it is not called directly by the entryPoint) -* validateUserOp MAY ignore the signature field +An account that works with aggregated signature, should return its signature aggregator address in the "sigAuthorizer" return value of validateUserOp. +It MAY ignore the signature field The core interface required by an aggregator is: + ```solidity interface IAggregator { @@ -144,7 +150,7 @@ interface IAggregator { } ``` -* If an account uses an aggregator (returns it with getAggregator()), then its address is returned by `simulateValidation()` reverting with `SimulationResultWithAggregator` instead of `SimulationResult` +* If an account uses an aggregator (returns it from validateUserOp), then its address is returned by `simulateValidation()` reverting with `ValidationResultWithAggregator` instead of `ValidationResult` * To accept the UserOp, the bundler must call **validateUserOpSignature()** to validate the userOp's signature. * **aggregateSignatures()** must aggregate all UserOp signature into a single value. * Note that the above methods are helper method for the bundler. The bundler MAY use a native library to perform the same validation and aggregation logic. @@ -153,18 +159,19 @@ interface IAggregator { #### Using signature aggregators -An account signify it uses signature aggregation by exposing the aggregator's address in the `getAggregator()` method. -During `simulateValidation`, this aggregator is returned (in the `SimulationResultWithAggregator`) +An account signify it uses signature aggregation returning its address from `validateUserOp`. +During `simulateValidation`, this aggregator is returned (in the `ValidationResultWithAggregator`) -The bundler should first accept the validator (validate its stake info and that it is not throttled/banned) +The bundler should first accept the aggregator (validate its stake info and that it is not throttled/banned) Then it MUST verify the userOp using `aggregator.validateUserOpSignature()` -Signature aggregator SHOULD stake just like a paymaster. Bundlers MAY throttle down and ban aggregators in case they take too much -resources (or revert) when the above methods are called in view mode, or if the signature aggregation fails +Signature aggregator SHOULD stake just like a paymaster, unless it is exempt due to not accessing global storage - see [reputation, throttling and banning section](#reputation-scoring-and-throttlingbanning-for-global-entities) for details. Bundlers MAY throttle down and ban aggregators in case they take too much +resources (or revert) when the above methods are called in view mode, or if the signature aggregation fails. ### Required entry point contract functionality There are 2 separate entry point methods: `handleOps` and `handleAggregatedOps` + * `handleOps` handle userOps of accounts that don't require any signature aggregator. * `handleAggregatedOps` can handle a batch that contains userOps of multiple aggregators (and also requests without any aggregator) * `handleAggregatedOps` performs the same logic below as `handleOps`, but it must transfer the correct aggregator to each userOp, and also must call `validateSignatures` on each aggregator after doing all the per-account validation. @@ -185,24 +192,22 @@ A node/bundler SHOULD drop (and not add to the mempool) `UserOperation` that fai ### Extension: paymasters -We extend the entry point logic to support **paymasters** that can sponsor transactions for other users. This feature can be used to allow application developers to subsidize fees for their users, allow users to pay fees with ERC-20 tokens and many other use cases. When the paymaster is not equal to the zero address, the entry point implements a different flow: +We extend the entry point logic to support **paymasters** that can sponsor transactions for other users. This feature can be used to allow application developers to subsidize fees for their users, allow users to pay fees with [EIP-20](./eip-20.md) tokens and many other use cases. When the paymaster is not equal to the zero address, the entry point implements a different flow: ![](../assets/eip-4337/image2.png) -First, a paymaster must issue `addStake()` to lock some eth for a period of time. Note that the amount staked (and unstake delay time) are not defined on-chain, and MUST be -validated by nodes during the UserOperation validation phase. The staked value should be above PAYMASTER_STAKE_VALUE and delay above PAYMASTER_MIN_UNSTAKE_DELAY seconds. -During the verification loop, in addition to calling `validateUserOp`, the `handleOps` execution also must check that the paymaster is staked, and also has enough ETH deposited with the entry point to pay for the operation, and then call `validatePaymasterUserOp` on the paymaster to verify that the paymaster is willing to pay for the operation. Note that in this case, the `validateUserOp` is called with a `missingAccountFunds` of 0 to reflect that the account's deposit is not used for payment for this userOp. +During the verification loop, in addition to calling `validateUserOp`, the `handleOps` execution also must check that the paymaster has enough ETH deposited with the entry point to pay for the operation, and then call `validatePaymasterUserOp` on the paymaster to verify that the paymaster is willing to pay for the operation. Note that in this case, the `validateUserOp` is called with a `missingAccountFunds` of 0 to reflect that the account's deposit is not used for payment for this userOp. -During the execution loop, the `handleOps` execution must call `postOp` on the paymaster after making the main execution call. It must guarantee the execution of `postOp`, by making the main execution inside an inner call context, and if the inner call context reverts attempting to call `postOp` again in an outer call context. +If the paymaster's validatePaymasterUserOp returns a "context", then `handleOps` must call `postOp` on the paymaster after making the main execution call. It must guarantee the execution of `postOp`, by making the main execution inside an inner call context, and if the inner call context reverts attempting to call `postOp` again in an outer call context. -Maliciously crafted paymasters _can_ DoS the system. To prevent this, we use a paymaster reputation system; see the [reputation, throttling and banning section](#reputation-scoring-and-throttlingbanning-for-paymasters) for details. +Maliciously crafted paymasters _can_ DoS the system. To prevent this, we use a reputation system. paymaster must either limit its storage usage, or have a stake. see the [reputation, throttling and banning section](#reputation-scoring-and-throttlingbanning-for-global-entities) for details. The paymaster interface is as follows: ```c++ -function validatePaymasterUserOp + function validatePaymasterUserOp (UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) - external returns (bytes memory context, uint256 deadline); + external returns (bytes memory context, uint256 validationData); function postOp (PostOpMode mode, bytes calldata context, uint256 actualGasCost) @@ -227,7 +232,10 @@ function unlockStake() external function withdrawStake(address payable withdrawAddress) external ``` -The paymaster must also have a deposit, which the entry point will charge UserOperation costs from. The entry point must implement the following interface to allow paymasters (and optionally accounts) manage their deposit: +The paymaster must also have a deposit, which the entry point will charge UserOperation costs from. +The deposit (for paying gas fees) is separate from the stake (which is locked). + +The entry point must implement the following interface to allow paymasters (and optionally accounts) manage their deposit: ```c++ // return the deposit of an account @@ -238,71 +246,83 @@ function depositTo(address account) public payable // withdraw from the deposit function withdrawTo(address payable withdrawAddress, uint256 withdrawAmount) external - ``` ### Client behavior upon receiving a UserOperation When a client receives a `UserOperation`, it must first run some basic sanity checks, namely that: -- Either the `sender` is an existing contract, or the `initCode` is not empty (but not both) -- The `verificationGasLimit` is sufficiently low (`<= MAX_VERIFICATION_GAS`) and the `preVerificationGas` is sufficiently high (enough to pay for the calldata gas cost of serializing the `UserOperation` plus `PRE_VERIFICATION_OVERHEAD_GAS`) -- The paymaster is either the zero address or is a contract which (i) currently has nonempty code on chain, (ii) has registered with sufficient stake value, (iii) has a sufficient deposit to pay for the UserOperation, and (v) is not currently banned. -- The callgas is at least the cost of a `CALL` with non-zero value. -- The `maxFeePerGas` and `maxPriorityFeePerGas` are above a configurable minimum value that the client is willing to accept. At the minimum, they are sufficiently high to be included with the current `block.basefee`. -- The sender doesn't have another `UserOperation` already present in the pool (or it replaces an existing entry with the same sender and nonce, with a higher `maxPriorityFeePerGas` and an equally increased `maxFeePerGas`). Only one `UserOperation` per sender may be included in a single batch. +* Either the `sender` is an existing contract, or the `initCode` is not empty (but not both) +* If `initCode` is not empty, parse its first 20 bytes as a factory address. Record whether the factory is staked, in case the later simulation indicates that it needs to be. If the factory accesses global state, it must be staked - see [reputation, throttling and banning section](#reputation-scoring-and-throttlingbanning-for-global-entities) for details. +* The `verificationGasLimit` is sufficiently low (`<= MAX_VERIFICATION_GAS`) and the `preVerificationGas` is sufficiently high (enough to pay for the calldata gas cost of serializing the `UserOperation` plus `PRE_VERIFICATION_OVERHEAD_GAS`) +* The `paymasterAndData` is either empty, or start with the **paymaster** address, which is a contract that (i) currently has nonempty code on chain, (ii) has a sufficient deposit to pay for the UserOperation, and (iii) is not currently banned. During simulation, the paymaster's stake is also checked, depending on its storage usage - see [reputation, throttling and banning section](#reputation-scoring-and-throttlingbanning-for-global-entities) for details. +* The callgas is at least the cost of a `CALL` with non-zero value. +* The `maxFeePerGas` and `maxPriorityFeePerGas` are above a configurable minimum value that the client is willing to accept. At the minimum, they are sufficiently high to be included with the current `block.basefee`. +* The sender doesn't have another `UserOperation` already present in the pool (or it replaces an existing entry with the same sender and nonce, with a higher `maxPriorityFeePerGas` and an equally increased `maxFeePerGas`). Only one `UserOperation` per sender may be included in a single batch. A sender is exempt from this rule and may have multiple `UserOperations` in the pool and in a batch if it is staked (see [reputation, throttling and banning section](#reputation-scoring-and-throttlingbanning-for-global-entities) below), but this exception is of limited use to normal accounts. -If the `UserOperation` object passes these sanity checks, the client must next run the first op simulation, and if the simulation succeeds, the client must add the op to the pool. A second simulation must also happen during bundling to make sure that the storage accessed is the same as the `accessList` that was saved during the initial simulation. +If the `UserOperation` object passes these sanity checks, the client must next run the first op simulation, and if the simulation succeeds, the client must add the op to the pool. A second simulation must also happen during bundling to make sure the UserOperation is still valid. ### Simulation -#### Rationale +#### Simulation Rationale -In order to add a UserOperation into the mempool (and later to add it into a bundle) we need to "simulate" it make sure it is valid, and that it is capable of paying for its own execution. +In order to add a UserOperation into the mempool (and later to add it into a bundle) we need to "simulate" its validation to make sure it is valid, and that it is capable of paying for its own execution. In addition, we need to verify that the same will hold true when executed on-chain. For this purpose, a UserOperation is not allowed to access any information that might change between simulation and execution, such as current block time, number, hash etc. In addition, a UserOperation is only allowed to access data related to this sender address: Multiple UserOperations should not access the same storage, so that it is impossible to invalidate a large number of UserOperations with a single state change. +There are 3 special contracts that interact with the account: the factory (initCode) that deploys the contract, the paymaster that can pay for the gas, and signature aggregator (described later) +Each of these contracts is also restricted in its storage access, to make sure UserOperation validations are isolated. -#### Specification -To simulate a `UserOperation` validation, the client makes a view call to `simulateValidation(userop)`, with a "from" address set to all-zeros +#### Specification: -This method always revert with `SimulationResult` as successful response. +To simulate a `UserOperation` validation, the client makes a view call to `simulateValidation(userop)` + +This method always revert with `ValidationResult` as successful response. If the call reverts with other error, the client rejects this `userOp`. -The simulated call performs the full validation, by -calling: -1. `account.validateUserOp`. -2. if specified a paymaster: `paymaster.validatePaymasterUserOp`. +The simulated call performs the full validation, by calling: + +1. If `initCode` is present, create the account. +2. `account.validateUserOp`. +3. if specified a paymaster: `paymaster.validatePaymasterUserOp`. -Either `validateUserOp` or `validatePaymasterUserOp` may return a "deadline", which is the latest timestamp that this UserOperation is valid on-chain. -the simulateValidation call returns the minimum of those deadlines. -A node MAY drop a UserOperation if the deadline is too soon (e.g. wouldn't make it to the next block) +Either `validateUserOp` or `validatePaymasterUserOp` may return a "validAfter" and "validUntil" timestamps, which is the time-range that this UserOperation is valid on-chain. +The simulateValidation call returns this range. +A node MAY drop a UserOperation if it expires too soon (e.g. wouldn't make it to the next block) + +If the `ValidationResult` includes `sigFail`, the client SHOULD drop the `UserOperation`. The operations differ in their opcode banning policy. -In order to distinguish between them, there is a call to the NUMBER opcode (`block.number`), used as a delimiter between the validation functions. +In order to distinguish between them, there is a call to the NUMBER opcode (`block.number`), used as a delimiter between the 3 functions. While simulating `userOp` validation, the client should make sure that: -1. Neither call's execution trace invokes any **forbidden opcodes** +1. May not invokes any **forbidden opcodes** 2. Must not use GAS opcode (unless followed immediately by one of { `CALL`, `DELEGATECALL`, `CALLCODE`, `STATICCALL` }.) -3. The first (validateUserOp) call is allowed to access storage slot of the account itself (see Storage access by Slots, below), - but only on contracts that are not part of the current bundle (that is, not in a paymaster or any other account) - Note that the account is allowed (and expected) to deposit to its own balance in the EntryPoint. That is allowed by this rule. -4. The second (validatePaymasterUserOp), the paymaster may access storage slots of itself AND of the account, above. -5. Limitation on "CALL" opcodes (`CALL`, `DELEGATECALL`, `CALLCODE`, `STATICCALL`): +3. Storage access is limited as follows: + 1. self storage (of factory/paymaster, respectively) is allowed, but only if self entity is staked + 2. account storage access is allowed (see Storage access by Slots, below), + 3. in any case, may not use storage used by another UserOp `sender` in the same bundle (that is, paymaster and factory are not allowed as senders) +4. Limitation on "CALL" opcodes (`CALL`, `DELEGATECALL`, `CALLCODE`, `STATICCALL`): 1. must not use value (except from account to the entrypoint) 2. must not revert with out-of-gas 3. destination address must have code (EXTCODESIZE>0) -6. `EXTCODEHASH` of every address accessed (by any opcode) does not change between first and second simulations of the op. -7. If `op.initcode.length != 0` , allow only one `CREATE2` opcode call (in the validateUserOp block), otherwise forbid `CREATE2`. + 4. cannot call EntryPoint's methods, except `depositFor` (to avoid recursion) +5. `EXTCODEHASH` of every address accessed (by any opcode) does not change between first and second simulations of the op. +6. `EXTCODEHASH`, `EXTCODELENGTH`, `EXTCODECOPY` may not access address with no code. +7. If `op.initcode.length != 0` , allow only one `CREATE2` opcode call (in the first (deployment) block), otherwise forbid `CREATE2`. + +#### Storage associated with an address -#### Storage access by slots +We define storage slots as "associated with an address" as all the slots that uniquely related on this address, and cannot be related with any other address. +In solidity, this includes all storage of the contract itself, and any storage of other contracts that use this contract address as a mapping key. + +An address `A` is associated with: -During validation, a contract `A` may reference storage of 1. Slots of contract `A` address itself. 2. Slot `A` on any other address. -3. Slots of type `keccak256(A || X)` on any other address. (to cover `mapping(address => value)`, which is usually used for balance in ERC20 tokens). -4. Slots of type `keccak256(X || OWN)` on any other address, where OWN is some slot of the previous (third) type, or recursively of this (forth) type - (to cover `mapping(address ⇒ mapping(address ⇒ uint256)`) that are usually used for `allowances` in ERC20 tokens). +3. Slots of type `keccak256(A || X) + n` on any other address. (to cover `mapping(address => value)`, which is usually used for balance in EIP-20 tokens). + `n` is an offset value up to 128, to allow accessing fields in the format `mapping(address => struct)` + #### Alternative Mempools @@ -319,14 +339,19 @@ These UserOperations can be bundled together with UserOperations from the main m ### Bundling During bundling, the client should: -- Exclude UserOps that access any sender address created by another UserOp on the same batch (via CREATE2 factory). -- For each paymaster used in the batch, keep track of the balance while adding UserOps. Ensure that it has sufficient deposit to pay for all the UserOps that use it. -- Sort UserOps by aggregator, to create the lists of UserOps-per-aggregator. -- For each aggregator, run the aggregator-specific code to create aggregated signature, and update the UserOps + +* Exclude UserOps that access any sender address of another UserOp in the same batch. +* Exclude UserOps that access any address created by another UserOp validation in the same batch (via a factory). +* For each paymaster used in the batch, keep track of the balance while adding UserOps. Ensure that it has sufficient deposit to pay for all the UserOps that use it. +* Sort UserOps by aggregator, to create the lists of UserOps-per-aggregator. +* For each aggregator, run the aggregator-specific code to create aggregated signature, and update the UserOps After creating the batch, before including the transaction in a block, the client should: -- Run `eth_estimateGas` with maximum possible gas, to verify the entire `handleOps` batch transaction, and use the estimated gas for the actual transaction execution. -- If the call reverted, check the `FailedOp` event. A `FailedOp` during `handleOps` simulation is an unexpected event since it was supposed to be caught by the single-UserOperation simulation. Remove the failed op that caused the revert from the batch and drop from the mempool. Other ops from the same paymaster should be removed from the current batch, but kept in the mempool. Repeat until `eth_estimateGas` succeeds. + +* Run `eth_estimateGas` with maximum possible gas, to verify the entire `handleOps` batch transaction, and use the estimated gas for the actual transaction execution. +* If the call reverted, check the `FailedOp` event. A `FailedOp` during `handleOps` simulation is an unexpected event since it was supposed to be caught by the single-UserOperation simulation. Remove the failed op that caused the revert from the batch and drop from the mempool. + If the error is caused by a factory (error code is "AA1.") or paymaster (error code is "AA3."), then also drop from mempool all other UserOps of this entity. + Repeat until `eth_estimateGas` succeeds. In practice, restrictions (2) and (3) basically mean that the only external accesses that the account and the paymaster can make are reading code of other contracts if their code is guaranteed to be immutable (eg. this is useful for calling or delegatecalling to libraries). @@ -336,41 +361,59 @@ When a bundler includes a bundle in a block it must ensure that earlier transact #### Forbidden opcodes -The forbidden opcodes are to be forbidden when `depth > 2` (i.e. when it is the account, paymaster, or other contracts called by them that are being executed). They are: `GASPRICE`, `GASLIMIT`, `DIFFICULTY`, `TIMESTAMP`, `BASEFEE`, `BLOCKHASH`, `NUMBER`, `SELFBALANCE`, `BALANCE`, `ORIGIN`, `GAS`, `CREATE`, `COINBASE`. They should only be forbidden during verification, not execution. These opcodes are forbidden because their outputs may differ between simulation and execution, so simulation of calls using these opcodes does not reliably tell what would happen if these calls are later done on-chain. +The forbidden opcodes are to be forbidden when `depth > 2` (i.e. when it is the factory, account, paymaster, or other contracts called by them that are being executed). They are: `GASPRICE`, `GASLIMIT`, `DIFFICULTY`, `TIMESTAMP`, `BASEFEE`, `BLOCKHASH`, `NUMBER`, `SELFBALANCE`, `BALANCE`, `ORIGIN`, `GAS`, `CREATE`, `COINBASE`, `SELFDESTRUCT`. They should only be forbidden during verification, not execution. These opcodes are forbidden because their outputs may differ between simulation and execution, so simulation of calls using these opcodes does not reliably tell what would happen if these calls are later done on-chain. Exceptions to the forbidden opcodes: + 1. A single `CREATE2` is allowed if `op.initcode.length != 0` and must result in the deployment of a previously-undeployed `UserOperation.sender`. 2. `GAS` is allowed if followed immediately by one of { `CALL`, `DELEGATECALL`, `CALLCODE`, `STATICCALL` }. (that is, making calls is allowed, using `gasleft()` or `gas` opcode directly is forbidden) -### Reputation scoring and throttling/banning for paymasters -#### Rationale +### Reputation scoring and throttling/banning for global entities + +#### Reputation Rationale. -Paymasters might influence many UserOperations in the mempool. -To prevent abuse, we throttle down (or completely ban for a period of time) a paymaster that causes invalidation of large number of UserOperations in the mempool. -To prevent paymasters from "sybil-attack", we require a paymaster to stake with the system, and thus make such DoS attack very expensive. +UserOperation's storage access rules prevent them from interfere with each other. +But "global" entities - paymasters, factories and aggregators are accessed by multiple UserOperations, and thus might invalidate multiple previously-valid UserOperations. + +To prevent abuse, we throttle down (or completely ban for a period of time) an entity that causes invalidation of large number of UserOperations in the mempool. +To prevent such entities from "sybil-attack", we require them to stake with the system, and thus make such DoS attack very expensive. Note that this stake is never slashed, and can be withdrawn any time (after unstake delay) +Unstaked entities are allowed, under the rules below. + +When staked, an entity is also allowed to use its own associated storage, in addition to sender's associated storage. + The stake value is not enforced on-chain, but specifically by each node while simulating a transaction. -The stake is expected to be above PAYMASTER_MIN_STAKE_VALUE, and unstake delay above PAYMASTER_MIN_UNSTAKE_DELAY -The value of PAYMASTER_MIN_UNSTAKE_DELAY is 84600 (one day) +The stake is expected to be above MIN_STAKE_VALUE, and unstake delay above MIN_UNSTAKE_DELAY +The value of MIN_UNSTAKE_DELAY is 84600 (one day) +The value of MIN_STAKE_VALUE is determined per chain, and specified in the "bundler specification test suite" + +#### Un-staked entities -The value of PAYMASTER_MIN_STAKE_VALUE is determined per chain, TBD +Under the following special conditions, unstaked entities still can be used: -#### Specification +- An entity that doesn't use any storage at all, or only the senders's storage (not the entity's storage - that does require a stake) +- If the UserOp doesn't create a new account (that is initCode is empty), then the entity may also use [storage associated with the sender](#storage-associated-with-an-address)) +- A paymaster that has a “postOp()” method (that is, validatePaymasterUserOp returns “context”) must be staked -Clients maintain two mappings with a value for each paymaster: +#### Specification. + +In the following specification, "entity" is either address that is explicitly referenced by the UserOperation: sender, factory, paymaster and aggregator. +Clients maintain two mappings with a value for staked entities: * `opsSeen: Map[Address, int]` * `opsIncluded: Map[Address, int]` -When the client learns of a new `paymaster`, it sets `opsSeen[paymaster] = 0` and `opsIncluded[paymaster] = 0` . +If an entity doesn't use storage at all, or only reference storage associated with the "sender" (see [Storage associated with an address](#storage-associated-with-an-address)), then it is considered "OK", without using the rules below. + +When the client learns of a new staked entity, it sets `opsSeen[paymaster] = 0` and `opsIncluded[paymaster] = 0` . -The client sets `opsSeen[paymaster] +=1` each time it adds an op with that `paymaster` to the `UserOperationPool`, and the client sets `opsIncluded[paymaster] += 1` each time an op that was in the `UserOperationPool` is included on-chain. +The client sets `opsSeen[entity] +=1` each time it adds an op with that `entity` to the `UserOperationPool`, and the client sets `opsIncluded[entity] += 1` each time an op that was in the `UserOperationPool` is included on-chain. -Every hour, the client sets `opsSeen[paymaster] -= opsSeen[paymaster] // 24` and `opsIncluded[paymaster] -= opsIncluded[paymaster] // 24` for all paymasters (so both values are 24-hour exponential moving averages). +Every hour, the client sets `opsSeen[entity] -= opsSeen[entity] // 24` and `opsIncluded[entity] -= opsIncluded[entity] // 24` for all entities (so both values are 24-hour exponential moving averages). -We define the **status** of a paymaster as follows: +We define the **status** of an entity as follows: ```python OK, THROTTLED, BANNED = 0, 1, 2 @@ -389,7 +432,7 @@ def status(paymaster: Address, return BANNED ``` -Stated in simpler terms, we expect at least `1 / MIN_INCLUSION_RATE_DENOMINATOR` of all ops seen on the network to get included. If a paymaster falls too far behind this minimum, the paymaster gets **throttled** (meaning, the client does not accept ops from that paymaster if there is already an op from that paymaster, and an op only stays in the pool for 10 blocks), If the paymaster falls even further behind, it gets **banned**. Throttling and banning naturally reverse over time because of the exponential-moving-average rule. +Stated in simpler terms, we expect at least `1 / MIN_INCLUSION_RATE_DENOMINATOR` of all ops seen on the network to get included. If an entity falls too far behind this minimum, it gets **throttled** (meaning, the client does not accept ops from that paymaster if there is already an op with that entity, and an op only stays in the pool for 10 blocks), If the entity falls even further behind, it gets **banned**. Throttling and banning naturally decay over time because of the exponential-moving-average rule. **Non-bundling clients and bundlers should use different settings for the above params**: @@ -401,16 +444,88 @@ Stated in simpler terms, we expect at least `1 / MIN_INCLUSION_RATE_DENOMINATOR` To help make sense of these params, note that a malicious paymaster can at most cause the network (only the p2p network, not the blockchain) to process `BAN_SLACK * MIN_INCLUSION_RATE_DENOMINATOR / 24` non-paying ops per hour. -### RPC methods +## Rationale + +The main challenge with a purely smart contract wallet based account abstraction system is DoS safety: how can a block builder including an operation make sure that it will actually pay fees, without having to first execute the entire operation? Requiring the block builder to execute the entire operation opens a DoS attack vector, as an attacker could easily send many operations that pretend to pay a fee but then revert at the last moment after a long execution. Similarly, to prevent attackers from cheaply clogging the mempool, nodes in the P2P network need to check if an operation will pay a fee before they are willing to forward it. + +In this proposal, we expect accounts to have a `validateUserOp` method that takes as input a `UserOperation`, and verify the signature and pay the fee. This method is required to be almost-pure: it is only allowed to access the storage of the account itself, cannot use environment opcodes (eg. `TIMESTAMP`), and can only edit the storage of the account, and can also send out ETH (needed to pay the entry point). The method is gas-limited by the `verificationGasLimit` of the `UserOperation`; nodes can choose to reject operations whose `verificationGasLimit` is too high. These restrictions allow block builders and network nodes to simulate the verification step locally, and be confident that the result will match the result when the operation actually gets included into a block. + +The entry point-based approach allows for a clean separation between verification and execution, and keeps accounts' logic simple. The alternative would be to require accounts to follow a template where they first self-call to verify and then self-call to execute (so that the execution is sandboxed and cannot cause the fee payment to revert); template-based approaches were rejected due to being harder to implement, as existing code compilation and verification tooling is not designed around template verification. + +### Paymasters + +Paymasters facilitate transaction sponsorship, allowing third-party-designed mechanisms to pay for transactions. Many of these mechanisms _could_ be done by having the paymaster wrap a `UserOperation` with their own, but there are some important fundamental limitations to that approach: + +* No possibility for "passive" paymasters (eg. that accept fees in some EIP-20 token at an exchange rate pulled from an on-chain DEX) +* Paymasters run the risk of getting griefed, as users could send ops that appear to pay the paymaster but then change their behavior after a block + +The paymaster scheme allows a contract to passively pay on users' behalf under arbitrary conditions. It even allows EIP-20 token paymasters to secure a guarantee that they would only need to pay if the user pays them: the paymaster contract can check that there is sufficient approved EIP-20 balance in the `validatePaymasterUserOp` method, and then extract it with `transferFrom` in the `postOp` call; if the op itself transfers out or de-approves too much of the EIP-20s, the inner `postOp` will fail and revert the execution and the outer `postOp` can extract payment (note that because of storage access restrictions the EIP-20 would need to be a wrapper defined within the paymaster itself). + +### First-time account creation + +It is an important design goal of this proposal to replicate the key property of EOAs that users do not need to perform some custom action or rely on an existing user to create their wallet; they can simply generate an address locally and immediately start accepting funds. + +The wallet creation itself is done by a "factory" contract, with wallet-specific data. +The factory is expected to use CREATE2 (not CREATE) to create the wallet, so that the order of creation of wallets doesn't interfere with the generated addresses. +The `initCode` field (if non-zero length) is parsed as a 20-byte address, followed by "calldata" to pass to this address. +This method call is expected to create a wallet and return its address. +If the factory does use CREATE2 or some other deterministic method to create the wallet, it's expected to return the wallet address even if the wallet has already been created. This is to make it easier for clients to query the address without knowing if the wallet has already been deployed, by simulating a call to `entryPoint.getSenderAddress()`, which calls the factory under the hood. +When `initCode` is specified, if either the `sender` address points to an existing contract, or (after calling the initCode) the `sender` address still does not exist, +then the operation is aborted. +The `initCode` MUST NOT be called directly from the entryPoint, but from another address. +The contract created by this factory method should accept a call to `validateUserOp` to validate the UserOp's signature. +For security reasons, it is important that the generated contract address will depend on the initial signature. +This way, even if someone can create a wallet at that address, he can't set different credentials to control it. +The factory has to be staked if it accesses global storage - see [reputation, throttling and banning section](#reputation-scoring-and-throttlingbanning-for-global-entities) for details. + +NOTE: In order for the wallet to determine the "counterfactual" address of the wallet (prior its creation), +it should make a static call to the `entryPoint.getSenderAddress()` + +### Entry point upgrading + +Accounts are encouraged to be DELEGATECALL forwarding contracts for gas efficiency and to allow account upgradability. The account code is expected to hard-code the entry point into their code for gas efficiency. If a new entry point is introduced, whether to add new functionality, improve gas efficiency, or fix a critical security bug, users can self-call to replace their account's code address with a new code address containing code that points to a new entry point. During an upgrade process, it's expected that two mempools will run in parallel. -`eth_sendUserOperation` +### RPC methods (eth namespace) -eth_sendUserOperation submits a User Operation object to the User Operation pool of the client. An entryPoint address `MUST` be specified, and the client `MUST` only simulate and submit the User Operation through the specified entryPoint. +#### * eth_sendUserOperation -The result `SHOULD` be set to true if and only if the request passed simulation and was accepted in the client's User Operation pool. If the validation, simulation, or User Operation pool inclusion fails, `result` `SHOULD NOT` be returned. Rather, the client `SHOULD` return the failure reason. +eth_sendUserOperation submits a User Operation object to the User Operation pool of the client. The client MUST validate the UserOperation, and return a result accordingly. + +The result `SHOULD` be set to the **userOpHash** if and only if the request passed simulation and was accepted in the client's User Operation pool. If the validation, simulation, or User Operation pool inclusion fails, `result` `SHOULD NOT` be returned. Rather, the client `SHOULD` return the failure reason. + +##### Parameters: + +1. **UserOperation** a full user-operation struct. All fields MUST be set as hex values. empty `bytes` block (e.g. empty `initCode`) MUST be set to `"0x"` +2. **EntryPoint** the entrypoint address the request should be sent through. this MUST be one of the entry points returned by the `supportedEntryPoints` rpc call. + +##### Return value: + +* If the UserOperation is valid, the client MUST return the calculated **userOpHash** for it +* in case of failure, MUST return an `error` result object, with `code` and `message`. The error code and message SHOULD be set as follows: + * **code: -32602** - invalid UserOperation struct/fields + * **code: -32500** - transaction rejected by entryPoint's simulateValidation, during wallet creation or validation + * The `message` field MUST be set to the FailedOp's "`AAxx`" error message from the EntryPoint + * **code: -32501** - transaction rejected by paymaster's validatePaymasterUserOp + * The `message` field SHOULD be set to the revert message from the paymaster + * The `data` field MUST contain a `paymaster` value + * **code: -32502** - transaction rejected because of opcode validation + * **code: -32503** - UserOperation out of time-range: either wallet or paymaster returned a time-range, and it is already expired (or will expire soon) + * The `data` field SHOULD contain the `validUntil` and `validAfter` values + * The `data` field SHOULD contain a `paymaster` value, if this error was triggered by the paymaster + * **code: -32504** - transaction rejected because paymaster (or signature aggregator) is throttled/banned + * The `data` field SHOULD contain a `paymaster` or `aggregator` value, depending on the failed entity + * **code: -32505** - transaction rejected because paymaster (or signature aggregator) stake or unstake-delay is too low + * The `data` field SHOULD contain a `paymaster` or `aggregator` value, depending on the failed entity + * The `data` field SHOULD contain a `minimumStake` and `minimumUnstakeDelay` + * **code: -32506** - transaction rejected because wallet specified unsupported signature aggregator + * The `data` field SHOULD contain an `aggregator` value + * **code: -32507** - transaction rejected because of wallet signature check failed (or paymaster signature, if the paymaster uses its data as signature) + +##### Example: + +Request: ```json= -# Request { "jsonrpc": "2.0", "id": 1, @@ -433,17 +548,110 @@ The result `SHOULD` be set to true if and only if the request passed simulation ] } -# Response +``` + +Response: + +``` +{ + "jsonrpc": "2.0", + "id": 1, + "result": "0x1234...5678" +} +``` + +##### Example failure responses: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "message": "AA21 didn't pay prefund", + "code": -32500 + } +} +``` + +```json { "jsonrpc": "2.0", "id": 1, - "result": true + "error": { + "message": "paymaster stake too low", + "data": { + "paymaster": "0x123456789012345678901234567890123456790", + "minimumStake": "0xde0b6b3a7640000", + "minimumUnstakeDelay": "0x15180" + }, + "code": -32504 + } } ``` -`eth_supportedEntryPoints` -eth_supportedEntryPoints returns an array of the entryPoint addresses supported by the client. The first element of the array `SHOULD` be the entryPoint addressed preferred by the client. +#### * eth_estimateUserOperationGas + +Estimate the gas values for a UserOperation. +Given UserOperation optionally without gas limits and gas prices, return the needed gas limits. +The signature field is ignored by the wallet, so that the operation will not require user's approval. +Still, it might require putting a "semi-valid" signature (e.g. a signature in the right length) + +**Parameters**: same as `eth_sendUserOperation` + gas limits (and prices) parameters are optional, but are used if specified. + `maxFeePerGas` and `maxPriorityFeePerGas` default to zero, so no payment is required by neither account nor paymaster. + +**Return Values:** + +* **preVerificationGas** gas overhead of this UserOperation +* **verificationGasLimit** actual gas used by the validation of this UserOperation +* **callGasLimit** value used by inner account execution + +##### Error Codes: + +Same as `eth_sendUserOperation` +This operation may also return an error if the inner call to the account contract reverts. + +#### * eth_getUserOperationByHash + +Return a UserOperation based on a hash (userOpHash) returned by `eth_sendUserOperation` + +**Parameters** + +* **hash** a userOpHash value returned by `eth_sendUserOperation` + +**Return value**: + +`null` in case the UserOperation is not yet included in a block, or a full UserOperation, with the addition of `entryPoint`, `blockNumber`, `blockHash` and `transactionHash` + +#### * eth_getUserOperationReceipt + +Return a UserOperation receipt based on a hash (userOpHash) returned by `eth_sendUserOperation` + +**Parameters** + +* **hash** a userOpHash value returned by `eth_sendUserOperation` + +**Return value**: + +`null` in case the UserOperation is not yet included in a block, or: + +* **userOpHash** the request hash +* **entryPoint** +* **sender** +* **nonce** +* **paymaster** the paymaster used for this userOp (or empty) +* **actualGasCost** - actual amount paid (by account or paymaster) for this UserOperation +* **actualGasUsed** - total gas used by this UserOperation (including preVerification, creation, validation and execution) +* **success** boolean - did this execution completed without revert +* **reason** in case of revert, this is the revert reason +* **logs** the logs generated by this UserOperation (not including logs of other UserOperations in the same bundle) +* **receipt** the TransactionReceipt object. + Note that the returned TransactionReceipt is for the entire bundle, not only for this UserOperation. + +#### * eth_supportedEntryPoints + +Returns an array of the entryPoint addresses supported by the client. The first element of the array `SHOULD` be the entryPoint addressed preferred by the client. ```json= # Request @@ -465,57 +673,240 @@ eth_supportedEntryPoints returns an array of the entryPoint addresses supported } ``` -## Rationale +#### * eth_chainId -The main challenge with a purely smart contract wallet based account abstraction system is DoS safety: how can a miner including an operation make sure that it will actually pay fees, without having to first execute the entire operation? Requiring the miner to execute the entire operation opens a DoS attack vector, as an attacker could easily send many operations that pretend to pay a fee but then revert at the last moment after a long execution. Similarly, to prevent attackers from cheaply clogging the mempool, nodes in the P2P network need to check if an operation will pay a fee before they are willing to forward it. +Returns [EIP-155](./eip-155.md) Chain ID. -In this proposal, we expect accounts to have a `validateUserOp` method that takes as input a `UserOperation`, and verify the signature and pay the fee. This method is required to be almost-pure: it is only allowed to access the storage of the account itself, cannot use environment opcodes (eg. `TIMESTAMP`), and can only edit the storage of the account, and can also send out ETH (needed to pay the entry point). The method is gas-limited by the `verificationGasLimit` of the `UserOperation`; nodes can choose to reject operations whose `verificationGasLimit` is too high. These restrictions allow miners and network nodes to simulate the verification step locally, and be confident that the result will match the result when the operation actually gets included into a block. +```json= +# Request +{ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_chainId", + "params": [] +} -The entry point-based approach allows for a clean separation between verification and execution, and keeps accounts' logic simple. The alternative would be to require accounts to follow a template where they first self-call to verify and then self-call to execute (so that the execution is sandboxed and cannot cause the fee payment to revert); template-based approaches were rejected due to being harder to implement, as existing code compilation and verification tooling is not designed around template verification. +# Response +{ + "jsonrpc": "2.0", + "id": 1, + "result": "0x1" +} +``` -### Paymasters +### RPC methods (debug Namespace) -Paymasters facilitate transaction sponsorship, allowing third-party-designed mechanisms to pay for transactions. Many of these mechanisms _could_ be done by having the paymaster wrap a `UserOperation` with their own, but there are some important fundamental limitations to that approach: +This api must only be available on testing mode and is required by the compatibility test suite. In production, any `debug_*` rpc calls should be blocked. -* No possibility for "passive" paymasters (eg. that accept fees in some ERC-20 token at an exchange rate pulled from an on-chain DEX) -* Paymasters run the risk of getting griefed, as users could send ops that appear to pay the paymaster but then change their behavior after a block +#### * debug_bundler_clearState -The paymaster scheme allows a contract to passively pay on users' behalf under arbitrary conditions. It even allows ERC-20 token paymasters to secure a guarantee that they would only need to pay if the user pays them: the paymaster contract can check that there is sufficient approved ERC-20 balance in the `validatePaymasterUserOp` method, and then extract it with `transferFrom` in the `postOp` call; if the op itself transfers out or de-approves too much of the ERC-20s, the inner `postOp` will fail and revert the execution and the outer `postOp` can extract payment (note that because of storage access restrictions the ERC-20 would need to be a wrapper defined within the paymaster itself). +Clears the bundler mempool and reputation data of paymasters/accounts/factories/aggregators. -### First-time account creation +```json= +# Request +{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_bundler_clearState", + "params": [] +} -It is an important design goal of this proposal to replicate the key property of EOAs that users do not need to perform some custom action or rely on an existing user to create their account; they can simply generate an address locally and immediately start accepting funds. +# Response +{ + "jsonrpc": "2.0", + "id": 1, + "result": "ok" +} +``` -The account creation itself is done by a "factory" contract, with account-specific data. -The factory is expected to use CREATE2 (not CREATE) to create the account, so that the order of creation of accounts doesn't interfere with the generated addresses. -The `initCode` field (if non-zero length) is parsed as a 20-byte address, followed by "calldata" to pass to this address. -This method call is expected to create an account and return its address. -If the factory does use CREATE2 or some other deterministic method to create the account, it's expected to return the account address even if the account has already been created. This is to make it easier for clients to query the address without knowing if the account has already been deployed, by simulating a call to `entryPoint.getSenderAddress()`, which calls the factory under the hood. -When `initCode` is specified, if either the `sender` address points to an existing contract, or (after calling the initCode) the `sender` address still does not exist, -then the operation is aborted. -The `initCode` MUST NOT be called directly from the entryPoint, but from another address. -The contract created by this factory method should accept a call to `validateUserOp` to validate the UserOp's signature. -For security reasons, it is important that the generated contract address will depend on the initial signature. -This way, even if someone can create an account at that address, he can't set different credentials to control it. +#### * debug_bundler_dumpMempool -NOTE: In order for the wallet to determine the "counterfactual" address of the account (prior its creation), -it should make a static call to the `entryPoint.getSenderAddress()` +Dumps the current UserOperations mempool -### Entry point upgrading +**Parameters:** -Accounts are encouraged to be DELEGATECALL forwarding contracts for gas efficiency and to allow account upgradability. The account code is expected to hard-code the entry point into their code for gas efficiency. If a new entry point is introduced, whether to add new functionality, improve gas efficiency, or fix a critical security bug, users can self-call to replace their account's code address with a new code address containing code that points to a new entry point. During an upgrade process, it's expected that two mempools will run in parallel. +* **EntryPoint** the entrypoint used by eth_sendUserOperation + +**Returns:** + +`array` - Array of UserOperations currently in the mempool. + +```json= +# Request +{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_bundler_dumpMempool", + "params": ["0x1306b01bC3e4AD202612D3843387e94737673F53"] +} + +# Response +{ + "jsonrpc": "2.0", + "id": 1, + "result": [ + { + sender, // address + nonce, // uint256 + initCode, // bytes + callData, // bytes + callGasLimit, // uint256 + verificationGasLimit, // uint256 + preVerificationGas, // uint256 + maxFeePerGas, // uint256 + maxPriorityFeePerGas, // uint256 + paymasterAndData, // bytes + signature // bytes + } + ] +} +``` + +#### * debug_bundler_sendBundleNow + +Forces the bundler to build and execute a bundle from the mempool as `handleOps()` transaction. + +Returns: `transactionHash` + +```json= +# Request +{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_bundler_sendBundleNow", + "params": [] +} + +# Response +{ + "jsonrpc": "2.0", + "id": 1, + "result": "0xdead9e43632ac70c46b4003434058b18db0ad809617bd29f3448d46ca9085576" +} +``` + +#### * debug_bundler_setBundlingMode + +Sets bundling mode. + +After setting mode to "manual", an explicit call to debug_bundler_sendBundleNow is required to send a bundle. + +##### parameters: + +`mode` - 'manual' | 'auto' + +```json= +# Request +{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_bundler_setBundlingMode", + "params": ["manual"] +} + +# Response +{ + "jsonrpc": "2.0", + "id": 1, + "result": "ok" +} +``` + +#### * debug_bundler_setReputation + +Sets reputation of given addresses. parameters: + +**Parameters:** + +* An array of reputation entries to add/replace, with the fields: + + * `address` - The address to set the reputation for. + * `opsSeen` - number of times a user operations with that entity was seen and added to the mempool + * `opsIncluded` - number of times a user operations that uses this entity was included on-chain + * `status` - (string) The status of the address in the bundler 'ok' | 'throttled' | 'banned'. + +* **EntryPoint** the entrypoint used by eth_sendUserOperation + +```json= +# Request +{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_bundler_setReputation", + "params": [ + [ + { + "address": "0x7A0A0d159218E6a2f407B99173A2b12A6DDfC2a6", + "opsSeen": 20, + "opsIncluded": 13 + } + ], + "0x1306b01bC3e4AD202612D3843387e94737673F53" + ] +} + +# Response +{ + "jsonrpc": "2.0", + "id": 1, + "result": "ok" +} +``` + + +#### * debug_bundler_dumpReputation + +Returns the reputation data of all observed addresses. +Returns an array of reputation objects, each with the fields described above in `debug_bundler_setReputation` with the + + +**Parameters:** + +* **EntryPoint** the entrypoint used by eth_sendUserOperation + +**Return value:** + +An array of reputation entries with the fields: + +* `address` - The address to set the reputation for. +* `opsSeen` - number of times a user operations with that entity was seen and added to the mempool +* `opsIncluded` - number of times a user operations that uses this entity was included on-chain +* `status` - (string) The status of the address in the bundler 'ok' | 'throttled' | 'banned'. + +```json= +# Request +{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_bundler_dumpReputation", + "params": ["0x1306b01bC3e4AD202612D3843387e94737673F53"] +} + +# Response +{ + "jsonrpc": "2.0", + "id": 1, + "result": [ + { "address": "0x7A0A0d159218E6a2f407B99173A2b12A6DDfC2a6", + "opsSeen": 20, + "opsIncluded": 19, + "status": "ok" + } + ] +} +``` ## Backwards Compatibility -This ERC does not change the consensus layer, so there are no backwards compatibility issues for Ethereum as a whole. Unfortunately it is not easily compatible with pre-ERC-4337 accounts, because those accounts do not have a `validateUserOp` function. If the account has a function for authorizing a trusted op submitter, then this could be fixed by creating an ERC-4337-compatible account that re-implements the verification logic as a wrapper and setting it to be the original account's trusted op submitter. +This EIP does not change the consensus layer, so there are no backwards compatibility issues for Ethereum as a whole. Unfortunately it is not easily compatible with pre-[EIP-4337](./eip-4337.md) accounts, because those accounts do not have a `validateUserOp` function. If the account has a function for authorizing a trusted op submitter, then this could be fixed by creating an [EIP-4337](./eip-4337.md) compatible account that re-implements the verification logic as a wrapper and setting it to be the original account's trusted op submitter. ## Reference Implementation -See [https://github.com/eth-infinitism/account-abstraction/tree/main/contracts](https://github.com/eth-infinitism/account-abstraction/tree/main/contracts) +See `https://github.com/eth-infinitism/account-abstraction/tree/main/contracts` -## Security considerations +## Security Considerations -The entry point contract will need to be very heavily audited and formally verified, because it will serve as a central trust point for _all_ ERC 4337 accounts. In total, this architecture reduces auditing and formal verification load for the ecosystem, because the amount of work that individual _accounts_ have to do becomes much smaller (they need only verify the `validateUserOp` function and its "check signature, increment nonce and pay fees" logic) and check that other functions are `msg.sender == ENTRY_POINT` gated (perhaps also allowing `msg.sender == self`), but it is nevertheless the case that this is done precisely by concentrating security risk in the entry point contract that needs to be verified to be very robust. +The entry point contract will need to be very heavily audited and formally verified, because it will serve as a central trust point for _all_ [EIP-4337](./eip-4337.md). In total, this architecture reduces auditing and formal verification load for the ecosystem, because the amount of work that individual _accounts_ have to do becomes much smaller (they need only verify the `validateUserOp` function and its "check signature, increment nonce and pay fees" logic) and check that other functions are `msg.sender == ENTRY_POINT` gated (perhaps also allowing `msg.sender == self`), but it is nevertheless the case that this is done precisely by concentrating security risk in the entry point contract that needs to be verified to be very robust. Verification would need to cover two primary claims (not including claims needed to protect paymasters, and claims needed to establish p2p-level DoS resistance): @@ -523,4 +914,5 @@ Verification would need to cover two primary claims (not including claims needed * **Safety against fee draining**: If the entry point calls `validateUserOp` and passes, it also must make the generic call with calldata equal to `op.calldata` ## Copyright -Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/packages/boba/account-abstraction/gascalc/2-paymaster.gas.ts b/packages/boba/account-abstraction/gascalc/2-paymaster.gas.ts index 335b7b865d..89a5d3dc9f 100644 --- a/packages/boba/account-abstraction/gascalc/2-paymaster.gas.ts +++ b/packages/boba/account-abstraction/gascalc/2-paymaster.gas.ts @@ -14,7 +14,7 @@ context('Minimal Paymaster', function () { let paymasterAddress: string before(async () => { const paymasterInit = hexValue(new TestPaymasterAcceptAll__factory(ethersSigner).getDeployTransaction(g.entryPoint().address).data!) - const paymasterAddress = await new Create2Factory(ethers.provider, ethersSigner).deploy(paymasterInit, 0) + paymasterAddress = await new Create2Factory(ethers.provider, ethersSigner).deploy(paymasterInit, 0) const paymaster = TestPaymasterAcceptAll__factory.connect(paymasterAddress, ethersSigner) await paymaster.addStake(1, { value: 1 }) await g.entryPoint().depositTo(paymaster.address, { value: parseEther('10') }) diff --git a/packages/boba/account-abstraction/gascalc/GasChecker.ts b/packages/boba/account-abstraction/gascalc/GasChecker.ts index 1c74f4065e..dc32b751ad 100644 --- a/packages/boba/account-abstraction/gascalc/GasChecker.ts +++ b/packages/boba/account-abstraction/gascalc/GasChecker.ts @@ -1,6 +1,6 @@ // calculate gas usage of different bundle sizes import '../test/aa.init' -import { formatEther, parseEther } from 'ethers/lib/utils' +import { defaultAbiCoder, formatEther, hexConcat, parseEther } from 'ethers/lib/utils' import { AddressZero, checkForGeth, @@ -8,14 +8,16 @@ import { createAccountOwner, deployEntryPoint } from '../test/testutils' -import { EntryPoint, EntryPoint__factory, SimpleAccount__factory } from '../typechain' +import { + EntryPoint, EntryPoint__factory, SimpleAccountFactory, + SimpleAccountFactory__factory, SimpleAccount__factory +} from '../typechain' import { BigNumberish, Wallet } from 'ethers' import hre from 'hardhat' import { fillAndSign } from '../test/UserOp' import { TransactionReceipt } from '@ethersproject/abstract-provider' import { table, TableUserConfig } from 'table' import { Create2Factory } from '../src/Create2Factory' -import { hexValue } from '@ethersproject/bytes' import * as fs from 'fs' import { SimpleAccountInterface } from '../typechain/contracts/samples/SimpleAccount' @@ -98,12 +100,15 @@ export class GasChecker { // generate the "exec" calldata for this account accountExec (dest: string, value: BigNumberish, data: string): string { - return this.accountInterface.encodeFunctionData('execFromEntryPoint', [dest, value, data]) + return this.accountInterface.encodeFunctionData('execute', [dest, value, data]) } // generate the account "creation code" - accountInitCode (): string { - return hexValue(new SimpleAccount__factory(ethersSigner).getDeployTransaction(GasCheckCollector.inst.entryPoint.address, this.accountOwner.address).data!) + accountInitCode (factory: SimpleAccountFactory, salt: BigNumberish): string { + return hexConcat([ + factory.address, + factory.interface.encodeFunctionData('createAccount', [this.accountOwner.address, salt]) + ]) } /** @@ -113,17 +118,23 @@ export class GasChecker { * @param count */ async createAccounts1 (count: number): Promise { - const fact = new Create2Factory(provider) + const create2Factory = new Create2Factory(this.entryPoint().provider) + const factoryAddress = await create2Factory.deploy( + hexConcat([ + SimpleAccountFactory__factory.bytecode, + defaultAbiCoder.encode(['address'], [this.entryPoint().address]) + ]), 0, 2708636) + console.log('factaddr', factoryAddress) + const fact = SimpleAccountFactory__factory.connect(factoryAddress, ethersSigner) // create accounts for (const n of range(count)) { const salt = n - const initCode = this.accountInitCode() + // const initCode = this.accountInitCode(fact, salt) - const addr = fact.getDeployedAddress(initCode, salt) + const addr = await fact.getAddress(this.accountOwner.address, salt) this.accounts[addr] = this.accountOwner - // deploy if not already deployed. - await fact.deploy(initCode, salt, 2e6) + await fact.createAccount(this.accountOwner.address, salt) const accountBalance = await GasCheckCollector.inst.entryPoint.balanceOf(addr) if (accountBalance.lte(minDepositOrBalance)) { await GasCheckCollector.inst.entryPoint.depositTo(addr, { value: minDepositOrBalance.mul(5) }) diff --git a/packages/boba/account-abstraction/reports/gas-checker.txt b/packages/boba/account-abstraction/reports/gas-checker.txt index ad028eb682..b01ac4e819 100644 --- a/packages/boba/account-abstraction/reports/gas-checker.txt +++ b/packages/boba/account-abstraction/reports/gas-checker.txt @@ -1,35 +1,35 @@ == gas estimate of direct calling the account's "execFromEntryPoint" method the destination is "account.nonce()", which is known to be "hot" address used by this account it little higher than EOA call: its an exec from entrypoint (or account owner) into account contract, verifying msg.sender and exec to target) -- gas estimate "simple" - 27860 -- gas estimate "big tx 5k" - 110612 +- gas estimate "simple" - 31033 +- gas estimate "big tx 5k" - 127284 ╔════════════════════════════════╤═══════╤═══════════════╤════════════════╤═════════════════════╗ ║ handleOps description │ count │ total gasUsed │ per UserOp gas │ per UserOp overhead ║ ║ │ │ │ (delta for │ (compared to ║ ║ │ │ │ one UserOp) │ account.exec()) ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple │ 1 │ 71731 │ │ ║ +║ simple │ 1 │ 75948 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple - diff from previous │ 2 │ │ 39754 │ 11894 ║ +║ simple - diff from previous │ 2 │ │ 41449 │ 10416 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple │ 10 │ 429490 │ │ ║ +║ simple │ 10 │ 449053 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple - diff from previous │ 11 │ │ 39812 │ 11952 ║ +║ simple - diff from previous │ 11 │ │ 41560 │ 10527 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster │ 1 │ 71769 │ │ ║ +║ simple paymaster │ 1 │ 82245 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster with diff │ 2 │ │ 39690 │ 11830 ║ +║ simple paymaster with diff │ 2 │ │ 40408 │ 9375 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster │ 10 │ 429528 │ │ ║ +║ simple paymaster │ 10 │ 446306 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster with diff │ 11 │ │ 39848 │ 11988 ║ +║ simple paymaster with diff │ 11 │ │ 40604 │ 9571 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx 5k │ 1 │ 159164 │ │ ║ +║ big tx 5k │ 1 │ 177693 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx - diff from previous │ 2 │ │ 126729 │ 16117 ║ +║ big tx - diff from previous │ 2 │ │ 142772 │ 15488 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx 5k │ 10 │ 1305534 │ │ ║ +║ big tx 5k │ 10 │ 1468115 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx - diff from previous │ 11 │ │ 128298 │ 17686 ║ +║ big tx - diff from previous │ 11 │ │ 144290 │ 17006 ║ ╚════════════════════════════════╧═══════╧═══════════════╧════════════════╧═════════════════════╝ diff --git a/packages/boba/account-abstraction/scripts/check-gas-reports b/packages/boba/account-abstraction/scripts/check-gas-reports index d0af47d1b9..38fa810fe0 100755 --- a/packages/boba/account-abstraction/scripts/check-gas-reports +++ b/packages/boba/account-abstraction/scripts/check-gas-reports @@ -11,7 +11,7 @@ if [ "$?" == 1 ]; then #diff with no error - ok exit else - echo ERROR: found above unchecked reports. + echo ERROR: found above unchecked reports. exit 1 fi diff --git a/packages/boba/account-abstraction/src/AASigner.ts b/packages/boba/account-abstraction/src/AASigner.ts index b8780a3040..8cee2fc4ff 100644 --- a/packages/boba/account-abstraction/src/AASigner.ts +++ b/packages/boba/account-abstraction/src/AASigner.ts @@ -1,7 +1,14 @@ -import { BigNumber, Bytes, ethers, Signer, Event } from 'ethers' +import { BigNumber, Bytes, ethers, Event, Signer } from 'ethers' +import { zeroAddress } from 'ethereumjs-util' import { BaseProvider, Provider, TransactionRequest } from '@ethersproject/providers' import { Deferrable, resolveProperties } from '@ethersproject/properties' -import { SimpleAccount, SimpleAccount__factory, EntryPoint, EntryPoint__factory } from '../typechain' +import { + EntryPoint, + EntryPoint__factory, + ERC1967Proxy__factory, + SimpleAccount, + SimpleAccount__factory +} from '../typechain' import { BytesLike, hexValue } from '@ethersproject/bytes' import { TransactionResponse } from '@ethersproject/abstract-provider' import { fillAndSign, getUserOpHash } from '../test/UserOp' @@ -9,7 +16,7 @@ import { UserOperation } from '../test/UserOperation' import { TransactionReceipt } from '@ethersproject/abstract-provider/src.ts/index' import { clearInterval } from 'timers' import { Create2Factory } from './Create2Factory' -import { getCreate2Address, hexConcat, keccak256 } from 'ethers/lib/utils' +import { getCreate2Address, hexConcat, Interface, keccak256 } from 'ethers/lib/utils' import { HashZero } from '../test/testutils' export type SendUserOp = (userOp: UserOperation) => Promise @@ -231,10 +238,12 @@ export class AASigner extends Signer { return getCreate2Address(Create2Factory.contractAddress, HashZero, keccak256(await this._deploymentTransaction())) } + // TODO TODO: THERE IS UTILS.getAccountInitCode - why not use that? async _deploymentTransaction (): Promise { + const implementationAddress = zeroAddress() // TODO: pass implementation in here const ownerAddress = await this.signer.getAddress() - return new SimpleAccount__factory(this.signer) - .getDeployTransaction(this.entryPoint.address, ownerAddress).data! + const initializeCall = new Interface(SimpleAccount__factory.abi).encodeFunctionData('initialize', [ownerAddress]) + return new ERC1967Proxy__factory(this.signer).getDeployTransaction(implementationAddress, initializeCall).data! } async getAddress (): Promise { @@ -378,7 +387,7 @@ export class AASigner extends Signer { initCallData ]) } - const execFromEntryPoint = await this._account!.populateTransaction.execFromEntryPoint(tx.to!, tx.value ?? 0, tx.data!) + const execFromEntryPoint = await this._account!.populateTransaction.execute(tx.to!, tx.value ?? 0, tx.data!) let { gasPrice, maxPriorityFeePerGas, maxFeePerGas } = tx // gasPrice is legacy, and overrides eip1559 values: diff --git a/packages/boba/account-abstraction/src/Create2Factory.ts b/packages/boba/account-abstraction/src/Create2Factory.ts index 574c1056b3..b0e78cf433 100644 --- a/packages/boba/account-abstraction/src/Create2Factory.ts +++ b/packages/boba/account-abstraction/src/Create2Factory.ts @@ -1,5 +1,5 @@ -// from https://eips.ethereum.org/EIPS/eip-2470 -import { BigNumber, BigNumberish, Contract, ethers, Signer } from 'ethers' +// from: https://github.com/Arachnid/deterministic-deployment-proxy +import { BigNumber, BigNumberish, ethers, Signer } from 'ethers' import { arrayify, hexConcat, hexlify, hexZeroPad, keccak256 } from 'ethers/lib/utils' import { Provider } from '@ethersproject/providers' import { TransactionRequest } from '@ethersproject/abstract-provider' @@ -7,19 +7,21 @@ import { TransactionRequest } from '@ethersproject/abstract-provider' export class Create2Factory { factoryDeployed = false - static readonly contractAddress = '0xce0042B868300000d44A59004Da54A005ffdcf9f' - static readonly factoryDeployer = '0xBb6e024b9cFFACB947A71991E386681B1Cd1477D' - static readonly factoryTx = '0xf9016c8085174876e8008303c4d88080b90154608060405234801561001057600080fd5b50610134806100206000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c80634af63f0214602d575b600080fd5b60cf60048036036040811015604157600080fd5b810190602081018135640100000000811115605b57600080fd5b820183602082011115606c57600080fd5b80359060200191846001830284011164010000000083111715608d57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250929550509135925060eb915050565b604080516001600160a01b039092168252519081900360200190f35b6000818351602085016000f5939250505056fea26469706673582212206b44f8a82cb6b156bfcc3dc6aadd6df4eefd204bc928a4397fd15dacf6d5320564736f6c634300060200331b83247000822470' - static readonly factoryTxHash = '0x803351deb6d745e91545a6a3e1c0ea3e9a6a02a1a4193b70edfcd2f40f71a01c' - static readonly factoryDeploymentFee = (0.0247 * 1e18).toString() + // from: https://github.com/Arachnid/deterministic-deployment-proxy + static readonly contractAddress = '0x4e59b44847b379578588920ca78fbf26c0b4956c' + static readonly factoryTx = '0xf8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222' + static readonly factoryDeployer = '0x3fab184622dc19b6109349b94811493bf2a45362' + static readonly deploymentGasPrice = 100e9 + static readonly deploymentGasLimit = 100000 + static readonly factoryDeploymentFee = (Create2Factory.deploymentGasPrice * Create2Factory.deploymentGasLimit).toString() constructor (readonly provider: Provider, readonly signer = (provider as ethers.providers.JsonRpcProvider).getSigner()) { } /** - * deploy a contract using our EIP-2470 deployer. - * The delpoyer is deployed (unless it is already deployed) + * deploy a contract using our deterministic deployer. + * The deployer is deployed (unless it is already deployed) * NOTE: this transaction will fail if already deployed. use getDeployedAddress to check it first. * @param initCode delpoyment code. can be a hex string or factory.getDeploymentTransaction(..) * @param salt specific salt for deployment @@ -32,15 +34,17 @@ export class Create2Factory { initCode = (initCode as TransactionRequest).data!.toString() } - const addr = this.getDeployedAddress(initCode, salt) + const addr = Create2Factory.getDeployedAddress(initCode, salt) if (await this.provider.getCode(addr).then(code => code.length) > 2) { return addr } - const factory = new Contract(Create2Factory.contractAddress, ['function deploy(bytes _initCode, bytes32 _salt) returns(address)'], this.signer) - const saltBytes32 = hexZeroPad(hexlify(salt), 32) + const deployTx = { + to: Create2Factory.contractAddress, + data: this.getDeployTransactionCallData(initCode, salt) + } if (gasLimit === 'estimate') { - gasLimit = await factory.estimateGas.deploy(initCode, saltBytes32) + gasLimit = await this.signer.estimateGas(deployTx) } // manual estimation (its bit larger: we don't know actual deployed code size) @@ -57,7 +61,7 @@ export class Create2Factory { gasLimit = Math.floor(gasLimit * 64 / 63) } - const ret = await factory.deploy(initCode, saltBytes32, { gasLimit }) + const ret = await this.signer.sendTransaction({ ...deployTx, gasLimit }) await ret.wait() if (await this.provider.getCode(addr).then(code => code.length) === 2) { throw new Error('failed to deploy') @@ -66,9 +70,11 @@ export class Create2Factory { } getDeployTransactionCallData (initCode: string, salt: BigNumberish = 0): string { - const factory = new Contract(Create2Factory.contractAddress, ['function deploy(bytes _initCode, bytes32 _salt) returns(address)']) const saltBytes32 = hexZeroPad(hexlify(salt), 32) - return factory.interface.encodeFunctionData('deploy', [initCode, saltBytes32]) + return hexConcat([ + saltBytes32, + initCode + ]) } /** @@ -77,7 +83,7 @@ export class Create2Factory { * @param initCode * @param salt */ - getDeployedAddress (initCode: string, salt: BigNumberish): string { + static getDeployedAddress (initCode: string, salt: BigNumberish): string { const saltBytes32 = hexZeroPad(hexlify(salt), 32) return '0x' + keccak256(hexConcat([ '0xff', @@ -87,8 +93,7 @@ export class Create2Factory { ])).slice(-40) } - // deploy the EIP2470 factory, if not already deployed. - // (note that it requires to have a "signer" with 0.0247 eth, to fund the deployer's deployment + // deploy the factory, if not already deployed. async deployFactory (signer?: Signer): Promise { if (await this._isFactoryDeployed()) { return @@ -99,7 +104,7 @@ export class Create2Factory { }) await this.provider.sendTransaction(Create2Factory.factoryTx) if (!await this._isFactoryDeployed()) { - throw new Error('fatal: failed to deploy Eip2470factory') + throw new Error('fatal: failed to deploy deterministic deployer') } } diff --git a/packages/boba/account-abstraction/src/runop.ts b/packages/boba/account-abstraction/src/runop.ts index 39467fc997..cef53d0d2f 100644 --- a/packages/boba/account-abstraction/src/runop.ts +++ b/packages/boba/account-abstraction/src/runop.ts @@ -117,10 +117,9 @@ import { Create2Factory } from './Create2Factory' const ev = await entryPoint.queryFilter(entryPoint.filters.UserOperationEvent(), block) // if (ev.length === 0) return {} return ev.map(event => { - const { nonce, actualGasCost, actualGasPrice } = event.args - const gasPaid = actualGasCost.div(actualGasPrice).toNumber() + const { nonce, actualGasUsed } = event.args const gasUsed = rcpt.gasUsed.toNumber() - return { nonce: nonce.toNumber(), gasPaid, gasUsed: gasUsed, diff: gasUsed - gasPaid } + return { nonce: nonce.toNumber(), gasPaid, gasUsed: gasUsed, diff: gasUsed - actualGasUsed.toNumber() } }) } })() diff --git a/packages/boba/account-abstraction/test/UserOp.ts b/packages/boba/account-abstraction/test/UserOp.ts index 0ce689d25f..0872d477f5 100644 --- a/packages/boba/account-abstraction/test/UserOp.ts +++ b/packages/boba/account-abstraction/test/UserOp.ts @@ -1,12 +1,11 @@ import { arrayify, defaultAbiCoder, - getCreate2Address, hexDataSlice, keccak256 } from 'ethers/lib/utils' import { BigNumber, Contract, Signer, Wallet } from 'ethers' -import { AddressZero, callDataCost, HashZero, rethrow } from './testutils' +import { AddressZero, callDataCost, rethrow } from './testutils' import { ecsign, toRpcSig, keccak256 as keccak256_buffer } from 'ethereumjs-util' import { EntryPoint @@ -175,10 +174,11 @@ export async function fillUserOp (op: Partial, entryPoint?: Entry const initCallData = hexDataSlice(op1.initCode!, 20) if (op1.nonce == null) op1.nonce = 0 if (op1.sender == null) { - // hack: if the init contract is our deployer, then we know what the address would be, without a view call + // hack: if the init contract is our known deployer, then we know what the address would be, without a view call if (initAddr.toLowerCase() === Create2Factory.contractAddress.toLowerCase()) { - const [ctr] = defaultAbiCoder.decode(['bytes', 'bytes32'], '0x' + initCallData.slice(10)) - op1.sender = getCreate2Address(initAddr, HashZero, keccak256(ctr)) + const ctr = hexDataSlice(initCallData, 32) + const salt = hexDataSlice(initCallData, 0, 32) + op1.sender = Create2Factory.getDeployedAddress(ctr, salt) } else { // console.log('\t== not our deployer. our=', Create2Factory.contractAddress, 'got', initAddr) if (provider == null) throw new Error('no entrypoint/provider') diff --git a/packages/boba/account-abstraction/test/create2factory.test.ts b/packages/boba/account-abstraction/test/create2factory.test.ts index 98ce4e6c18..c67bae7cf3 100644 --- a/packages/boba/account-abstraction/test/create2factory.test.ts +++ b/packages/boba/account-abstraction/test/create2factory.test.ts @@ -15,12 +15,12 @@ describe('test Create2Factory', () => { expect(await factory._isFactoryDeployed()).to.equal(false, 'factory exists before test deploy') await factory.deployFactory() expect(await factory._isFactoryDeployed()).to.equal(true, 'factory failed to deploy') - }).timeout(100000) + }) it('should deploy to known address', async () => { const initCode = TestToken__factory.bytecode - const addr = factory.getDeployedAddress(initCode, 0) + const addr = Create2Factory.getDeployedAddress(initCode, 0) expect(await provider.getCode(addr).then(code => code.length)).to.equal(2) await factory.deploy(initCode, 0) @@ -29,7 +29,7 @@ describe('test Create2Factory', () => { it('should deploy to different address based on salt', async () => { const initCode = TestToken__factory.bytecode - const addr = factory.getDeployedAddress(initCode, 123) + const addr = Create2Factory.getDeployedAddress(initCode, 123) expect(await provider.getCode(addr).then(code => code.length)).to.equal(2) await factory.deploy(initCode, 123) diff --git a/packages/boba/account-abstraction/test/deposit-paymaster-boba.test.ts b/packages/boba/account-abstraction/test/deposit-paymaster-boba.test.ts index b5c22bc3a8..343d390364 100644 --- a/packages/boba/account-abstraction/test/deposit-paymaster-boba.test.ts +++ b/packages/boba/account-abstraction/test/deposit-paymaster-boba.test.ts @@ -3,7 +3,6 @@ import { ethers } from 'hardhat' import { expect } from 'chai' import { SimpleAccount, - SimpleAccount__factory, EntryPoint, BobaDepositPaymaster, BobaDepositPaymaster__factory, @@ -19,7 +18,7 @@ import { import { AddressZero, createAddress, createAccountOwner, - deployEntryPoint, FIVE_ETH, ONE_ETH, simulationResultCatch, userOpsWithoutAgg + deployEntryPoint, FIVE_ETH, ONE_ETH, simulationResultCatch, userOpsWithoutAgg, createAccount } from './testutils' import { fillAndSign } from './UserOp' import { hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils' @@ -70,7 +69,7 @@ describe('BobaDepositPaymaster', () => { let account: SimpleAccount before(async () => { - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, await ethersSigner.getAddress()) + ({ proxy: account } = await createAccount(ethersSigner, await ethersSigner.getAddress(), entryPoint.address)) }) it('should deposit and read balance', async () => { await paymaster.addDepositFor(token.address, account.address, 100) @@ -84,7 +83,7 @@ describe('BobaDepositPaymaster', () => { const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(token.address, AddressZero, 1).then(tx => tx.data!) await expect( - account.exec(paymaster.address, 0, paymasterWithdraw) + account.execute(paymaster.address, 0, paymasterWithdraw) ).to.revertedWith('DepositPaymaster: must unlockTokenDeposit') }) it('should fail to withdraw within the same block ', async () => { @@ -92,15 +91,15 @@ describe('BobaDepositPaymaster', () => { const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(token.address, AddressZero, 1).then(tx => tx.data!) await expect( - account.execBatch([paymaster.address, paymaster.address], [paymasterUnlock, paymasterWithdraw]) + account.executeBatch([paymaster.address, paymaster.address], [paymasterUnlock, paymasterWithdraw]) ).to.be.revertedWith('DepositPaymaster: must unlockTokenDeposit') }) it('should succeed to withdraw after unlock', async () => { const paymasterUnlock = await paymaster.populateTransaction.unlockTokenDeposit().then(tx => tx.data!) const target = createAddress() const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(token.address, target, 1).then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterUnlock) - await account.exec(paymaster.address, 0, paymasterWithdraw) + await account.execute(paymaster.address, 0, paymasterUnlock) + await account.execute(paymaster.address, 0, paymasterWithdraw) expect(await token.balanceOf(target)).to.eq(1) }) }) @@ -108,11 +107,9 @@ describe('BobaDepositPaymaster', () => { describe('#validatePaymasterUserOp', () => { let account: SimpleAccount const gasPrice = 1e9 - let accountOwner: string before(async () => { - accountOwner = await ethersSigner.getAddress() - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner) + ({ proxy: account } = await createAccount(ethersSigner, await ethersSigner.getAddress(), entryPoint.address)) }) it('should fail if no token', async () => { @@ -143,7 +140,7 @@ describe('BobaDepositPaymaster', () => { await paymaster.addDepositFor(token.address, account.address, ONE_ETH) const paymasterUnlock = await paymaster.populateTransaction.unlockTokenDeposit().then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterUnlock) + await account.execute(paymaster.address, 0, paymasterUnlock) const userOp = await fillAndSign({ sender: account.address, @@ -155,7 +152,7 @@ describe('BobaDepositPaymaster', () => { it('succeed with valid deposit', async () => { // needed only if previous test did unlock. const paymasterLockTokenDeposit = await paymaster.populateTransaction.lockTokenDeposit().then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterLockTokenDeposit) + await account.execute(paymaster.address, 0, paymasterLockTokenDeposit) const userOp = await fillAndSign({ sender: account.address, @@ -171,10 +168,10 @@ describe('BobaDepositPaymaster', () => { let counter: TestCounter let callData: string before(async () => { - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner.address) + ({ proxy: account } = await createAccount(ethersSigner, await accountOwner.getAddress(), entryPoint.address)) counter = await new TestCounter__factory(ethersSigner).deploy() const counterJustEmit = await counter.populateTransaction.justemit().then(tx => tx.data!) - callData = await account.populateTransaction.execFromEntryPoint(counter.address, 0, counterJustEmit).then(tx => tx.data!) + callData = await account.populateTransaction.execute(counter.address, 0, counterJustEmit).then(tx => tx.data!) await paymaster.addDepositFor(token.address, account.address, ONE_ETH) }) @@ -202,7 +199,7 @@ describe('BobaDepositPaymaster', () => { // need to "approve" the paymaster to use the tokens. we issue a UserOp for that (which uses the deposit to execute) const tokenApprovePaymaster = await token.populateTransaction.approve(paymaster.address, ethers.constants.MaxUint256).then(tx => tx.data!) - const execApprove = await account.populateTransaction.execFromEntryPoint(token.address, 0, tokenApprovePaymaster).then(tx => tx.data!) + const execApprove = await account.populateTransaction.execute(token.address, 0, tokenApprovePaymaster).then(tx => tx.data!) const userOp1 = await fillAndSign({ sender: account.address, paymasterAndData: hexConcat([paymaster.address, hexZeroPad(token.address, 20)]), diff --git a/packages/boba/account-abstraction/test/deposit-paymaster-gpo.test.ts b/packages/boba/account-abstraction/test/deposit-paymaster-gpo.test.ts index 26e777fbac..f8fd1f2ef3 100644 --- a/packages/boba/account-abstraction/test/deposit-paymaster-gpo.test.ts +++ b/packages/boba/account-abstraction/test/deposit-paymaster-gpo.test.ts @@ -3,7 +3,6 @@ import { ethers } from 'hardhat' import { expect } from 'chai' import { SimpleAccount, - SimpleAccount__factory, EntryPoint, GPODepositPaymaster, GPODepositPaymaster__factory, @@ -19,7 +18,7 @@ import { import { AddressZero, createAddress, createAccountOwner, - deployEntryPoint, FIVE_ETH, ONE_ETH, simulationResultCatch, userOpsWithoutAgg + deployEntryPoint, FIVE_ETH, ONE_ETH, simulationResultCatch, userOpsWithoutAgg, createAccount } from './testutils' import { fillAndSign } from './UserOp' import { parseEther } from 'ethers/lib/utils' @@ -51,7 +50,7 @@ describe('GPODepositPaymaster', () => { let account: SimpleAccount before(async () => { - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, await ethersSigner.getAddress()) + ({ proxy: account } = await createAccount(ethersSigner, await ethersSigner.getAddress(), entryPoint.address)) }) it('should deposit and read balance', async () => { await paymaster.addDepositFor(account.address, 100) @@ -61,7 +60,7 @@ describe('GPODepositPaymaster', () => { const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(AddressZero, 1).then(tx => tx.data!) await expect( - account.exec(paymaster.address, 0, paymasterWithdraw) + account.execute(paymaster.address, 0, paymasterWithdraw) ).to.revertedWith('DepositPaymaster: must unlockTokenDeposit') }) it('should fail to withdraw within the same block ', async () => { @@ -69,15 +68,15 @@ describe('GPODepositPaymaster', () => { const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(AddressZero, 1).then(tx => tx.data!) await expect( - account.execBatch([paymaster.address, paymaster.address], [paymasterUnlock, paymasterWithdraw]) + account.executeBatch([paymaster.address, paymaster.address], [paymasterUnlock, paymasterWithdraw]) ).to.be.revertedWith('DepositPaymaster: must unlockTokenDeposit') }) it('should succeed to withdraw after unlock', async () => { const paymasterUnlock = await paymaster.populateTransaction.unlockTokenDeposit().then(tx => tx.data!) const target = createAddress() const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(target, 1).then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterUnlock) - await account.exec(paymaster.address, 0, paymasterWithdraw) + await account.execute(paymaster.address, 0, paymasterUnlock) + await account.execute(paymaster.address, 0, paymasterWithdraw) expect(await token.balanceOf(target)).to.eq(1) }) }) @@ -85,11 +84,9 @@ describe('GPODepositPaymaster', () => { describe('#validatePaymasterUserOp', () => { let account: SimpleAccount const gasPrice = 1e9 - let accountOwner: string before(async () => { - accountOwner = await ethersSigner.getAddress() - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner) + ({ proxy: account } = await createAccount(ethersSigner, await ethersSigner.getAddress(), entryPoint.address)) }) it('should reject if no deposit', async () => { @@ -104,7 +101,7 @@ describe('GPODepositPaymaster', () => { await paymaster.addDepositFor(account.address, ONE_ETH) const paymasterUnlock = await paymaster.populateTransaction.unlockTokenDeposit().then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterUnlock) + await account.execute(paymaster.address, 0, paymasterUnlock) const userOp = await fillAndSign({ sender: account.address, @@ -116,7 +113,7 @@ describe('GPODepositPaymaster', () => { it('succeed with valid deposit', async () => { // needed only if previous test did unlock. const paymasterLockTokenDeposit = await paymaster.populateTransaction.lockTokenDeposit().then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterLockTokenDeposit) + await account.execute(paymaster.address, 0, paymasterLockTokenDeposit) const userOp = await fillAndSign({ sender: account.address, @@ -131,10 +128,10 @@ describe('GPODepositPaymaster', () => { let counter: TestCounter let callData: string before(async () => { - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner.address) + ({ proxy: account } = await createAccount(ethersSigner, await accountOwner.getAddress(), entryPoint.address)) counter = await new TestCounter__factory(ethersSigner).deploy() const counterJustEmit = await counter.populateTransaction.justemit().then(tx => tx.data!) - callData = await account.populateTransaction.execFromEntryPoint(counter.address, 0, counterJustEmit).then(tx => tx.data!) + callData = await account.populateTransaction.execute(counter.address, 0, counterJustEmit).then(tx => tx.data!) await paymaster.addDepositFor(account.address, ONE_ETH) }) @@ -162,7 +159,7 @@ describe('GPODepositPaymaster', () => { // need to "approve" the paymaster to use the tokens. we issue a UserOp for that (which uses the deposit to execute) const tokenApprovePaymaster = await token.populateTransaction.approve(paymaster.address, ethers.constants.MaxUint256).then(tx => tx.data!) - const execApprove = await account.populateTransaction.execFromEntryPoint(token.address, 0, tokenApprovePaymaster).then(tx => tx.data!) + const execApprove = await account.populateTransaction.execute(token.address, 0, tokenApprovePaymaster).then(tx => tx.data!) const userOp1 = await fillAndSign({ sender: account.address, paymasterAndData: paymaster.address, diff --git a/packages/boba/account-abstraction/test/deposit-paymaster-manual.test.ts b/packages/boba/account-abstraction/test/deposit-paymaster-manual.test.ts index 66c09a1f86..221955b5a6 100644 --- a/packages/boba/account-abstraction/test/deposit-paymaster-manual.test.ts +++ b/packages/boba/account-abstraction/test/deposit-paymaster-manual.test.ts @@ -3,7 +3,6 @@ import { ethers } from 'hardhat' import { expect } from 'chai' import { SimpleAccount, - SimpleAccount__factory, EntryPoint, ManualDepositPaymaster, ManualDepositPaymaster__factory, @@ -17,7 +16,7 @@ import { import { AddressZero, createAddress, createAccountOwner, - deployEntryPoint, FIVE_ETH, ONE_ETH, simulationResultCatch, userOpsWithoutAgg + deployEntryPoint, FIVE_ETH, ONE_ETH, simulationResultCatch, userOpsWithoutAgg, createAccount } from './testutils' import { fillAndSign } from './UserOp' import { hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils' @@ -66,7 +65,7 @@ describe('ManualDepositPaymaster', () => { let account: SimpleAccount before(async () => { - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, await ethersSigner.getAddress()) + ({ proxy: account } = await createAccount(ethersSigner, await ethersSigner.getAddress(), entryPoint.address)) }) it('should deposit and read balance', async () => { await paymaster.addDepositFor(token.address, account.address, 100) @@ -82,7 +81,7 @@ describe('ManualDepositPaymaster', () => { const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(token.address, AddressZero, 1).then(tx => tx.data!) await expect( - account.exec(paymaster.address, 0, paymasterWithdraw) + account.execute(paymaster.address, 0, paymasterWithdraw) ).to.revertedWith('DepositPaymaster: must unlockTokenDeposit') }) it('should fail to withdraw within the same block ', async () => { @@ -90,15 +89,15 @@ describe('ManualDepositPaymaster', () => { const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(token.address, AddressZero, 1).then(tx => tx.data!) await expect( - account.execBatch([paymaster.address, paymaster.address], [paymasterUnlock, paymasterWithdraw]) + account.executeBatch([paymaster.address, paymaster.address], [paymasterUnlock, paymasterWithdraw]) ).to.be.revertedWith('DepositPaymaster: must unlockTokenDeposit') }) it('should succeed to withdraw after unlock', async () => { const paymasterUnlock = await paymaster.populateTransaction.unlockTokenDeposit().then(tx => tx.data!) const target = createAddress() const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(token.address, target, 1).then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterUnlock) - await account.exec(paymaster.address, 0, paymasterWithdraw) + await account.execute(paymaster.address, 0, paymasterUnlock) + await account.execute(paymaster.address, 0, paymasterWithdraw) expect(await token.balanceOf(target)).to.eq(1) }) }) @@ -106,11 +105,9 @@ describe('ManualDepositPaymaster', () => { describe('#validatePaymasterUserOp', () => { let account: SimpleAccount const gasPrice = 1e9 - let accountOwner: string before(async () => { - accountOwner = await ethersSigner.getAddress() - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner) + ({ proxy: account } = await createAccount(ethersSigner, await ethersSigner.getAddress(), entryPoint.address)) }) it('should fail if no token', async () => { @@ -141,7 +138,7 @@ describe('ManualDepositPaymaster', () => { await paymaster.addDepositFor(token.address, account.address, ONE_ETH) const paymasterUnlock = await paymaster.populateTransaction.unlockTokenDeposit().then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterUnlock) + await account.execute(paymaster.address, 0, paymasterUnlock) const userOp = await fillAndSign({ sender: account.address, @@ -153,7 +150,7 @@ describe('ManualDepositPaymaster', () => { it('succeed with valid deposit', async () => { // needed only if previous test did unlock. const paymasterLockTokenDeposit = await paymaster.populateTransaction.lockTokenDeposit().then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterLockTokenDeposit) + await account.execute(paymaster.address, 0, paymasterLockTokenDeposit) const userOp = await fillAndSign({ sender: account.address, @@ -168,10 +165,10 @@ describe('ManualDepositPaymaster', () => { let counter: TestCounter let callData: string before(async () => { - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner.address) + ({ proxy: account } = await createAccount(ethersSigner, await accountOwner.getAddress(), entryPoint.address)) counter = await new TestCounter__factory(ethersSigner).deploy() const counterJustEmit = await counter.populateTransaction.justemit().then(tx => tx.data!) - callData = await account.populateTransaction.execFromEntryPoint(counter.address, 0, counterJustEmit).then(tx => tx.data!) + callData = await account.populateTransaction.execute(counter.address, 0, counterJustEmit).then(tx => tx.data!) await paymaster.addDepositFor(token.address, account.address, ONE_ETH) }) @@ -199,7 +196,7 @@ describe('ManualDepositPaymaster', () => { // need to "approve" the paymaster to use the tokens. we issue a UserOp for that (which uses the deposit to execute) const tokenApprovePaymaster = await token.populateTransaction.approve(paymaster.address, ethers.constants.MaxUint256).then(tx => tx.data!) - const execApprove = await account.populateTransaction.execFromEntryPoint(token.address, 0, tokenApprovePaymaster).then(tx => tx.data!) + const execApprove = await account.populateTransaction.execute(token.address, 0, tokenApprovePaymaster).then(tx => tx.data!) const userOp1 = await fillAndSign({ sender: account.address, paymasterAndData: hexConcat([paymaster.address, hexZeroPad(token.address, 20)]), @@ -227,20 +224,20 @@ describe('ManualDepositPaymaster', () => { let account: SimpleAccount before(async () => { - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, await ethersSigner.getAddress()) + ({ proxy: account } = await createAccount(ethersSigner, await ethersSigner.getAddress(), entryPoint.address)) }) it('should not allow non owner to update priceRatio', async () => { const updatePriceRatio = await paymaster.populateTransaction.updatePriceRatio(token.address, 60).then(tx => tx.data!) await expect( - account.exec(paymaster.address, 0, updatePriceRatio) + account.execute(paymaster.address, 0, updatePriceRatio) ).to.revertedWith('Ownable: caller is not the owner') }) it('should not allow non owner to update token params', async () => { const updateParams = await paymaster.populateTransaction.updateTokenParams(token.address, priceRatioDecimals, 10, 1000).then(tx => tx.data!) await expect( - account.exec(paymaster.address, 0, updateParams) + account.execute(paymaster.address, 0, updateParams) ).to.revertedWith('Ownable: caller is not the owner') }) it('should allow only the owner to update token params and priceRatio', async () => { diff --git a/packages/boba/account-abstraction/test/deposit-paymaster.test.ts b/packages/boba/account-abstraction/test/deposit-paymaster.test.ts index 2c5fc6032c..9aaa48e9d1 100644 --- a/packages/boba/account-abstraction/test/deposit-paymaster.test.ts +++ b/packages/boba/account-abstraction/test/deposit-paymaster.test.ts @@ -3,7 +3,6 @@ import { ethers } from 'hardhat' import { expect } from 'chai' import { SimpleAccount, - SimpleAccount__factory, EntryPoint, DepositPaymaster, DepositPaymaster__factory, @@ -16,7 +15,7 @@ import { import { AddressZero, createAddress, createAccountOwner, - deployEntryPoint, FIVE_ETH, ONE_ETH, simulationResultCatch, userOpsWithoutAgg + deployEntryPoint, FIVE_ETH, ONE_ETH, simulationResultCatch, userOpsWithoutAgg, createAccount } from './testutils' import { fillAndSign } from './UserOp' import { hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils' @@ -45,7 +44,7 @@ describe('DepositPaymaster', () => { let account: SimpleAccount before(async () => { - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, await ethersSigner.getAddress()) + ({ proxy: account } = await createAccount(ethersSigner, await ethersSigner.getAddress(), entryPoint.address)) }) it('should deposit and read balance', async () => { await paymaster.addDepositFor(token.address, account.address, 100) @@ -55,7 +54,7 @@ describe('DepositPaymaster', () => { const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(token.address, AddressZero, 1).then(tx => tx.data!) await expect( - account.exec(paymaster.address, 0, paymasterWithdraw) + account.execute(paymaster.address, 0, paymasterWithdraw) ).to.revertedWith('DepositPaymaster: must unlockTokenDeposit') }) it('should fail to withdraw within the same block ', async () => { @@ -63,15 +62,15 @@ describe('DepositPaymaster', () => { const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(token.address, AddressZero, 1).then(tx => tx.data!) await expect( - account.execBatch([paymaster.address, paymaster.address], [paymasterUnlock, paymasterWithdraw]) + account.executeBatch([paymaster.address, paymaster.address], [paymasterUnlock, paymasterWithdraw]) ).to.be.revertedWith('DepositPaymaster: must unlockTokenDeposit') }) it('should succeed to withdraw after unlock', async () => { const paymasterUnlock = await paymaster.populateTransaction.unlockTokenDeposit().then(tx => tx.data!) const target = createAddress() const paymasterWithdraw = await paymaster.populateTransaction.withdrawTokensTo(token.address, target, 1).then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterUnlock) - await account.exec(paymaster.address, 0, paymasterWithdraw) + await account.execute(paymaster.address, 0, paymasterUnlock) + await account.execute(paymaster.address, 0, paymasterWithdraw) expect(await token.balanceOf(target)).to.eq(1) }) }) @@ -79,11 +78,9 @@ describe('DepositPaymaster', () => { describe('#validatePaymasterUserOp', () => { let account: SimpleAccount const gasPrice = 1e9 - let accountOwner: string before(async () => { - accountOwner = await ethersSigner.getAddress() - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner) + ({ proxy: account } = await createAccount(ethersSigner, await ethersSigner.getAddress(), entryPoint.address)) }) it('should fail if no token', async () => { @@ -114,7 +111,7 @@ describe('DepositPaymaster', () => { await paymaster.addDepositFor(token.address, account.address, ONE_ETH) const paymasterUnlock = await paymaster.populateTransaction.unlockTokenDeposit().then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterUnlock) + await account.execute(paymaster.address, 0, paymasterUnlock) const userOp = await fillAndSign({ sender: account.address, @@ -126,7 +123,7 @@ describe('DepositPaymaster', () => { it('succeed with valid deposit', async () => { // needed only if previous test did unlock. const paymasterLockTokenDeposit = await paymaster.populateTransaction.lockTokenDeposit().then(tx => tx.data!) - await account.exec(paymaster.address, 0, paymasterLockTokenDeposit) + await account.execute(paymaster.address, 0, paymasterLockTokenDeposit) const userOp = await fillAndSign({ sender: account.address, @@ -141,10 +138,10 @@ describe('DepositPaymaster', () => { let counter: TestCounter let callData: string before(async () => { - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner.address) + ({ proxy: account } = await createAccount(ethersSigner, await accountOwner.getAddress(), entryPoint.address)) counter = await new TestCounter__factory(ethersSigner).deploy() const counterJustEmit = await counter.populateTransaction.justemit().then(tx => tx.data!) - callData = await account.populateTransaction.execFromEntryPoint(counter.address, 0, counterJustEmit).then(tx => tx.data!) + callData = await account.populateTransaction.execute(counter.address, 0, counterJustEmit).then(tx => tx.data!) await paymaster.addDepositFor(token.address, account.address, ONE_ETH) }) @@ -172,7 +169,7 @@ describe('DepositPaymaster', () => { // need to "approve" the paymaster to use the tokens. we issue a UserOp for that (which uses the deposit to execute) const tokenApprovePaymaster = await token.populateTransaction.approve(paymaster.address, ethers.constants.MaxUint256).then(tx => tx.data!) - const execApprove = await account.populateTransaction.execFromEntryPoint(token.address, 0, tokenApprovePaymaster).then(tx => tx.data!) + const execApprove = await account.populateTransaction.execute(token.address, 0, tokenApprovePaymaster).then(tx => tx.data!) const userOp1 = await fillAndSign({ sender: account.address, paymasterAndData: hexConcat([paymaster.address, hexZeroPad(token.address, 20)]), diff --git a/packages/boba/account-abstraction/test/deterministicDeployer.test.ts b/packages/boba/account-abstraction/test/deterministicDeployer.test.ts index cb7af613de..b09aeff91c 100644 --- a/packages/boba/account-abstraction/test/deterministicDeployer.test.ts +++ b/packages/boba/account-abstraction/test/deterministicDeployer.test.ts @@ -7,13 +7,9 @@ import { TestCounter__factory } from '../typechain/factories/contracts/test/Test const deployer = new DeterministicDeployer(ethers.provider) describe('#deterministicDeployer', () => { - it('deploy deployer', async () => { - expect(await deployer.isDeployerDeployed()).to.equal(false) - await deployer.deployDeployer() - expect(await deployer.isDeployerDeployed()).to.equal(true) - }) it('should ignore deploy again of deployer', async () => { await deployer.deployDeployer() + expect(await deployer.isDeployerDeployed()).to.equal(true) }) it('should deploy at given address', async () => { const testCounter = await new TestCounter__factory( diff --git a/packages/boba/account-abstraction/test/entrypoint.test.ts b/packages/boba/account-abstraction/test/entrypoint.test.ts index a392d5551d..d4502b44a6 100644 --- a/packages/boba/account-abstraction/test/entrypoint.test.ts +++ b/packages/boba/account-abstraction/test/entrypoint.test.ts @@ -1,10 +1,12 @@ import './aa.init' -import { BigNumber, Wallet } from 'ethers' +import { BigNumber, Event, Wallet } from 'ethers' import { expect } from 'chai' import { EntryPoint, SimpleAccount, - SimpleAccount__factory, + SimpleAccountFactory, + TestAggregatedAccount__factory, + TestAggregatedAccountFactory__factory, TestCounter, TestCounter__factory, TestExpirePaymaster, @@ -12,7 +14,13 @@ import { TestExpiryAccount, TestExpiryAccount__factory, TestPaymasterAcceptAll, - TestPaymasterAcceptAll__factory + TestPaymasterAcceptAll__factory, + TestRevertAccount__factory, + TestAggregatedAccount, + TestSignatureAggregator, + TestSignatureAggregator__factory, + MaliciousAccount__factory, + TestWarmColdAccount__factory } from '../typechain' import { AddressZero, @@ -21,7 +29,7 @@ import { checkForGeth, rethrow, tostr, - getAccountDeployer, + getAccountInitCode, calcGasUsage, checkForBannedOps, ONE_ETH, @@ -31,26 +39,23 @@ import { createAddress, getAccountAddress, HashZero, - getAggregatedAccountDeployer, simulationResultCatch, + createAccount, + getAggregatedAccountInitCode, simulationResultWithAggregationCatch } from './testutils' -import { fillAndSign, getUserOpHash } from './UserOp' +import { DefaultsForUserOp, fillAndSign, getUserOpHash } from './UserOp' import { UserOperation } from './UserOperation' import { PopulatedTransaction } from 'ethers/lib/ethers' import { ethers } from 'hardhat' -import { defaultAbiCoder, hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils' +import { arrayify, defaultAbiCoder, hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils' import { debugTransaction } from './debugTx' import { BytesLike } from '@ethersproject/bytes' -import { TestSignatureAggregator } from '../typechain/contracts/samples/TestSignatureAggregator' -import { TestAggregatedAccount } from '../typechain/contracts/samples/TestAggregatedAccount' -import { - TestSignatureAggregator__factory -} from '../typechain/factories/contracts/samples/TestSignatureAggregator__factory' -import { TestAggregatedAccount__factory } from '../typechain/factories/contracts/samples/TestAggregatedAccount__factory' +import { toChecksumAddress } from 'ethereumjs-util' describe('EntryPoint', function () { let entryPoint: EntryPoint + let simpleAccountFactory: SimpleAccountFactory let accountOwner: Wallet const ethersSigner = ethers.provider.getSigner() @@ -60,14 +65,18 @@ describe('EntryPoint', function () { const paymasterStake = ethers.utils.parseEther('2') before(async function () { + this.timeout(20000) await checkForGeth() const chainId = await ethers.provider.getNetwork().then(net => net.chainId) entryPoint = await deployEntryPoint() - accountOwner = createAccountOwner() - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, await accountOwner.getAddress()) + accountOwner = createAccountOwner(); + ({ + proxy: account, + accountFactory: simpleAccountFactory + } = await createAccount(ethersSigner, await accountOwner.getAddress(), entryPoint.address)) await fund(account) // sanity: validate helper functions @@ -196,11 +205,9 @@ describe('EntryPoint', function () { }) }) describe('with deposit', () => { - let owner: string let account: SimpleAccount before(async () => { - owner = await ethersSigner.getAddress() - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, owner) + ({ proxy: account } = await createAccount(ethersSigner, await ethersSigner.getAddress(), entryPoint.address, simpleAccountFactory)) await account.addDeposit({ value: ONE_ETH }) expect(await getBalance(account.address)).to.equal(0) expect(await account.getDeposit()).to.eql(ONE_ETH) @@ -219,14 +226,39 @@ describe('EntryPoint', function () { let account1: SimpleAccount before(async () => { - account1 = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, await accountOwner1.getAddress()) + ({ proxy: account1 } = await createAccount(ethersSigner, await accountOwner1.getAddress(), entryPoint.address)) }) it('should fail if validateUserOp fails', async () => { + // using wrong nonce + const op = await fillAndSign({ sender: account.address, nonce: 1234 }, accountOwner, entryPoint) + await expect(entryPoint.callStatic.simulateValidation(op)).to + .revertedWith('AA23 reverted: account: invalid nonce') + }) + + it('should report signature failure without revert', async () => { + // (this is actually a feature of the wallet, not the entrypoint) // using wrong owner for account1 - const op = await fillAndSign({ sender: account1.address }, accountOwner, entryPoint) - await expect(entryPoint.callStatic.simulateValidation(op).catch(rethrow())).to - .revertedWith('wrong signature') + // (zero gas price so it doesn't fail on prefund) + const op = await fillAndSign({ sender: account1.address, maxFeePerGas: 0 }, accountOwner, entryPoint) + const { returnInfo } = await entryPoint.callStatic.simulateValidation(op).catch(simulationResultCatch) + expect(returnInfo.sigFailed).to.be.true + }) + + it('should revert if wallet not deployed (and no initcode)', async () => { + const op = await fillAndSign({ + sender: createAddress(), + nonce: 0, + verificationGasLimit: 1000 + }, accountOwner, entryPoint) + await expect(entryPoint.callStatic.simulateValidation(op)).to + .revertedWith('AA20 account not deployed') + }) + + it('should revert on oog if not enough verificationGas', async () => { + const op = await fillAndSign({ sender: account.address, verificationGasLimit: 1000 }, accountOwner, entryPoint) + await expect(entryPoint.callStatic.simulateValidation(op)).to + .revertedWith('AA23 reverted (or OOG)') }) it('should succeed if validateUserOp succeeds', async () => { @@ -235,6 +267,23 @@ describe('EntryPoint', function () { await entryPoint.callStatic.simulateValidation(op).catch(simulationResultCatch) }) + it('should return empty context if no paymaster', async () => { + const op = await fillAndSign({ sender: account1.address, maxFeePerGas: 0 }, accountOwner1, entryPoint) + const { returnInfo } = await entryPoint.callStatic.simulateValidation(op).catch(simulationResultCatch) + expect(returnInfo.paymasterContext).to.eql('0x') + }) + + it('should return stake of sender', async () => { + const stakeValue = BigNumber.from(123) + const unstakeDelay = 3 + const { proxy: account2 } = await createAccount(ethersSigner, await ethersSigner.getAddress(), entryPoint.address) + await fund(account2) + await account2.execute(entryPoint.address, stakeValue, entryPoint.interface.encodeFunctionData('addStake', [unstakeDelay])) + const op = await fillAndSign({ sender: account2.address }, ethersSigner, entryPoint) + const result = await entryPoint.callStatic.simulateValidation(op).catch(simulationResultCatch) + expect(result.senderInfo).to.eql({ stake: stakeValue, unstakeDelaySec: unstakeDelay }) + }) + it('should prevent overflows: fail if any numeric value is more than 120 bits', async () => { const op = await fillAndSign({ preVerificationGas: BigNumber.from(2).pow(130), @@ -247,19 +296,42 @@ describe('EntryPoint', function () { it('should fail creation for wrong sender', async () => { const op1 = await fillAndSign({ - initCode: getAccountDeployer(entryPoint.address, accountOwner1.address), + initCode: getAccountInitCode(accountOwner1.address, simpleAccountFactory), sender: '0x'.padEnd(42, '1'), - verificationGasLimit: 1e6 + verificationGasLimit: 3e6 }, accountOwner1, entryPoint) - await expect(entryPoint.callStatic.simulateValidation(op1).catch(rethrow())) - .to.revertedWith('sender doesn\'t match initCode address') + await expect(entryPoint.callStatic.simulateValidation(op1)) + .to.revertedWith('AA14 initCode must return sender') + }) + + it('should report failure on insufficient verificationGas (OOG) for creation', async () => { + const initCode = getAccountInitCode(accountOwner1.address, simpleAccountFactory) + const sender = await entryPoint.callStatic.getSenderAddress(initCode).catch(e => e.errorArgs.sender) + const op0 = await fillAndSign({ + initCode, + sender, + verificationGasLimit: 5e5, + maxFeePerGas: 0 + }, accountOwner1, entryPoint) + // must succeed with enough verification gas. + await expect(entryPoint.callStatic.simulateValidation(op0, { gasLimit: 1e6 })) + .to.revertedWith('ValidationResult') + + const op1 = await fillAndSign({ + initCode, + sender, + verificationGasLimit: 1e5, + maxFeePerGas: 0 + }, accountOwner1, entryPoint) + await expect(entryPoint.callStatic.simulateValidation(op1, { gasLimit: 1e6 })) + .to.revertedWith('AA13 initCode failed or OOG') }) it('should succeed for creating an account', async () => { - const sender = getAccountAddress(entryPoint.address, accountOwner1.address) + const sender = await getAccountAddress(accountOwner1.address, simpleAccountFactory) const op1 = await fillAndSign({ sender, - initCode: getAccountDeployer(entryPoint.address, accountOwner1.address) + initCode: getAccountInitCode(accountOwner1.address, simpleAccountFactory) }, accountOwner1, entryPoint) await fund(op1.sender) @@ -268,23 +340,23 @@ describe('EntryPoint', function () { it('should not call initCode from entrypoint', async () => { // a possible attack: call an account's execFromEntryPoint through initCode. This might lead to stolen funds. - const account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner.address) + const { proxy: account } = await createAccount(ethersSigner, await accountOwner.getAddress(), entryPoint.address) const sender = createAddress() const op1 = await fillAndSign({ initCode: hexConcat([ account.address, - account.interface.encodeFunctionData('execFromEntryPoint', [sender, 0, '0x']) + account.interface.encodeFunctionData('execute', [sender, 0, '0x']) ]), sender }, accountOwner, entryPoint) const error = await entryPoint.callStatic.simulateValidation(op1).catch(e => e) - expect(error.message).to.match(/initCode failed/, error) + expect(error.message).to.match(/initCode failed or OOG/, error) }) it('should not use banned ops during simulateValidation', async () => { const op1 = await fillAndSign({ - initCode: getAccountDeployer(entryPoint.address, accountOwner1.address), - sender: getAccountAddress(entryPoint.address, accountOwner1.address) + initCode: getAccountInitCode(accountOwner1.address, simpleAccountFactory), + sender: await getAccountAddress(accountOwner1.address, simpleAccountFactory) }, accountOwner1, entryPoint) await fund(op1.sender) await entryPoint.simulateValidation(op1, { gasLimit: 10e6 }).catch(e => e) @@ -294,6 +366,150 @@ describe('EntryPoint', function () { }) }) + describe('#simulateHandleOp', () => { + it('should simulate execution', async () => { + const accountOwner1 = createAccountOwner() + const { proxy: account } = await createAccount(ethersSigner, await accountOwner.getAddress(), entryPoint.address) + await fund(account) + const counter = await new TestCounter__factory(ethersSigner).deploy() + + const count = counter.interface.encodeFunctionData('count') + const callData = account.interface.encodeFunctionData('execute', [counter.address, 0, count]) + // deliberately broken signature.. simulate should work with it too. + const userOp = await fillAndSign({ + sender: account.address, + callData + }, accountOwner1, entryPoint) + + const ret = await entryPoint.callStatic.simulateHandleOp(userOp, + counter.address, + counter.interface.encodeFunctionData('counters', [account.address]) + ).catch(e => e.errorArgs) + + const [countResult] = counter.interface.decodeFunctionResult('counters', ret.targetResult) + expect(countResult).to.eql(1) + expect(ret.targetSuccess).to.be.true + + // actual counter is zero + expect(await counter.counters(account.address)).to.eql(0) + }) + }) + + describe('flickering account validation', () => { + it('should prevent leakage of basefee', async () => { + const maliciousAccount = await new MaliciousAccount__factory(ethersSigner).deploy(entryPoint.address, + { value: parseEther('1') }) + + const snap = await ethers.provider.send('evm_snapshot', []) + await ethers.provider.send('evm_mine', []) + const block = await ethers.provider.getBlock('latest') + await ethers.provider.send('evm_revert', [snap]) + + if (block.baseFeePerGas == null) { + expect.fail(null, null, 'test error: no basefee') + } + + const userOp: UserOperation = { + sender: maliciousAccount.address, + nonce: block.baseFeePerGas, + initCode: '0x', + callData: '0x', + callGasLimit: '0x' + 1e5.toString(16), + verificationGasLimit: '0x' + 1e5.toString(16), + preVerificationGas: '0x' + 1e5.toString(16), + // we need maxFeeperGas > block.basefee + maxPriorityFeePerGas so requiredPrefund onchain is basefee + maxPriorityFeePerGas + maxFeePerGas: block.baseFeePerGas.mul(3), + maxPriorityFeePerGas: block.baseFeePerGas, + paymasterAndData: '0x', + signature: '0x' + } + try { + await expect(entryPoint.simulateValidation(userOp, { gasLimit: 1e6 })) + .to.revertedWith('ValidationResult') + console.log('after first simulation') + await ethers.provider.send('evm_mine', []) + await expect(entryPoint.simulateValidation(userOp, { gasLimit: 1e6 })) + .to.revertedWith('Revert after first validation') + // if we get here, it means the userOp passed first sim and reverted second + expect.fail(null, null, 'should fail on first simulation') + } catch (e: any) { + expect(e.message).to.include('Revert after first validation') + } + }) + + it('should limit revert reason length before emitting it', async () => { + const revertLength = 1e5 + const REVERT_REASON_MAX_LEN = 2048 + const testRevertAccount = await new TestRevertAccount__factory(ethersSigner).deploy(entryPoint.address, { value: parseEther('1') }) + const badData = await testRevertAccount.populateTransaction.revertLong(revertLength + 1) + const badOp: UserOperation = { + ...DefaultsForUserOp, + sender: testRevertAccount.address, + callGasLimit: 1e5, + maxFeePerGas: 1, + verificationGasLimit: 1e5, + callData: badData.data! + } + const beneficiaryAddress = createAddress() + await expect(entryPoint.simulateValidation(badOp, { gasLimit: 3e5 })) + .to.revertedWith('ValidationResult') + const tx = await entryPoint.handleOps([badOp], beneficiaryAddress, { gasLimit: 3e5 }) + const receipt = await tx.wait() + const userOperationRevertReasonEvent = receipt.events?.find(event => event.event === 'UserOperationRevertReason') + expect(userOperationRevertReasonEvent?.event).to.equal('UserOperationRevertReason') + const revertReason = Buffer.from(arrayify(userOperationRevertReasonEvent?.args?.revertReason)) + expect(revertReason.length).to.equal(REVERT_REASON_MAX_LEN) + }) + describe('warm/cold storage detection in simulation vs execution', () => { + const TOUCH_GET_AGGREGATOR = 1 + const TOUCH_PAYMASTER = 2 + it('should prevent detection through getAggregator()', async () => { + const testWarmColdAccount = await new TestWarmColdAccount__factory(ethersSigner).deploy(entryPoint.address, + { value: parseEther('1') }) + const badOp: UserOperation = { + ...DefaultsForUserOp, + nonce: TOUCH_GET_AGGREGATOR, + sender: testWarmColdAccount.address + } + const beneficiaryAddress = createAddress() + try { + await entryPoint.simulateValidation(badOp, { gasLimit: 1e6 }) + } catch (e: any) { + if ((e as Error).message.includes('ValidationResult')) { + const tx = await entryPoint.handleOps([badOp], beneficiaryAddress, { gasLimit: 1e6 }) + await tx.wait() + } else { + expect(e.message).to.include('FailedOp(0, "AA23 reverted (or OOG)")') + } + } + }) + + it('should prevent detection through paymaster.code.length', async () => { + const testWarmColdAccount = await new TestWarmColdAccount__factory(ethersSigner).deploy(entryPoint.address, + { value: parseEther('1') }) + const paymaster = await new TestPaymasterAcceptAll__factory(ethersSigner).deploy(entryPoint.address) + await paymaster.deposit({ value: ONE_ETH }) + const badOp: UserOperation = { + ...DefaultsForUserOp, + nonce: TOUCH_PAYMASTER, + paymasterAndData: paymaster.address, + sender: testWarmColdAccount.address + } + const beneficiaryAddress = createAddress() + try { + await entryPoint.simulateValidation(badOp, { gasLimit: 1e6 }) + } catch (e: any) { + if ((e as Error).message.includes('ValidationResult')) { + const tx = await entryPoint.handleOps([badOp], beneficiaryAddress, { gasLimit: 1e6 }) + await tx.wait() + } else { + expect(e.message).to.include('FailedOp(0, "AA23 reverted (or OOG)")') + } + } + }) + }) + }) + describe('without paymaster (account pays in eth)', () => { describe('#handleOps', () => { let counter: TestCounter @@ -301,7 +517,17 @@ describe('EntryPoint', function () { before(async () => { counter = await new TestCounter__factory(ethersSigner).deploy() const count = await counter.populateTransaction.count() - accountExecFromEntryPoint = await account.populateTransaction.execFromEntryPoint(counter.address, 0, count.data!) + accountExecFromEntryPoint = await account.populateTransaction.execute(counter.address, 0, count.data!) + }) + + it('should revert on signature failure', async () => { + // wallet-reported signature failure should revert in handleOps + const wrongOwner = createAccountOwner() + const op = await fillAndSign({ + sender: account.address + }, wrongOwner, entryPoint) + const beneficiaryAddress = createAddress() + await expect(entryPoint.estimateGas.handleOps([op], beneficiaryAddress)).to.revertedWith('AA24 signature error') }) it('account should pay for tx', async function () { @@ -330,6 +556,71 @@ describe('EntryPoint', function () { await calcGasUsage(rcpt, entryPoint, beneficiaryAddress) }) + it('account should pay for high gas usage tx', async function () { + if (process.env.COVERAGE != null) { + return + } + const iterations = 45 + const count = await counter.populateTransaction.gasWaster(iterations, '') + const accountExec = await account.populateTransaction.execute(counter.address, 0, count.data!) + const op = await fillAndSign({ + sender: account.address, + callData: accountExec.data, + verificationGasLimit: 1e5, + callGasLimit: 11e5 + }, accountOwner, entryPoint) + const beneficiaryAddress = createAddress() + const offsetBefore = await counter.offset() + console.log(' == offset before', offsetBefore) + // for estimateGas, must specify maxFeePerGas, otherwise our gas check fails + console.log(' == est gas=', await entryPoint.estimateGas.handleOps([op], beneficiaryAddress, { maxFeePerGas: 1e9 }).then(tostr)) + + // must specify at least on of maxFeePerGas, gasLimit + // (gasLimit, to prevent estimateGas to fail on missing maxFeePerGas, see above..) + const rcpt = await entryPoint.handleOps([op], beneficiaryAddress, { + maxFeePerGas: 1e9, + gasLimit: 13e5 + }).then(async t => await t.wait()) + + console.log('rcpt.gasUsed=', rcpt.gasUsed.toString(), rcpt.transactionHash) + await calcGasUsage(rcpt, entryPoint, beneficiaryAddress) + + // check that the state of the counter contract is updated + // this ensures that the `callGasLimit` is high enough + // therefore this value can be used as a reference in the test below + console.log(' == offset after', await counter.offset()) + expect(await counter.offset()).to.equal(offsetBefore.add(iterations)) + }) + + it('account should not pay if too low gas limit was set', async function () { + const iterations = 45 + const count = await counter.populateTransaction.gasWaster(iterations, '') + const accountExec = await account.populateTransaction.execute(counter.address, 0, count.data!) + const op = await fillAndSign({ + sender: account.address, + callData: accountExec.data, + verificationGasLimit: 1e5, + callGasLimit: 11e5 + }, accountOwner, entryPoint) + const inititalAccountBalance = await getBalance(account.address) + const beneficiaryAddress = createAddress() + const offsetBefore = await counter.offset() + console.log(' == offset before', offsetBefore) + // for estimateGas, must specify maxFeePerGas, otherwise our gas check fails + console.log(' == est gas=', await entryPoint.estimateGas.handleOps([op], beneficiaryAddress, { maxFeePerGas: 1e9 }).then(tostr)) + + // must specify at least on of maxFeePerGas, gasLimit + // (gasLimit, to prevent estimateGas to fail on missing maxFeePerGas, see above..) + // this transaction should revert as the gasLimit is too low to satisfy the expected `callGasLimit` (see test above) + await expect(entryPoint.handleOps([op], beneficiaryAddress, { + maxFeePerGas: 1e9, + gasLimit: 12e5 + })).to.revertedWith('AA95 out of gas') + + // Make sure that the user did not pay for the transaction + expect(await getBalance(account.address)).to.eq(inititalAccountBalance) + }) + it('legacy mode (maxPriorityFee==maxFeePerGas) should not use "basefee" opcode', async function () { const op = await fillAndSign({ sender: account.address, @@ -425,6 +716,23 @@ describe('EntryPoint', function () { console.log('rcpt.gasUsed=', rcpt.gasUsed.toString(), rcpt.transactionHash) await calcGasUsage(rcpt, entryPoint, beneficiaryAddress) }) + + it('should report failure on insufficient verificationGas after creation', async () => { + const op0 = await fillAndSign({ + sender: account.address, + verificationGasLimit: 5e5 + }, accountOwner, entryPoint) + // must succeed with enough verification gas + await expect(entryPoint.callStatic.simulateValidation(op0)) + .to.revertedWith('ValidationResult') + + const op1 = await fillAndSign({ + sender: account.address, + verificationGasLimit: 10000 + }, accountOwner, entryPoint) + await expect(entryPoint.callStatic.simulateValidation(op1)) + .to.revertedWith('AA23 reverted (or OOG)') + }) }) describe('create account', () => { @@ -436,19 +744,19 @@ describe('EntryPoint', function () { it('should reject create if sender address is wrong', async () => { const op = await fillAndSign({ - initCode: getAccountDeployer(entryPoint.address, accountOwner.address), + initCode: getAccountInitCode(accountOwner.address, simpleAccountFactory), verificationGasLimit: 2e6, sender: '0x'.padEnd(42, '1') }, accountOwner, entryPoint) await expect(entryPoint.callStatic.handleOps([op], beneficiaryAddress, { gasLimit: 1e7 - })).to.revertedWith('sender doesn\'t match initCode address') + })).to.revertedWith('AA14 initCode must return sender') }) it('should reject create if account not funded', async () => { const op = await fillAndSign({ - initCode: getAccountDeployer(entryPoint.address, accountOwner.address), + initCode: getAccountInitCode(accountOwner.address, simpleAccountFactory, 100), verificationGasLimit: 2e6 }, accountOwner, entryPoint) @@ -463,24 +771,31 @@ describe('EntryPoint', function () { }) it('should succeed to create account after prefund', async () => { - const preAddr = getAccountAddress(entryPoint.address, accountOwner.address) + const salt = 20 + const preAddr = await getAccountAddress(accountOwner.address, simpleAccountFactory, salt) await fund(preAddr) createOp = await fillAndSign({ - initCode: getAccountDeployer(entryPoint.address, accountOwner.address), - callGasLimit: 1e7, + initCode: getAccountInitCode(accountOwner.address, simpleAccountFactory, salt), + callGasLimit: 1e6, verificationGasLimit: 2e6 }, accountOwner, entryPoint) await expect(await ethers.provider.getCode(preAddr).then(x => x.length)).to.equal(2, 'account exists before creation') - const rcpt = await entryPoint.handleOps([createOp], beneficiaryAddress, { + const ret = await entryPoint.handleOps([createOp], beneficiaryAddress, { gasLimit: 1e7 - }).then(async tx => await tx.wait()).catch(rethrow()) + }) + const rcpt = await ret.wait() + const hash = await entryPoint.getUserOpHash(createOp) + await expect(ret).to.emit(entryPoint, 'AccountDeployed') + // eslint-disable-next-line @typescript-eslint/no-base-to-string + .withArgs(hash, createOp.sender, toChecksumAddress(createOp.initCode.toString().slice(0, 42)), AddressZero) + await calcGasUsage(rcpt!, entryPoint, beneficiaryAddress) }) it('should reject if account already created', async function () { - const preAddr = getAccountAddress(entryPoint.address, accountOwner.address) + const preAddr = await getAccountAddress(accountOwner.address, simpleAccountFactory) if (await ethers.provider.getCode(preAddr).then(x => x.length) === 2) { this.skip() } @@ -491,7 +806,8 @@ describe('EntryPoint', function () { }) }) - describe('batch multiple requests', () => { + describe('batch multiple requests', function () { + this.timeout(20000) if (process.env.COVERAGE != null) { return } @@ -512,14 +828,14 @@ describe('EntryPoint', function () { before('before', async () => { counter = await new TestCounter__factory(ethersSigner).deploy() const count = await counter.populateTransaction.count() - accountExecCounterFromEntryPoint = await account.populateTransaction.execFromEntryPoint(counter.address, 0, count.data!) - account1 = getAccountAddress(entryPoint.address, accountOwner1.address) - account2 = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner2.address) + accountExecCounterFromEntryPoint = await account.populateTransaction.execute(counter.address, 0, count.data!) + account1 = await getAccountAddress(accountOwner1.address, simpleAccountFactory); + ({ proxy: account2 } = await createAccount(ethersSigner, await accountOwner2.getAddress(), entryPoint.address)) await fund(account1) await fund(account2.address) // execute and increment counter const op1 = await fillAndSign({ - initCode: getAccountDeployer(entryPoint.address, accountOwner1.address), + initCode: getAccountInitCode(accountOwner1.address, simpleAccountFactory), callData: accountExecCounterFromEntryPoint.data, callGasLimit: 2e6, verificationGasLimit: 2e6 @@ -570,7 +886,7 @@ describe('EntryPoint', function () { }, accountOwner, entryPoint) // no aggregator is kind of "wrong aggregator" - await expect(entryPoint.handleOps([userOp], beneficiaryAddress)).to.revertedWith('wrong aggregator') + await expect(entryPoint.handleOps([userOp], beneficiaryAddress)).to.revertedWith('AA24 signature error') }) it('should fail to execute aggregated account with wrong aggregator', async () => { const userOp = await fillAndSign({ @@ -584,7 +900,28 @@ describe('EntryPoint', function () { userOps: [userOp], aggregator: wrongAggregator.address, signature: sig - }], beneficiaryAddress)).to.revertedWith('wrong aggregator') + }], beneficiaryAddress)).to.revertedWith('AA24 signature error') + }) + + it('should reject non-contract (address(1)) aggregator', async () => { + // this is just sanity check that the compiler indeed reverts on a call to "validateSignatures()" to nonexistent contracts + const address1 = hexZeroPad('0x1', 20) + const aggAccount1 = await new TestAggregatedAccount__factory(ethersSigner).deploy(entryPoint.address, address1) + + const userOp = await fillAndSign({ + sender: aggAccount1.address, + maxFeePerGas: 0 + }, accountOwner, entryPoint) + + const sig = HashZero + + expect(await entryPoint.handleAggregatedOps([{ + userOps: [userOp], + aggregator: address1, + signature: sig + }], beneficiaryAddress).catch(e => e.reason)) + .to.match(/invalid aggregator/) + // (different error in coverage mode (because of different solidity settings) }) it('should fail to execute aggregated account with wrong agg. signature', async () => { @@ -641,7 +978,28 @@ describe('EntryPoint', function () { aggregator: AddressZero, signature: '0x' }] - await entryPoint.handleAggregatedOps(aggInfos, beneficiaryAddress, { gasLimit: 3e6 }) + const rcpt = await entryPoint.handleAggregatedOps(aggInfos, beneficiaryAddress, { gasLimit: 3e6 }).then(async ret => ret.wait()) + const events = rcpt.events?.map((ev: Event) => { + if (ev.event === 'UserOperationEvent') { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `userOp(${ev.args?.sender})` + } + if (ev.event === 'SignatureAggregatorChanged') { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `agg(${ev.args?.aggregator})` + } else return null + }).filter(ev => ev != null) + // expected "SignatureAggregatorChanged" before every switch of aggregator + expect(events).to.eql([ + `agg(${aggregator.address})`, + `userOp(${userOp1.sender})`, + `userOp(${userOp2.sender})`, + `agg(${aggregator3.address})`, + `userOp(${userOp_agg3.sender})`, + `agg(${AddressZero})`, + `userOp(${userOp_noAgg.sender})`, + `agg(${AddressZero})` + ]) }) describe('execution ordering', () => { @@ -663,7 +1021,8 @@ describe('EntryPoint', function () { let addr: string let userOp: UserOperation before(async () => { - initCode = await getAggregatedAccountDeployer(entryPoint.address, aggregator.address) + const factory = await new TestAggregatedAccountFactory__factory(ethersSigner).deploy(entryPoint.address, aggregator.address) + initCode = await getAggregatedAccountInitCode(entryPoint.address, factory) addr = await entryPoint.callStatic.getSenderAddress(initCode).catch(e => e.errorArgs.sender) await ethersSigner.sendTransaction({ to: addr, value: parseEther('0.1') }) userOp = await fillAndSign({ @@ -673,10 +1032,10 @@ describe('EntryPoint', function () { }) it('simulateValidation should return aggregator and its stake', async () => { await aggregator.addStake(entryPoint.address, 3, { value: TWO_ETH }) - const { aggregationInfo } = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultWithAggregationCatch) - expect(aggregationInfo.actualAggregator).to.equal(aggregator.address) - expect(aggregationInfo.aggregatorStake).to.equal(TWO_ETH) - expect(aggregationInfo.aggregatorUnstakeDelay).to.equal(3) + const { aggregatorInfo } = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultWithAggregationCatch) + expect(aggregatorInfo.aggregator).to.equal(aggregator.address) + expect(aggregatorInfo.stakeInfo.stake).to.equal(TWO_ETH) + expect(aggregatorInfo.stakeInfo.unstakeDelaySec).to.equal(3) }) it('should create account in handleOps', async () => { await aggregator.validateUserOpSignature(userOp) @@ -702,20 +1061,32 @@ describe('EntryPoint', function () { await paymaster.addStake(globalUnstakeDelaySec, { value: paymasterStake }) counter = await new TestCounter__factory(ethersSigner).deploy() const count = await counter.populateTransaction.count() - accountExecFromEntryPoint = await account.populateTransaction.execFromEntryPoint(counter.address, 0, count.data!) + accountExecFromEntryPoint = await account.populateTransaction.execute(counter.address, 0, count.data!) + }) + + it('should fail with nonexistent paymaster', async () => { + const pm = createAddress() + const op = await fillAndSign({ + paymasterAndData: pm, + callData: accountExecFromEntryPoint.data, + initCode: getAccountInitCode(account2Owner.address, simpleAccountFactory), + verificationGasLimit: 3e6, + callGasLimit: 1e6 + }, account2Owner, entryPoint) + await expect(entryPoint.simulateValidation(op)).to.revertedWith('"AA30 paymaster not deployed"') }) it('should fail if paymaster has no deposit', async function () { const op = await fillAndSign({ paymasterAndData: paymaster.address, callData: accountExecFromEntryPoint.data, - initCode: getAccountDeployer(entryPoint.address, account2Owner.address), + initCode: getAccountInitCode(account2Owner.address, simpleAccountFactory), - verificationGasLimit: 1e6, + verificationGasLimit: 3e6, callGasLimit: 1e6 }, account2Owner, entryPoint) const beneficiaryAddress = createAddress() - await expect(entryPoint.handleOps([op], beneficiaryAddress)).to.revertedWith('"paymaster deposit too low"') + await expect(entryPoint.handleOps([op], beneficiaryAddress)).to.revertedWith('"AA31 paymaster deposit too low"') }) it('paymaster should pay for tx', async function () { @@ -723,7 +1094,7 @@ describe('EntryPoint', function () { const op = await fillAndSign({ paymasterAndData: paymaster.address, callData: accountExecFromEntryPoint.data, - initCode: getAccountDeployer(entryPoint.address, account2Owner.address) + initCode: getAccountInitCode(account2Owner.address, simpleAccountFactory) }, account2Owner, entryPoint) const beneficiaryAddress = createAddress() @@ -733,20 +1104,20 @@ describe('EntryPoint', function () { const paymasterPaid = ONE_ETH.sub(await entryPoint.balanceOf(paymaster.address)) expect(paymasterPaid).to.eql(actualGasCost) }) - it('simulate should return paymaster stake and delay', async () => { + it('simulateValidation should return paymaster stake and delay', async () => { await paymaster.deposit({ value: ONE_ETH }) const anOwner = createAccountOwner() const op = await fillAndSign({ paymasterAndData: paymaster.address, callData: accountExecFromEntryPoint.data, - initCode: getAccountDeployer(entryPoint.address, anOwner.address) + initCode: getAccountInitCode(anOwner.address, simpleAccountFactory) }, anOwner, entryPoint) const { paymasterInfo } = await entryPoint.callStatic.simulateValidation(op).catch(simulationResultCatch) const { - paymasterStake: simRetStake, - paymasterUnstakeDelay: simRetDelay + stake: simRetStake, + unstakeDelaySec: simRetDelay } = paymasterInfo expect(simRetStake).to.eql(paymasterStake) @@ -754,44 +1125,48 @@ describe('EntryPoint', function () { }) }) - describe('Validation deadline', () => { - describe('validateUserOp deadline', function () { - let account: TestExpiryAccount - let now: number - before('init account with session key', async () => { - // create a test account. The primary owner is the global ethersSigner, so that we can easily add a temporaryOwner, below - account = await new TestExpiryAccount__factory(ethersSigner).deploy(entryPoint.address, await ethersSigner.getAddress()) - await ethersSigner.sendTransaction({ to: account.address, value: parseEther('0.1') }) - now = await ethers.provider.getBlock('latest').then(block => block.timestamp) - }) + describe('Validation time-range', () => { + const beneficiary = createAddress() + let account: TestExpiryAccount + let now: number + let sessionOwner: Wallet + before('init account with session key', async () => { + // create a test account. The primary owner is the global ethersSigner, so that we can easily add a temporaryOwner, below + account = await new TestExpiryAccount__factory(ethersSigner).deploy(entryPoint.address) + await account.initialize(await ethersSigner.getAddress()) + await ethersSigner.sendTransaction({ to: account.address, value: parseEther('0.1') }) + now = await ethers.provider.getBlock('latest').then(block => block.timestamp) + sessionOwner = createAccountOwner() + await account.addTemporaryOwner(sessionOwner.address, 100, now + 60) + }) + describe('validateUserOp time-range', function () { it('should accept non-expired owner', async () => { - const sessionOwner = createAccountOwner() - await account.addTemporaryOwner(sessionOwner.address, now + 60) const userOp = await fillAndSign({ sender: account.address }, sessionOwner, entryPoint) - const { deadline } = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) - expect(deadline).to.eql(now + 60) + const ret = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) + expect(ret.returnInfo.validUntil).to.eql(now + 60) + expect(ret.returnInfo.validAfter).to.eql(100) }) - it('should reject expired owner', async () => { - const sessionOwner = createAccountOwner() - await account.addTemporaryOwner(sessionOwner.address, now - 60) + it('should not reject expired owner', async () => { + const expiredOwner = createAccountOwner() + await account.addTemporaryOwner(expiredOwner.address, 123, now - 60) const userOp = await fillAndSign({ sender: account.address - }, sessionOwner, entryPoint) - await expect(entryPoint.callStatic.simulateValidation(userOp)).to.revertedWith('expired') + }, expiredOwner, entryPoint) + const ret = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) + expect(ret.returnInfo.validUntil).eql(now - 60) + expect(ret.returnInfo.validAfter).to.eql(123) }) }) describe('validatePaymasterUserOp with deadline', function () { - let account: TestExpiryAccount let paymaster: TestExpirePaymaster let now: number - before('init account with session key', async () => { - // account without eth - must be paid by paymaster. - account = await new TestExpiryAccount__factory(ethersSigner).deploy(entryPoint.address, await ethersSigner.getAddress()) + before('init account with session key', async function () { + this.timeout(20000) paymaster = await new TestExpirePaymaster__factory(ethersSigner).deploy(entryPoint.address) await paymaster.addStake(1, { value: paymasterStake }) await paymaster.deposit({ value: parseEther('0.1') }) @@ -799,22 +1174,89 @@ describe('EntryPoint', function () { }) it('should accept non-expired paymaster request', async () => { - const expireTime = defaultAbiCoder.encode(['uint256'], [now + 60]) + const timeRange = defaultAbiCoder.encode(['uint48', 'uint48'], [123, now + 60]) const userOp = await fillAndSign({ sender: account.address, - paymasterAndData: hexConcat([paymaster.address, expireTime]) + paymasterAndData: hexConcat([paymaster.address, timeRange]) }, ethersSigner, entryPoint) - const { deadline } = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) - expect(deadline).to.eql(now + 60) + const ret = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) + expect(ret.returnInfo.validUntil).to.eql(now + 60) + expect(ret.returnInfo.validAfter).to.eql(123) }) - it('should reject expired paymaster request', async () => { - const expireTime = defaultAbiCoder.encode(['uint256'], [now - 60]) + it('should not reject expired paymaster request', async () => { + const timeRange = defaultAbiCoder.encode(['uint48', 'uint48'], [321, now - 60]) const userOp = await fillAndSign({ sender: account.address, - paymasterAndData: hexConcat([paymaster.address, expireTime]) + paymasterAndData: hexConcat([paymaster.address, timeRange]) }, ethersSigner, entryPoint) - await expect(entryPoint.callStatic.simulateValidation(userOp)).to.revertedWith('expired') + const ret = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) + expect(ret.returnInfo.validUntil).to.eql(now - 60) + expect(ret.returnInfo.validAfter).to.eql(321) + }) + + // helper method + async function createOpWithPaymasterParams (owner: Wallet, after: number, until: number): Promise { + const timeRange = defaultAbiCoder.encode(['uint48', 'uint48'], [after, until]) + return await fillAndSign({ + sender: account.address, + paymasterAndData: hexConcat([paymaster.address, timeRange]) + }, owner, entryPoint) + } + + describe('time-range overlap of paymaster and account should intersect', () => { + let owner: Wallet + before(async () => { + owner = createAccountOwner() + await account.addTemporaryOwner(owner.address, 100, 500) + }) + + async function simulateWithPaymasterParams (after: number, until: number): Promise { + const userOp = await createOpWithPaymasterParams(owner, after, until) + const ret = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) + return ret.returnInfo + } + + // sessionOwner has a range of 100.. now+60 + it('should use lower "after" value of paymaster', async () => { + expect((await simulateWithPaymasterParams(10, 1000)).validAfter).to.eql(100) + }) + it('should use lower "after" value of account', async () => { + expect((await simulateWithPaymasterParams(200, 1000)).validAfter).to.eql(200) + }) + it('should use higher "until" value of paymaster', async () => { + expect((await simulateWithPaymasterParams(10, 400)).validUntil).to.eql(400) + }) + it('should use higher "until" value of account', async () => { + expect((await simulateWithPaymasterParams(200, 600)).validUntil).to.eql(500) + }) + + it('handleOps should revert on expired paymaster request', async () => { + const userOp = await createOpWithPaymasterParams(sessionOwner, now + 100, now + 200) + await expect(entryPoint.handleOps([userOp], beneficiary)) + .to.revertedWith('AA32 paymaster expired or not due') + }) + }) + }) + describe('handleOps should abort on time-range', () => { + it('should revert on expired account', async () => { + const expiredOwner = createAccountOwner() + await account.addTemporaryOwner(expiredOwner.address, 1, 2) + const userOp = await fillAndSign({ + sender: account.address + }, expiredOwner, entryPoint) + await expect(entryPoint.handleOps([userOp], beneficiary)) + .to.revertedWith('AA22 expired or not due') + }) + + it('should revert on date owner', async () => { + const futureOwner = createAccountOwner() + await account.addTemporaryOwner(futureOwner.address, now + 100, now + 200) + const userOp = await fillAndSign({ + sender: account.address + }, futureOwner, entryPoint) + await expect(entryPoint.handleOps([userOp], beneficiary)) + .to.revertedWith('AA22 expired or not due') }) }) }) diff --git a/packages/boba/account-abstraction/test/gnosis.test.ts b/packages/boba/account-abstraction/test/gnosis.test.ts index 4f5a140079..784b7f914b 100644 --- a/packages/boba/account-abstraction/test/gnosis.test.ts +++ b/packages/boba/account-abstraction/test/gnosis.test.ts @@ -2,14 +2,17 @@ import './aa.init' import { ethers } from 'hardhat' import { Signer } from 'ethers' import { + EIP4337Fallback__factory, EIP4337Manager, EIP4337Manager__factory, EntryPoint, EntryPoint__factory, GnosisSafe, + GnosisSafeAccountFactory, + GnosisSafeAccountFactory__factory, + GnosisSafeProxy, + GnosisSafeProxyFactory__factory, GnosisSafe__factory, - SafeProxy4337, - SafeProxy4337__factory, TestCounter, TestCounter__factory } from '../typechain' @@ -23,9 +26,8 @@ import { isDeployed } from './testutils' import { fillAndSign } from './UserOp' -import { defaultAbiCoder, hexConcat, hexValue, hexZeroPad, parseEther } from 'ethers/lib/utils' +import { defaultAbiCoder, hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils' import { expect } from 'chai' -import { Create2Factory } from '../src/Create2Factory' describe('Gnosis Proxy', function () { this.timeout(30000) @@ -34,13 +36,15 @@ describe('Gnosis Proxy', function () { let safeSingleton: GnosisSafe let owner: Signer let ownerAddress: string - let proxy: SafeProxy4337 + let proxy: GnosisSafeProxy let manager: EIP4337Manager let entryPoint: EntryPoint let counter: TestCounter let proxySafe: GnosisSafe let safe_execTxCallData: string + let accountFactory: GnosisSafeAccountFactory + before('before', async function () { // EIP4337Manager fails to compile with solc-coverage if (process.env.COVERAGE != null) { @@ -49,27 +53,49 @@ describe('Gnosis Proxy', function () { const provider = ethers.provider ethersSigner = provider.getSigner() + + // standard safe singleton contract (implementation) safeSingleton = await new GnosisSafe__factory(ethersSigner).deploy() + // standard safe proxy factory + const proxyFactory = await new GnosisSafeProxyFactory__factory(ethersSigner).deploy() entryPoint = await deployEntryPoint() manager = await new EIP4337Manager__factory(ethersSigner).deploy(entryPoint.address) owner = createAccountOwner() ownerAddress = await owner.getAddress() counter = await new TestCounter__factory(ethersSigner).deploy() - proxy = await new SafeProxy4337__factory(ethersSigner).deploy(safeSingleton.address, manager.address, ownerAddress) + accountFactory = await new GnosisSafeAccountFactory__factory(ethersSigner) + .deploy(proxyFactory.address, safeSingleton.address, manager.address) - proxySafe = GnosisSafe__factory.connect(proxy.address, owner) + await accountFactory.createAccount(ownerAddress, 0) + // we use our accountFactory to create and configure the proxy. + // but the actual deployment is done internally by the gnosis factory + const ev = await proxyFactory.queryFilter(proxyFactory.filters.ProxyCreation()) + const addr = ev[0].args.proxy - await ethersSigner.sendTransaction({ to: proxy.address, value: parseEther('0.1') }) + proxy = + proxySafe = GnosisSafe__factory.connect(addr, owner) + + await ethersSigner.sendTransaction({ + to: proxy.address, + value: parseEther('0.1') + }) const counter_countCallData = counter.interface.encodeFunctionData('count') - safe_execTxCallData = safeSingleton.interface.encodeFunctionData('execTransactionFromModule', [counter.address, 0, counter_countCallData, 0]) + safe_execTxCallData = manager.interface.encodeFunctionData('executeAndRevert', [counter.address, 0, counter_countCallData, 0]) }) let beneficiary: string beforeEach(() => { beneficiary = createAddress() }) + it('#getCurrentEIP4337Manager', async () => { + // need some manager to query the current manager of a safe + const tempManager = await new EIP4337Manager__factory(ethersSigner).deploy(AddressZero) + const { manager: curManager } = await tempManager.getCurrentEIP4337Manager(proxySafe.address) + expect(curManager).to.eq(manager.address) + }) + it('should validate', async function () { await manager.callStatic.validateEip4337(proxySafe.address, manager.address, { gasLimit: 10e6 }) }) @@ -85,7 +111,7 @@ describe('Gnosis Proxy', function () { }) it('should fail on invalid userop', async function () { - const op = await fillAndSign({ + let op = await fillAndSign({ sender: proxy.address, nonce: 1234, callGasLimit: 1e6, @@ -93,8 +119,14 @@ describe('Gnosis Proxy', function () { }, owner, entryPoint) await expect(entryPoint.handleOps([op], beneficiary)).to.revertedWith('account: invalid nonce') + op = await fillAndSign({ + sender: proxy.address, + callGasLimit: 1e6, + callData: safe_execTxCallData + }, owner, entryPoint) + // invalidate the signature op.callGasLimit = 1 - await expect(entryPoint.handleOps([op], beneficiary)).to.revertedWith('account: wrong signature') + await expect(entryPoint.handleOps([op], beneficiary)).to.revertedWith('FailedOp(0, "AA24 signature error")') }) it('should exec', async function () { @@ -111,19 +143,44 @@ describe('Gnosis Proxy', function () { expect(await getBalance(beneficiary)).to.eq(ev.args!.actualGasCost) }) + it('should revert with reason', async function () { + const counter_countFailCallData = counter.interface.encodeFunctionData('countFail') + const safe_execFailTxCallData = manager.interface.encodeFunctionData('executeAndRevert', [counter.address, 0, counter_countFailCallData, 0]) + + const op = await fillAndSign({ + sender: proxy.address, + callGasLimit: 1e6, + callData: safe_execFailTxCallData + }, owner, entryPoint) + const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait()) + console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash) + + // decode the revertReason + const ev = rcpt.events!.find(ev => ev.event === 'UserOperationRevertReason')! + let message: string = ev.args!.revertReason + if (message.startsWith('0x08c379a0')) { + // Error(string) + message = defaultAbiCoder.decode(['string'], '0x' + message.substring(10)).toString() + } + expect(message).to.eq('count failed') + }) + let counterfactualAddress: string it('should create account', async function () { - const ctrCode = hexValue(await new SafeProxy4337__factory(ethersSigner).getDeployTransaction(safeSingleton.address, manager.address, ownerAddress).data!) const initCode = hexConcat([ - Create2Factory.contractAddress, - new Create2Factory(ethers.provider).getDeployTransactionCallData(ctrCode, 0) + accountFactory.address, + accountFactory.interface.encodeFunctionData('createAccount', [ownerAddress, 123]) ]) - counterfactualAddress = await entryPoint.callStatic.getSenderAddress(initCode).catch(e => e.errorArgs.sender) + counterfactualAddress = await accountFactory.callStatic.getAddress(ownerAddress, 123) expect(!await isDeployed(counterfactualAddress)) - await ethersSigner.sendTransaction({ to: counterfactualAddress, value: parseEther('0.1') }) + await ethersSigner.sendTransaction({ + to: counterfactualAddress, + value: parseEther('0.1') + }) const op = await fillAndSign({ + sender: counterfactualAddress, initCode, verificationGasLimit: 400000 }, owner, entryPoint) @@ -149,6 +206,21 @@ describe('Gnosis Proxy', function () { console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash) }) + it('should validate ERC1271 signatures', async function () { + const safe = EIP4337Fallback__factory.connect(proxySafe.address, ethersSigner) + + const message = ethers.utils.hexlify(ethers.utils.toUtf8Bytes('hello erc1271')) + const dataHash = ethers.utils.arrayify(ethers.utils.keccak256(message)) + + const sig = await owner.signMessage(dataHash) + expect(await safe.isValidSignature(dataHash, sig)).to.be.eq('0x1626ba7e') + + // make an sig invalid + const badWallet = ethers.Wallet.createRandom() + const badSig = await badWallet.signMessage(dataHash) + expect(await safe.isValidSignature(dataHash, badSig)).to.be.not.eq('0x1626ba7e') + }) + context('#replaceEIP4337', () => { let signature: string let newEntryPoint: EntryPoint @@ -186,7 +258,10 @@ describe('Gnosis Proxy', function () { expect(await proxySafe.isModuleEnabled(oldFallback)).to.equal(true) expect(oldManager.toLowerCase()).to.eq(manager.address.toLowerCase()) - await ethersSigner.sendTransaction({ to: ownerAddress, value: parseEther('0.1') }) + await ethersSigner.sendTransaction({ + to: ownerAddress, + value: parseEther('33') + }) const replaceManagerCallData = manager.interface.encodeFunctionData('replaceEIP4337Manager', [prev, oldManager, newManager.address]) @@ -198,6 +273,9 @@ describe('Gnosis Proxy', function () { expect(await proxySafe.isModuleEnabled(newFallback)).to.equal(true) expect(await proxySafe.isModuleEnabled(entryPoint.address)).to.equal(false) expect(await proxySafe.isModuleEnabled(oldFallback)).to.equal(false) + + const { manager: curManager } = await manager.getCurrentEIP4337Manager(proxySafe.address) + expect(curManager).to.eq(newManager.address) }) }) }) diff --git a/packages/boba/account-abstraction/test/helpers.test.ts b/packages/boba/account-abstraction/test/helpers.test.ts new file mode 100644 index 0000000000..dd69d55a69 --- /dev/null +++ b/packages/boba/account-abstraction/test/helpers.test.ts @@ -0,0 +1,64 @@ +import './aa.init' +import { BigNumber } from 'ethers' +import { AddressZero } from './testutils' +import { expect } from 'chai' +import { hexlify } from 'ethers/lib/utils' +import { TestHelpers, TestHelpers__factory } from '../typechain' +import { ethers } from 'hardhat' + +const provider = ethers.provider +const ethersSigner = provider.getSigner() + +describe('#ValidationData helpers', function () { + function pack (addr: string, validUntil: number, validAfter: number): BigNumber { + return BigNumber.from(BigNumber.from(addr)) + .add(BigNumber.from(validUntil).mul(BigNumber.from(2).pow(160))) + .add(BigNumber.from(validAfter).mul(BigNumber.from(2).pow(160 + 48))) + } + + let helpers: TestHelpers + const addr1 = AddressZero.replace(/0$/, '1') + const addr = '0x'.padEnd(42, '9') + const max48 = 2 ** 48 - 1 + + before(async () => { + helpers = await new TestHelpers__factory(ethersSigner).deploy() + }) + + it('#parseValidationData', async () => { + expect(await helpers.parseValidationData(0)) + .to.eql({ aggregator: AddressZero, validAfter: 0, validUntil: max48 }) + expect(await helpers.parseValidationData(1)) + .to.eql({ aggregator: addr1, validAfter: 0, validUntil: max48 }) + expect(await helpers.parseValidationData(pack(AddressZero, 0, 10))) + .to.eql({ aggregator: AddressZero, validAfter: 10, validUntil: max48 }) + expect(await helpers.parseValidationData(pack(AddressZero, 10, 0))) + .to.eql({ aggregator: AddressZero, validAfter: 0, validUntil: 10 }) + }) + + it('#packValidationData', async () => { + expect(await helpers.packValidationData(false, 0, 0)).to.eql(0) + expect(await helpers.packValidationData(true, 0, 0)).to.eql(1) + expect(hexlify(await helpers.packValidationData(true, 123, 456))) + .to.eql(hexlify(pack(addr1, 123, 456))) + }) + + it('#packValidationData with aggregator', async () => { + expect(hexlify(await helpers.packValidationDataStruct({ aggregator: addr, validUntil: 234, validAfter: 567 }))) + .to.eql(hexlify(pack(addr, 234, 567))) + }) + + it('#intersectTimeRange', async () => { + expect(await helpers.intersectTimeRange(pack(AddressZero, 0, 0), pack(AddressZero, 0, 0))) + .to.eql({ aggregator: AddressZero, validAfter: 0, validUntil: max48 }) + expect(await helpers.intersectTimeRange(pack(AddressZero, 100, 10), pack(AddressZero, 200, 50))) + .to.eql({ aggregator: AddressZero, validAfter: 50, validUntil: 100 }) + + expect(await helpers.intersectTimeRange(pack(addr, 100, 10), pack(AddressZero, 200, 50))) + .to.eql({ aggregator: addr, validAfter: 50, validUntil: 100 }) + expect(await helpers.intersectTimeRange(pack(addr, 100, 10), pack(addr1, 200, 50))) + .to.eql({ aggregator: addr, validAfter: 50, validUntil: 100 }) + expect(await helpers.intersectTimeRange(pack(AddressZero, 100, 10), pack(addr1, 200, 50))) + .to.eql({ aggregator: addr1, validAfter: 50, validUntil: 100 }) + }) +}) diff --git a/packages/boba/account-abstraction/test/paymaster.test.ts b/packages/boba/account-abstraction/test/paymaster.test.ts index 6fec95fc8d..228688ae96 100644 --- a/packages/boba/account-abstraction/test/paymaster.test.ts +++ b/packages/boba/account-abstraction/test/paymaster.test.ts @@ -3,13 +3,12 @@ import { ethers } from 'hardhat' import { expect } from 'chai' import { SimpleAccount, - SimpleAccount__factory, EntryPoint, TokenPaymaster, TokenPaymaster__factory, TestCounter__factory, - SimpleAccountDeployer, - SimpleAccountDeployer__factory + SimpleAccountFactory, + SimpleAccountFactory__factory } from '../typechain' import { AddressZero, @@ -24,6 +23,7 @@ import { checkForBannedOps, createAddress, ONE_ETH, + createAccount, getAccountAddress } from './testutils' import { fillAndSign } from './UserOp' @@ -37,23 +37,24 @@ describe('EntryPoint with paymaster', function () { const ethersSigner = ethers.provider.getSigner() let account: SimpleAccount const beneficiaryAddress = '0x'.padEnd(42, '1') - let deployer: SimpleAccountDeployer + let factory: SimpleAccountFactory - function getAccountDeployer (entryPoint: string, accountOwner: string): string { + function getAccountDeployer (entryPoint: string, accountOwner: string, _salt: number = 0): string { return hexConcat([ - deployer.address, - hexValue(deployer.interface.encodeFunctionData('deployAccount', [entryPoint, accountOwner, 0])!) + factory.address, + hexValue(factory.interface.encodeFunctionData('createAccount', [accountOwner, _salt])!) ]) } before(async function () { + this.timeout(20000) await checkForGeth() entryPoint = await deployEntryPoint() - deployer = await new SimpleAccountDeployer__factory(ethersSigner).deploy() + factory = await new SimpleAccountFactory__factory(ethersSigner).deploy(entryPoint.address) - accountOwner = createAccountOwner() - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, await accountOwner.getAddress()) + accountOwner = createAccountOwner(); + ({ proxy: account } = await createAccount(ethersSigner, await accountOwner.getAddress(), entryPoint.address, factory)) await fund(account) }) @@ -64,7 +65,7 @@ describe('EntryPoint with paymaster', function () { let pmAddr: string before(async () => { - paymaster = await new TokenPaymaster__factory(ethersSigner).deploy(deployer.address, 'ttt', entryPoint.address) + paymaster = await new TokenPaymaster__factory(ethersSigner).deploy(factory.address, 'ttt', entryPoint.address) pmAddr = paymaster.address ownerAddr = await ethersSigner.getAddress() }) @@ -84,7 +85,7 @@ describe('EntryPoint with paymaster', function () { describe('using TokenPaymaster (account pays in paymaster tokens)', () => { let paymaster: TokenPaymaster before(async () => { - paymaster = await new TokenPaymaster__factory(ethersSigner).deploy(deployer.address, 'tst', entryPoint.address) + paymaster = await new TokenPaymaster__factory(ethersSigner).deploy(factory.address, 'tst', entryPoint.address) await entryPoint.depositTo(paymaster.address, { value: parseEther('1') }) await paymaster.addStake(1, { value: parseEther('2') }) }) @@ -92,8 +93,8 @@ describe('EntryPoint with paymaster', function () { describe('#handleOps', () => { let calldata: string before(async () => { - const updateEntryPoint = await account.populateTransaction.updateEntryPoint(AddressZero).then(tx => tx.data!) - calldata = await account.populateTransaction.execFromEntryPoint(account.address, 0, updateEntryPoint).then(tx => tx.data!) + const updateEntryPoint = await account.populateTransaction.withdrawDepositTo(AddressZero, 0).then(tx => tx.data!) + calldata = await account.populateTransaction.execute(account.address, 0, updateEntryPoint).then(tx => tx.data!) }) it('paymaster should reject if account doesn\'t have tokens', async () => { const op = await fillAndSign({ @@ -103,10 +104,10 @@ describe('EntryPoint with paymaster', function () { }, accountOwner, entryPoint) await expect(entryPoint.callStatic.handleOps([op], beneficiaryAddress, { gasLimit: 1e7 - }).catch(rethrow())).to.revertedWith('TokenPaymaster: no balance') + })).to.revertedWith('AA33 reverted: TokenPaymaster: no balance') await expect(entryPoint.handleOps([op], beneficiaryAddress, { gasLimit: 1e7 - }).catch(rethrow())).to.revertedWith('TokenPaymaster: no balance') + })).to.revertedWith('AA33 reverted: TokenPaymaster: no balance') }) }) @@ -117,7 +118,7 @@ describe('EntryPoint with paymaster', function () { it('should reject if account not funded', async () => { const op = await fillAndSign({ - initCode: getAccountDeployer(entryPoint.address, accountOwner.address), + initCode: getAccountDeployer(entryPoint.address, accountOwner.address, 1), verificationGasLimit: 1e7, paymasterAndData: paymaster.address }, accountOwner, entryPoint) @@ -128,8 +129,8 @@ describe('EntryPoint with paymaster', function () { it('should succeed to create account with tokens', async () => { createOp = await fillAndSign({ - initCode: getAccountDeployer(entryPoint.address, accountOwner.address), - verificationGasLimit: 1e7, + initCode: getAccountDeployer(entryPoint.address, accountOwner.address, 3), + verificationGasLimit: 2e6, paymasterAndData: paymaster.address, nonce: 0 }, accountOwner, entryPoint) @@ -156,7 +157,7 @@ describe('EntryPoint with paymaster', function () { const ethRedeemed = await getBalance(beneficiaryAddress) expect(ethRedeemed).to.above(100000) - const accountAddr = getAccountAddress(entryPoint.address, accountOwner.address) + const accountAddr = await getAccountAddress(accountOwner.address, factory) const postBalance = await getTokenBalance(paymaster, accountAddr) expect(1e18 - postBalance).to.above(10000) }) @@ -168,20 +169,21 @@ describe('EntryPoint with paymaster', function () { }).catch(rethrow())).to.revertedWith('sender already constructed') }) - it('batched request should each pay for its share', async () => { + it('batched request should each pay for its share', async function () { + this.timeout(20000) // validate context is passed correctly to postOp // (context is the account to pay with) const beneficiaryAddress = createAddress() const testCounter = await new TestCounter__factory(ethersSigner).deploy() const justEmit = testCounter.interface.encodeFunctionData('justemit') - const execFromSingleton = account.interface.encodeFunctionData('execFromEntryPoint', [testCounter.address, 0, justEmit]) + const execFromSingleton = account.interface.encodeFunctionData('execute', [testCounter.address, 0, justEmit]) const ops: UserOperation[] = [] const accounts: SimpleAccount[] = [] for (let i = 0; i < 4; i++) { - const aAccount = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, await accountOwner.getAddress()) + const { proxy: aAccount } = await createAccount(ethersSigner, await accountOwner.getAddress(), entryPoint.address) await paymaster.mintTokens(aAccount.address, parseEther('1')) const op = await fillAndSign({ sender: aAccount.address, @@ -212,15 +214,16 @@ describe('EntryPoint with paymaster', function () { describe('grief attempt', () => { let account2: SimpleAccount let approveCallData: string - before(async () => { - account2 = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, await accountOwner.getAddress()) + before(async function () { + this.timeout(20000); + ({ proxy: account2 } = await createAccount(ethersSigner, await accountOwner.getAddress(), entryPoint.address)) await paymaster.mintTokens(account2.address, parseEther('1')) await paymaster.mintTokens(account.address, parseEther('1')) approveCallData = paymaster.interface.encodeFunctionData('approve', [account.address, ethers.constants.MaxUint256]) // need to call approve from account2. use paymaster for that const approveOp = await fillAndSign({ sender: account2.address, - callData: account2.interface.encodeFunctionData('execFromEntryPoint', [paymaster.address, 0, approveCallData]), + callData: account2.interface.encodeFunctionData('execute', [paymaster.address, 0, approveCallData]), paymasterAndData: paymaster.address }, accountOwner, entryPoint) await entryPoint.handleOps([approveOp], beneficiaryAddress) @@ -235,7 +238,7 @@ describe('EntryPoint with paymaster', function () { const withdrawAmount = account2Balance.sub(transferCost.mul(0)) const withdrawTokens = paymaster.interface.encodeFunctionData('transferFrom', [account2.address, account.address, withdrawAmount]) // const withdrawTokens = paymaster.interface.encodeFunctionData('transfer', [account.address, parseEther('0.1')]) - const execFromEntryPoint = account.interface.encodeFunctionData('execFromEntryPoint', [paymaster.address, 0, withdrawTokens]) + const execFromEntryPoint = account.interface.encodeFunctionData('execute', [paymaster.address, 0, withdrawTokens]) const userOp1 = await fillAndSign({ sender: account.address, @@ -262,7 +265,8 @@ describe('EntryPoint with paymaster', function () { }) describe('withdraw', () => { const withdrawAddress = createAddress() - it('should fail to withdraw before unstake', async () => { + it('should fail to withdraw before unstake', async function () { + this.timeout(20000) await expect( paymaster.withdrawStake(withdrawAddress) ).to.revertedWith('must call unlockStake') diff --git a/packages/boba/account-abstraction/test/simple-wallet.test.ts b/packages/boba/account-abstraction/test/simple-wallet.test.ts index 3fedbfdc01..d5350e0eb6 100644 --- a/packages/boba/account-abstraction/test/simple-wallet.test.ts +++ b/packages/boba/account-abstraction/test/simple-wallet.test.ts @@ -3,12 +3,18 @@ import { ethers } from 'hardhat' import { expect } from 'chai' import { SimpleAccount, - SimpleAccountDeployer__factory, - SimpleAccount__factory, + SimpleAccountFactory__factory, TestUtil, TestUtil__factory } from '../typechain' -import { AddressZero, createAddress, createAccountOwner, getBalance, isDeployed, ONE_ETH } from './testutils' +import { + createAddress, + createAccountOwner, + getBalance, + isDeployed, + ONE_ETH, + createAccount, HashZero +} from './testutils' import { fillUserOpDefaults, getUserOpHash, packUserOp, signUserOp } from './UserOp' import { parseEther } from 'ethers/lib/utils' import { UserOperation } from './UserOperation' @@ -29,14 +35,14 @@ describe('SimpleAccount', function () { }) it('owner should be able to call transfer', async () => { - const account = await new SimpleAccount__factory(ethers.provider.getSigner()).deploy(entryPoint, accounts[0]) + const { proxy: account } = await createAccount(ethers.provider.getSigner(), accounts[0], entryPoint) await ethersSigner.sendTransaction({ from: accounts[0], to: account.address, value: parseEther('2') }) - await account.transfer(accounts[2], ONE_ETH) + await account.execute(accounts[2], ONE_ETH, '0x') }) it('other account should not be able to call transfer', async () => { - const account = await new SimpleAccount__factory(ethers.provider.getSigner()).deploy(entryPoint, accounts[0]) - await expect(account.connect(ethers.provider.getSigner(1)).transfer(accounts[2], ONE_ETH)) - .to.be.revertedWith('only owner') + const { proxy: account } = await createAccount(ethers.provider.getSigner(), accounts[0], entryPoint) + await expect(account.connect(ethers.provider.getSigner(1)).execute(accounts[2], ONE_ETH, '0x')) + .to.be.revertedWith('account: not Owner or EntryPoint') }) it('should pack in js the same as solidity', async () => { @@ -56,8 +62,8 @@ describe('SimpleAccount', function () { before(async () => { // that's the account of ethersSigner - const entryPoint = accounts[2] - account = await new SimpleAccount__factory(await ethers.getSigner(entryPoint)).deploy(entryPoint, accountOwner.address) + const entryPoint = accounts[2]; + ({ proxy: account } = await createAccount(await ethers.getSigner(entryPoint), accountOwner.address, entryPoint)) await ethersSigner.sendTransaction({ from: accounts[0], to: account.address, value: parseEther('0.2') }) const callGasLimit = 200000 const verificationGasLimit = 100000 @@ -76,7 +82,7 @@ describe('SimpleAccount', function () { expectedPay = actualGasPrice * (callGasLimit + verificationGasLimit) preBalance = await getBalance(account.address) - const ret = await account.validateUserOp(userOp, userOpHash, AddressZero, expectedPay, { gasPrice: actualGasPrice }) + const ret = await account.validateUserOp(userOp, userOpHash, expectedPay, { gasPrice: actualGasPrice }) await ret.wait() }) @@ -88,23 +94,24 @@ describe('SimpleAccount', function () { it('should increment nonce', async () => { expect(await account.nonce()).to.equal(1) }) + it('should reject same TX on nonce error', async () => { - await expect(account.validateUserOp(userOp, userOpHash, AddressZero, 0)).to.revertedWith('invalid nonce') + await expect(account.validateUserOp(userOp, userOpHash, 0)).to.revertedWith('invalid nonce') }) - it('should reject tx with wrong signature', async () => { - // validateUserOp doesn't check the actual UserOp for the signature, but relies on the userOpHash given by - // the entrypoint - const wrongUserOpHash = ethers.constants.HashZero - await expect(account.validateUserOp(userOp, wrongUserOpHash, AddressZero, 0)).to.revertedWith('account: wrong signature') + + it('should return NO_SIG_VALIDATION on wrong signature', async () => { + const userOpHash = HashZero + const deadline = await account.callStatic.validateUserOp({ ...userOp, nonce: 1 }, userOpHash, 0) + expect(deadline).to.eq(1) }) }) - context('SimpleAccountDeployer', () => { + context('SimpleAccountFactory', () => { it('sanity: check deployer', async () => { const ownerAddr = createAddress() - const deployer = await new SimpleAccountDeployer__factory(ethersSigner).deploy() - const target = await deployer.callStatic.deployAccount(entryPoint, ownerAddr, 1234) + const deployer = await new SimpleAccountFactory__factory(ethersSigner).deploy(entryPoint) + const target = await deployer.callStatic.createAccount(ownerAddr, 1234) expect(await isDeployed(target)).to.eq(false) - await deployer.deployAccount(entryPoint, ownerAddr, 1234) + await deployer.createAccount(ownerAddr, 1234) expect(await isDeployed(target)).to.eq(true) }) }) diff --git a/packages/boba/account-abstraction/test/solidityTypes.ts b/packages/boba/account-abstraction/test/solidityTypes.ts index 08ada8511d..5026ef9e36 100644 --- a/packages/boba/account-abstraction/test/solidityTypes.ts +++ b/packages/boba/account-abstraction/test/solidityTypes.ts @@ -5,6 +5,6 @@ import { BytesLike } from '@ethersproject/bytes' export type address = string export type uint256 = BigNumberish export type uint = BigNumberish -export type uint64 = BigNumberish +export type uint48 = BigNumberish export type bytes = BytesLike export type bytes32 = BytesLike diff --git a/packages/boba/account-abstraction/test/testutils.ts b/packages/boba/account-abstraction/test/testutils.ts index 660de51f84..5204ca57b4 100644 --- a/packages/boba/account-abstraction/test/testutils.ts +++ b/packages/boba/account-abstraction/test/testutils.ts @@ -1,8 +1,21 @@ import { ethers } from 'hardhat' -import { arrayify, getCreate2Address, hexConcat, keccak256, parseEther } from 'ethers/lib/utils' -import { BigNumber, BigNumberish, Contract, ContractReceipt, Wallet } from 'ethers' -import { EntryPoint, EntryPoint__factory, IEntryPoint, IERC20, SimpleAccount__factory, TestAggregatedAccount__factory } from '../typechain' -import { BytesLike, hexValue } from '@ethersproject/bytes' +import { + arrayify, + hexConcat, + keccak256, + parseEther +} from 'ethers/lib/utils' +import { BigNumber, BigNumberish, Contract, ContractReceipt, Signer, Wallet } from 'ethers' +import { + EntryPoint, + EntryPoint__factory, + IERC20, + IEntryPoint, + SimpleAccount, + SimpleAccountFactory__factory, + SimpleAccount__factory, SimpleAccountFactory, TestAggregatedAccountFactory +} from '../typechain' +import { BytesLike } from '@ethersproject/bytes' import { expect } from 'chai' import { Create2Factory } from '../src/Create2Factory' import { debugTransaction } from './debugTx' @@ -68,46 +81,37 @@ export function callDataCost (data: string): number { export async function calcGasUsage (rcpt: ContractReceipt, entryPoint: EntryPoint, beneficiaryAddress?: string): Promise<{ actualGasCost: BigNumberish }> { const actualGas = await rcpt.gasUsed const logs = await entryPoint.queryFilter(entryPoint.filters.UserOperationEvent(), rcpt.blockHash) - const { actualGasCost, actualGasPrice } = logs[0].args + const { actualGasCost, actualGasUsed } = logs[0].args console.log('\t== actual gasUsed (from tx receipt)=', actualGas.toString()) - const calculatedGasUsed = actualGasCost.toNumber() / actualGasPrice.toNumber() - console.log('\t== calculated gasUsed (paid to beneficiary)=', calculatedGasUsed) + console.log('\t== calculated gasUsed (paid to beneficiary)=', actualGasUsed) const tx = await ethers.provider.getTransaction(rcpt.transactionHash) - console.log('\t== gasDiff', actualGas.toNumber() - calculatedGasUsed - callDataCost(tx.data)) + console.log('\t== gasDiff', actualGas.toNumber() - actualGasUsed.toNumber() - callDataCost(tx.data)) if (beneficiaryAddress != null) { expect(await getBalance(beneficiaryAddress)).to.eq(actualGasCost.toNumber()) } return { actualGasCost } } -// helper function to create a deployer (initCode) call to our account. relies on the global "create2Deployer" -// note that this is a very naive deployer: merely calls "create2", which means entire constructor code is passed -// with each deployment. a better deployer will only receive the constructor parameters. -export function getAccountDeployer (entryPoint: string, owner: string): BytesLike { - const accountCtr = new SimpleAccount__factory(ethers.provider.getSigner()).getDeployTransaction(entryPoint, owner).data! - const factory = new Create2Factory(ethers.provider) - const initCallData = factory.getDeployTransactionCallData(hexValue(accountCtr), 0) +// helper function to create the initCode to deploy the account, using our account factory. +export function getAccountInitCode (owner: string, factory: SimpleAccountFactory, salt = 0): BytesLike { return hexConcat([ - Create2Factory.contractAddress, - initCallData + factory.address, + factory.interface.encodeFunctionData('createAccount', [owner, salt]) ]) } -export async function getAggregatedAccountDeployer (entryPoint: string, aggregator: string): Promise { - const accountCtr = await new TestAggregatedAccount__factory(ethers.provider.getSigner()).getDeployTransaction(entryPoint, aggregator).data! - - const factory = new Create2Factory(ethers.provider) - const initCallData = factory.getDeployTransactionCallData(hexValue(accountCtr), 0) +export async function getAggregatedAccountInitCode (entryPoint: string, factory: TestAggregatedAccountFactory, salt = 0): Promise { + // the test aggregated account doesn't check the owner... + const owner = AddressZero return hexConcat([ - Create2Factory.contractAddress, - initCallData + factory.address, + factory.interface.encodeFunctionData('createAccount', [owner, salt]) ]) } // given the parameters as AccountDeployer, return the resulting "counterfactual address" that it would create. -export function getAccountAddress (entryPoint: string, owner: string): string { - const accountCtr = new SimpleAccount__factory(ethers.provider.getSigner()).getDeployTransaction(entryPoint, owner).data! - return getCreate2Address(Create2Factory.contractAddress, HashZero, keccak256(hexValue(accountCtr))) +export async function getAccountAddress (owner: string, factory: SimpleAccountFactory, salt = 0): Promise { + return await factory.getAddress(owner, salt) } const panicCodes: { [key: number]: string } = { @@ -183,6 +187,7 @@ export async function checkForGeth (): Promise { currentNode = await provider.request({ method: 'web3_clientVersion' }) + console.log('node version:', currentNode) // NOTE: must run geth with params: // --http.api personal,eth,net,web3 // --allow-insecure-unlock @@ -190,7 +195,7 @@ export async function checkForGeth (): Promise { for (let i = 0; i < 2; i++) { const acc = await provider.request({ method: 'personal_newAccount', params: ['pass'] }).catch(rethrow) await provider.request({ method: 'personal_unlockAccount', params: [acc, 'pass'] }).catch(rethrow) - await fund(acc) + await fund(acc, '10') } } } @@ -213,7 +218,7 @@ export async function checkForBannedOps (txHash: string, checkPaymaster: boolean const tx = await debugTransaction(txHash) const logs = tx.structLogs const blockHash = logs.map((op, index) => ({ op: op.op, index })).filter(op => op.op === 'NUMBER') - expect(blockHash.length).to.equal(1, 'expected exactly 1 call to NUMBER (Just before validatePaymasterUserOp)') + expect(blockHash.length).to.equal(2, 'expected exactly 2 call to NUMBER (Just before and after validateUserOperation)') const validateAccountOps = logs.slice(0, blockHash[0].index - 1) const validatePaymasterOps = logs.slice(blockHash[0].index + 1) const ops = validateAccountOps.filter(log => log.depth > 1).map(log => log.op) @@ -237,22 +242,22 @@ export async function checkForBannedOps (txHash: string, checkPaymaster: boolean } /** - * process exception of SimulationResult + * process exception of ValidationResult * usage: entryPoint.simulationResult(..).catch(simulationResultCatch) */ export function simulationResultCatch (e: any): any { - if (e.errorName !== 'SimulationResult') { + if (e.errorName !== 'ValidationResult') { throw e } return e.errorArgs } /** - * process exception of SimulationResultWithAggregation + * process exception of ValidationResultWithAggregation * usage: entryPoint.simulationResult(..).catch(simulationResultWithAggregation) */ export function simulationResultWithAggregationCatch (e: any): any { - if (e.errorName !== 'SimulationResultWithAggregation') { + if (e.errorName !== 'ValidationResultWithAggregation') { throw e } return e.errorArgs @@ -278,3 +283,27 @@ export function userOpsWithoutAgg (userOps: UserOperation[]): IEntryPoint.UserOp signature: '0x' }] } + +// Deploys an implementation and a proxy pointing to this implementation +export async function createAccount ( + ethersSigner: Signer, + accountOwner: string, + entryPoint: string, + _factory?: SimpleAccountFactory +): + Promise<{ + proxy: SimpleAccount + accountFactory: SimpleAccountFactory + implementation: string + }> { + const accountFactory = _factory ?? await new SimpleAccountFactory__factory(ethersSigner).deploy(entryPoint) + const implementation = await accountFactory.accountImplementation() + await accountFactory.createAccount(accountOwner, 0) + const accountAddress = await accountFactory.getAddress(accountOwner, 0) + const proxy = SimpleAccount__factory.connect(accountAddress, ethersSigner) + return { + implementation, + accountFactory, + proxy + } +} diff --git a/packages/boba/account-abstraction/test/verifying_paymaster.test.ts b/packages/boba/account-abstraction/test/verifying_paymaster.test.ts index be05db31fb..7a06b0665f 100644 --- a/packages/boba/account-abstraction/test/verifying_paymaster.test.ts +++ b/packages/boba/account-abstraction/test/verifying_paymaster.test.ts @@ -3,17 +3,22 @@ import { ethers } from 'hardhat' import { expect } from 'chai' import { SimpleAccount, - SimpleAccount__factory, EntryPoint, VerifyingPaymaster, VerifyingPaymaster__factory } from '../typechain' import { - createAccountOwner, + createAccount, + createAccountOwner, createAddress, deployEntryPoint, simulationResultCatch } from './testutils' import { fillAndSign } from './UserOp' -import { arrayify, hexConcat, parseEther } from 'ethers/lib/utils' +import { arrayify, defaultAbiCoder, hexConcat, parseEther } from 'ethers/lib/utils' +import { UserOperation } from './UserOperation' + +const MOCK_VALID_UNTIL = '0x00000000deadbeef' +const MOCK_VALID_AFTER = '0x0000000000001234' +const MOCK_SIG = '0x1234' describe('EntryPoint with VerifyingPaymaster', function () { let entryPoint: EntryPoint @@ -24,6 +29,7 @@ describe('EntryPoint with VerifyingPaymaster', function () { let paymaster: VerifyingPaymaster before(async function () { + this.timeout(20000) entryPoint = await deployEntryPoint() offchainSigner = createAccountOwner() @@ -31,15 +37,26 @@ describe('EntryPoint with VerifyingPaymaster', function () { paymaster = await new VerifyingPaymaster__factory(ethersSigner).deploy(entryPoint.address, offchainSigner.address) await paymaster.addStake(1, { value: parseEther('2') }) - await entryPoint.depositTo(paymaster.address, { value: parseEther('1') }) - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner.address) + await entryPoint.depositTo(paymaster.address, { value: parseEther('1') }); + ({ proxy: account } = await createAccount(ethersSigner, accountOwner.address, entryPoint.address)) + }) + + describe('#parsePaymasterAndData', () => { + it('should parse data properly', async () => { + const paymasterAndData = hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), MOCK_SIG]) + console.log(paymasterAndData) + const res = await paymaster.parsePaymasterAndData(paymasterAndData) + expect(res.validUntil).to.be.equal(ethers.BigNumber.from(MOCK_VALID_UNTIL)) + expect(res.validAfter).to.be.equal(ethers.BigNumber.from(MOCK_VALID_AFTER)) + expect(res.signature).equal(MOCK_SIG) + }) }) describe('#validatePaymasterUserOp', () => { it('should reject on no signature', async () => { const userOp = await fillAndSign({ sender: account.address, - paymasterAndData: hexConcat([paymaster.address, '0x1234']) + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), '0x1234']) }, accountOwner, entryPoint) await expect(entryPoint.callStatic.simulateValidation(userOp)).to.be.revertedWith('invalid signature length in paymasterAndData') }) @@ -47,22 +64,47 @@ describe('EntryPoint with VerifyingPaymaster', function () { it('should reject on invalid signature', async () => { const userOp = await fillAndSign({ sender: account.address, - paymasterAndData: hexConcat([paymaster.address, '0x' + '1c'.repeat(65)]) + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), '0x' + '00'.repeat(65)]) }, accountOwner, entryPoint) await expect(entryPoint.callStatic.simulateValidation(userOp)).to.be.revertedWith('ECDSA: invalid signature') }) + describe('with wrong signature', () => { + let wrongSigUserOp: UserOperation + const beneficiaryAddress = createAddress() + before(async () => { + const sig = await offchainSigner.signMessage(arrayify('0xdead')) + wrongSigUserOp = await fillAndSign({ + sender: account.address, + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), sig]) + }, accountOwner, entryPoint) + }) + + it('should return signature error (no revert) on wrong signer signature', async () => { + const ret = await entryPoint.callStatic.simulateValidation(wrongSigUserOp).catch(simulationResultCatch) + expect(ret.returnInfo.sigFailed).to.be.true + }) + + it('handleOp revert on signature failure in handleOps', async () => { + await expect(entryPoint.estimateGas.handleOps([wrongSigUserOp], beneficiaryAddress)).to.revertedWith('AA34 signature error') + }) + }) + it('succeed with valid signature', async () => { const userOp1 = await fillAndSign({ - sender: account.address + sender: account.address, + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), '0x' + '00'.repeat(65)]) }, accountOwner, entryPoint) - const hash = await paymaster.getHash(userOp1) + const hash = await paymaster.getHash(userOp1, MOCK_VALID_UNTIL, MOCK_VALID_AFTER) const sig = await offchainSigner.signMessage(arrayify(hash)) const userOp = await fillAndSign({ ...userOp1, - paymasterAndData: hexConcat([paymaster.address, sig]) + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), sig]) }, accountOwner, entryPoint) - await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) + const res = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) + expect(res.returnInfo.sigFailed).to.be.false + expect(res.returnInfo.validAfter).to.be.equal(ethers.BigNumber.from(MOCK_VALID_AFTER)) + expect(res.returnInfo.validUntil).to.be.equal(ethers.BigNumber.from(MOCK_VALID_UNTIL)) }) }) }) 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 b23e74ec98..c611bd70c2 100644 --- a/packages/boba/account-abstraction/test/verifying_paymaster_boba.test.ts +++ b/packages/boba/account-abstraction/test/verifying_paymaster_boba.test.ts @@ -3,7 +3,6 @@ import { ethers } from 'hardhat' import { expect } from 'chai' import { SimpleAccount, - SimpleAccount__factory, EntryPoint, BobaVerifyingPaymaster, BobaVerifyingPaymaster__factory, @@ -15,11 +14,17 @@ import { TestToken__factory } from '../typechain' import { - createAccountOwner, + createAccount, + createAccountOwner, createAddress, deployEntryPoint, simulationResultCatch } from './testutils' import { fillAndSign } from './UserOp' -import { arrayify, hexConcat, parseEther } from 'ethers/lib/utils' +import { arrayify, defaultAbiCoder, hexConcat, parseEther } from 'ethers/lib/utils' +import { UserOperation } from './UserOperation' + +const MOCK_VALID_UNTIL = '0x00000000deadbeef' +const MOCK_VALID_AFTER = '0x0000000000001234' +const MOCK_SIG = '0x1234' describe('EntryPoint with VerifyingPaymaster', function () { let entryPoint: EntryPoint @@ -33,6 +38,7 @@ describe('EntryPoint with VerifyingPaymaster', function () { let ethOracle: MockFeedRegistry let token: TestToken before(async function () { + this.timeout(20000) entryPoint = await deployEntryPoint() offchainSigner = createAccountOwner() @@ -46,15 +52,26 @@ describe('EntryPoint with VerifyingPaymaster', function () { paymaster = await new BobaVerifyingPaymaster__factory(ethersSigner).deploy(entryPoint.address, offchainSigner.address, depositPaymaster.address, token.address) await paymaster.addStake(1, { value: parseEther('2') }) - await entryPoint.depositTo(paymaster.address, { value: parseEther('1') }) - account = await new SimpleAccount__factory(ethersSigner).deploy(entryPoint.address, accountOwner.address) + await entryPoint.depositTo(paymaster.address, { value: parseEther('1') }); + ({ proxy: account } = await createAccount(ethersSigner, accountOwner.address, entryPoint.address)) + }) + + describe('#parsePaymasterAndData', () => { + it('should parse data properly', async () => { + const paymasterAndData = hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), MOCK_SIG]) + console.log(paymasterAndData) + const res = await paymaster.parsePaymasterAndData(paymasterAndData) + expect(res.validUntil).to.be.equal(ethers.BigNumber.from(MOCK_VALID_UNTIL)) + expect(res.validAfter).to.be.equal(ethers.BigNumber.from(MOCK_VALID_AFTER)) + expect(res.signature).equal(MOCK_SIG) + }) }) describe('#validatePaymasterUserOp', () => { it('should reject on no signature', async () => { const userOp = await fillAndSign({ sender: account.address, - paymasterAndData: hexConcat([paymaster.address, '0x1234']) + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), '0x1234']) }, accountOwner, entryPoint) await expect(entryPoint.callStatic.simulateValidation(userOp)).to.be.revertedWith('invalid signature length in paymasterAndData') }) @@ -62,23 +79,45 @@ describe('EntryPoint with VerifyingPaymaster', function () { it('should reject on invalid signature', async () => { const userOp = await fillAndSign({ sender: account.address, - paymasterAndData: hexConcat([paymaster.address, '0x' + '1c'.repeat(65)]) + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), '0x' + '00'.repeat(65)]) }, accountOwner, entryPoint) await expect(entryPoint.callStatic.simulateValidation(userOp)).to.be.revertedWith('ECDSA: invalid signature') }) + describe('with wrong signature', () => { + let wrongSigUserOp: UserOperation + const beneficiaryAddress = createAddress() + before(async () => { + const sig = await offchainSigner.signMessage(arrayify('0xdead')) + wrongSigUserOp = await fillAndSign({ + sender: account.address, + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), sig]) + }, accountOwner, entryPoint) + }) + + it('should return signature error (no revert) on wrong signer signature', async () => { + const ret = await entryPoint.callStatic.simulateValidation(wrongSigUserOp).catch(simulationResultCatch) + expect(ret.returnInfo.sigFailed).to.be.true + }) + + it('handleOp revert on signature failure in handleOps', async () => { + await expect(entryPoint.estimateGas.handleOps([wrongSigUserOp], beneficiaryAddress)).to.revertedWith('AA34 signature error') + }) + }) + it('should reject on unsupported calldata', async () => { const approveData = token.interface.encodeFunctionData('mint', [account.address, 1000]) const userOp1 = await fillAndSign({ sender: account.address, - callData: account.interface.encodeFunctionData('execFromEntryPoint', [token.address, 0, approveData]) + callData: account.interface.encodeFunctionData('execute', [token.address, 0, approveData]), + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), '0x' + '00'.repeat(65)]) }, accountOwner, entryPoint) - const hash = await paymaster.getHash(userOp1) + const hash = await paymaster.getHash(userOp1, MOCK_VALID_UNTIL, MOCK_VALID_AFTER) const sig = await offchainSigner.signMessage(arrayify(hash)) const userOp = await fillAndSign({ ...userOp1, - paymasterAndData: hexConcat([paymaster.address, sig]) + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), sig]) }, accountOwner, entryPoint) await expect(entryPoint.callStatic.simulateValidation(userOp)).to.be.revertedWith('VerifyingPaymaster: invalid operation') }) @@ -87,14 +126,15 @@ describe('EntryPoint with VerifyingPaymaster', function () { const approveData = token.interface.encodeFunctionData('approve', [paymaster.address, ethers.constants.MaxUint256]) const userOp1 = await fillAndSign({ sender: account.address, - callData: account.interface.encodeFunctionData('execFromEntryPoint', [token.address, 0, approveData]) + callData: account.interface.encodeFunctionData('execute', [token.address, 0, approveData]), + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), '0x' + '00'.repeat(65)]) }, accountOwner, entryPoint) - const hash = await paymaster.getHash(userOp1) + const hash = await paymaster.getHash(userOp1, MOCK_VALID_UNTIL, MOCK_VALID_AFTER) const sig = await offchainSigner.signMessage(arrayify(hash)) const userOp = await fillAndSign({ ...userOp1, - paymasterAndData: hexConcat([paymaster.address, sig]) + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), sig]) }, accountOwner, entryPoint) await expect(entryPoint.callStatic.simulateValidation(userOp)).to.be.revertedWith('VerifyingPaymaster: invalid operation') }) @@ -103,16 +143,20 @@ describe('EntryPoint with VerifyingPaymaster', function () { const approveData = token.interface.encodeFunctionData('approve', [depositPaymaster.address, ethers.constants.MaxUint256]) const userOp1 = await fillAndSign({ sender: account.address, - callData: account.interface.encodeFunctionData('execFromEntryPoint', [token.address, 0, approveData]) + callData: account.interface.encodeFunctionData('execute', [token.address, 0, approveData]), + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), '0x' + '00'.repeat(65)]) }, accountOwner, entryPoint) - const hash = await paymaster.getHash(userOp1) + const hash = await paymaster.getHash(userOp1, MOCK_VALID_UNTIL, MOCK_VALID_AFTER) const sig = await offchainSigner.signMessage(arrayify(hash)) const userOp = await fillAndSign({ ...userOp1, - paymasterAndData: hexConcat([paymaster.address, sig]) + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), sig]) }, accountOwner, entryPoint) - await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) + const res = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) + expect(res.returnInfo.sigFailed).to.be.false + expect(res.returnInfo.validAfter).to.be.equal(ethers.BigNumber.from(MOCK_VALID_AFTER)) + expect(res.returnInfo.validUntil).to.be.equal(ethers.BigNumber.from(MOCK_VALID_UNTIL)) }) it('reject on unsupported deposit calldata', async () => { @@ -123,13 +167,14 @@ describe('EntryPoint with VerifyingPaymaster', function () { const userOp1 = await fillAndSign({ sender: account.address, // incorrect calldata - callData: account.interface.encodeFunctionData('execFromEntryPoint', [depositPaymaster.address, 0, depositData]) + callData: account.interface.encodeFunctionData('execute', [depositPaymaster.address, 0, depositData]), + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), '0x' + '00'.repeat(65)]) }, accountOwner, entryPoint) - const hash = await paymaster.getHash(userOp1) + const hash = await paymaster.getHash(userOp1, MOCK_VALID_UNTIL, MOCK_VALID_AFTER) const sig = await offchainSigner.signMessage(arrayify(hash)) const userOp = await fillAndSign({ ...userOp1, - paymasterAndData: hexConcat([paymaster.address, sig]) + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), sig]) }, accountOwner, entryPoint) await expect(entryPoint.callStatic.simulateValidation(userOp)).to.be.revertedWith('VerifyingPaymaster: invalid operation') }) @@ -139,15 +184,19 @@ describe('EntryPoint with VerifyingPaymaster', function () { const depositData = depositPaymaster.interface.encodeFunctionData('addDepositFor', [token.address, account.address, 0]) const userOp1 = await fillAndSign({ sender: account.address, - callData: account.interface.encodeFunctionData('execFromEntryPoint', [depositPaymaster.address, 0, depositData]) + callData: account.interface.encodeFunctionData('execute', [depositPaymaster.address, 0, depositData]), + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), '0x' + '00'.repeat(65)]) }, accountOwner, entryPoint) - const hash = await paymaster.getHash(userOp1) + const hash = await paymaster.getHash(userOp1, MOCK_VALID_UNTIL, MOCK_VALID_AFTER) const sig = await offchainSigner.signMessage(arrayify(hash)) const userOp = await fillAndSign({ ...userOp1, - paymasterAndData: hexConcat([paymaster.address, sig]) + paymasterAndData: hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), sig]) }, accountOwner, entryPoint) - await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) + const res = await entryPoint.callStatic.simulateValidation(userOp).catch(simulationResultCatch) + expect(res.returnInfo.sigFailed).to.be.false + expect(res.returnInfo.validAfter).to.be.equal(ethers.BigNumber.from(MOCK_VALID_AFTER)) + expect(res.returnInfo.validUntil).to.be.equal(ethers.BigNumber.from(MOCK_VALID_UNTIL)) }) }) }) diff --git a/packages/boba/account-abstraction/test/y.bls.test.ts b/packages/boba/account-abstraction/test/y.bls.test.ts index 673ca0ce9c..16cfcc42a0 100644 --- a/packages/boba/account-abstraction/test/y.bls.test.ts +++ b/packages/boba/account-abstraction/test/y.bls.test.ts @@ -6,18 +6,26 @@ import { BLSSignatureAggregator__factory, BLSAccount, BLSAccount__factory, + BLSAccountFactory, + BLSAccountFactory__factory, + BrokenBLSAccountFactory__factory, EntryPoint } from '../typechain' import { ethers } from 'hardhat' -import { deployEntryPoint, fund, ONE_ETH, simulationResultWithAggregationCatch } from './testutils' +import { createAddress, deployEntryPoint, fund, ONE_ETH, simulationResultWithAggregationCatch } from './testutils' import { DefaultsForUserOp, fillUserOp } from './UserOp' import { expect } from 'chai' import { keccak256 } from 'ethereumjs-util' import { hashToPoint } from '@thehubbleproject/bls/dist/mcl' -import { BigNumber } from 'ethers' +import { BigNumber, Signer } from 'ethers' import { BytesLike, hexValue } from '@ethersproject/bytes' -import { BLSAccountDeployer } from '../typechain/contracts/bls/BLSAccount.sol' -import { BLSAccountDeployer__factory } from '../typechain/factories/contracts/bls/BLSAccount.sol' + +async function deployBlsAccount (ethersSigner: Signer, factoryAddr: string, blsSigner: any): Promise { + const factory = BLSAccountFactory__factory.connect(factoryAddr, ethersSigner) + const addr = await factory.callStatic.createAccount(0, blsSigner.pubkey) + await factory.createAccount(0, blsSigner.pubkey) + return BLSAccount__factory.connect(addr, ethersSigner) +} describe('bls account', function () { this.timeout(20000) @@ -30,12 +38,12 @@ describe('bls account', function () { let entrypoint: EntryPoint let account1: BLSAccount let account2: BLSAccount - let accountDeployer: BLSAccountDeployer + let accountDeployer: BLSAccountFactory before(async () => { entrypoint = await deployEntryPoint() const BLSOpenLib = await new BLSOpen__factory(ethers.provider.getSigner()).deploy() blsAgg = await new BLSSignatureAggregator__factory({ - 'contracts/bls/lib/BLSOpen.sol:BLSOpen': BLSOpenLib.address + 'contracts/samples/bls/lib/BLSOpen.sol:BLSOpen': BLSOpenLib.address }, ethers.provider.getSigner()).deploy() await blsAgg.addStake(entrypoint.address, 2, { value: ONE_ETH }) @@ -43,10 +51,10 @@ describe('bls account', function () { signer1 = fact.getSigner(arrayify(BLS_DOMAIN), '0x01') signer2 = fact.getSigner(arrayify(BLS_DOMAIN), '0x02') - accountDeployer = await new BLSAccountDeployer__factory(etherSigner).deploy() + accountDeployer = await new BLSAccountFactory__factory(etherSigner).deploy(entrypoint.address, blsAgg.address) - account1 = await new BLSAccount__factory(etherSigner).deploy(entrypoint.address, blsAgg.address, signer1.pubkey) - account2 = await new BLSAccount__factory(etherSigner).deploy(entrypoint.address, blsAgg.address, signer2.pubkey) + account1 = await deployBlsAccount(etherSigner, accountDeployer.address, signer1) + account2 = await deployBlsAccount(etherSigner, accountDeployer.address, signer2) }) it('#getTrailingPublicKey', async () => { @@ -91,6 +99,39 @@ describe('bls account', function () { expect(ret).to.equal('0x') }) + it('aggregated sig validation must succeed if off-chain UserOp sig succeeds', async () => { + // regression AA-119: prevent off-chain signature success and on-chain revert. + // "broken account" uses different public-key during construction and runtime. + const brokenAccountFactory = await new BrokenBLSAccountFactory__factory(etherSigner).deploy(entrypoint.address, blsAgg.address) + // const brokenAccountFactory = await new BLSAccountFactory__factory(etherSigner).deploy(entrypoint.address, blsAgg.address) + const deployTx = await brokenAccountFactory.populateTransaction.createAccount(0, signer1.pubkey) + const res = await brokenAccountFactory.provider.call(deployTx) + const acc = brokenAccountFactory.interface.decodeFunctionResult('createAccount', res)[0] + await fund(acc) + const userOp = await fillUserOp({ + sender: acc, + initCode: hexConcat([brokenAccountFactory.address, deployTx.data!]) + }, entrypoint) + const requestHash = await blsAgg.getUserOpHash(userOp) + const signature = userOp.signature = hexConcat(signer1.sign(requestHash)) + + // and sig validation should fail: + const singleOpSigCheck = await blsAgg.validateUserOpSignature(userOp).then(() => 'ok', e => e.message) as string + + // above account should fail on-chain: + const beneficiary = createAddress() + const handleRet = await entrypoint.callStatic.handleAggregatedOps([ + { + userOps: [userOp], + aggregator: blsAgg.address, + signature + } + ], beneficiary).then(() => 'ok', e => e.errorName) as string + + expect(`${singleOpSigCheck},${handleRet}`) + .to.eq('ok,ok') + }) + it('validateSignatures', async function () { // yes, it does take long on hardhat, but quick on geth. this.timeout(30000) @@ -133,11 +174,11 @@ describe('bls account', function () { signer3 = fact.getSigner(arrayify(BLS_DOMAIN), '0x03') initCode = hexConcat([ accountDeployer.address, - accountDeployer.interface.encodeFunctionData('deployAccount', [entrypoint.address, blsAgg.address, 0, signer3.pubkey]) + accountDeployer.interface.encodeFunctionData('createAccount', [0, signer3.pubkey]) ]) }) - it('validate after simulation returns SimulationResultWithAggregation', async () => { + it('validate after simulation returns ValidationResultWithAggregation', async () => { const verifier = new BlsVerifier(BLS_DOMAIN) const senderAddress = await entrypoint.callStatic.getSenderAddress(initCode).catch(e => e.errorArgs.sender) await fund(senderAddress, '0.01') @@ -150,10 +191,10 @@ describe('bls account', function () { const sigParts = signer3.sign(requestHash) userOp.signature = hexConcat(sigParts) - const { aggregationInfo } = await entrypoint.callStatic.simulateValidation(userOp).catch(simulationResultWithAggregationCatch) - expect(aggregationInfo.actualAggregator).to.eq(blsAgg.address) - expect(aggregationInfo.aggregatorStake).to.eq(ONE_ETH) - expect(aggregationInfo.aggregatorUnstakeDelay).to.eq(2) + const { aggregatorInfo } = await entrypoint.callStatic.simulateValidation(userOp).catch(simulationResultWithAggregationCatch) + expect(aggregatorInfo.aggregator).to.eq(blsAgg.address) + expect(aggregatorInfo.stakeInfo.stake).to.eq(ONE_ETH) + expect(aggregatorInfo.stakeInfo.unstakeDelaySec).to.eq(2) const [signature] = defaultAbiCoder.decode(['bytes32[2]'], userOp.signature) const pubkey = (await blsAgg.getUserOpPublicKey(userOp)).map(n => hexValue(n)) // TODO: returns uint256[4], verify needs bytes32[4] diff --git a/packages/boba/account-abstraction/test/z-batch.test.ts b/packages/boba/account-abstraction/test/z-batch.test.ts deleted file mode 100644 index 78f067f2a1..0000000000 --- a/packages/boba/account-abstraction/test/z-batch.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -/* eslint-disable no-unreachable */ -export {} -describe('Batch gas testing', function () { - - it('z-batch.test', async function () { - console.log('this test is currently useless. client need to do better work with preVerificationGas calculation.') - }) -}) - -// import './aa.init' -// import { describe } from 'mocha' -// import { BigNumber, Wallet } from 'ethers' -// import { expect } from 'chai' -// import { -// SimpleWallet, -// SimpleWallet__factory, -// EntryPoint, -// TestCounter, -// TestCounter__factory -// } from '../typechain' -// import { -// createWalletOwner, -// fund, -// checkForGeth, -// rethrow, -// getWalletDeployer, -// tonumber, -// deployEntryPoint, -// callDataCost, createAddress, getWalletAddress, simulationResultCatch -// } from './testutils' -// import { fillAndSign } from './UserOp' -// import { UserOperation } from './UserOperation' -// import { PopulatedTransaction } from 'ethers/lib/ethers' -// import { ethers } from 'hardhat' -// import { toBuffer } from 'ethereumjs-util' -// import { defaultAbiCoder } from 'ethers/lib/utils' - -// describe('Batch gas testing', function () { -// // this test is currently useless. client need to do better work with preVerificationGas calculation. -// // we do need a better recommendation for bundlers how to validate those values before accepting a request. -// return - -// let once = true - -// const ethersSigner = ethers.provider.getSigner() -// let entryPoint: EntryPoint - -// let walletOwner: Wallet -// let wallet: SimpleWallet - -// const results: Array<() => void> = [] -// before(async function () { -// this.skip() - -// await checkForGeth() -// entryPoint = await deployEntryPoint() -// // static call must come from address zero, to validate it can only be called off-chain. -// walletOwner = createWalletOwner() -// wallet = await new SimpleWallet__factory(ethersSigner).deploy(entryPoint.address, await walletOwner.getAddress()) -// await fund(wallet) -// }) - -// after(async () => { -// if (results.length === 0) { -// return -// } -// console.log('== Summary') -// console.log('note: negative "overpaid" means the client should compensate the relayer with higher priority fee') -// for (const result of results) { -// await result() -// } -// }); - -// [1, -// 10 -// ].forEach(maxCount => { -// describe(`test batches maxCount=${maxCount}`, () => { -// /** -// * attempt big batch. -// */ -// let counter: TestCounter -// let walletExecCounterFromEntryPoint: PopulatedTransaction -// let execCounterCount: PopulatedTransaction -// const beneficiaryAddress = createAddress() - -// before(async () => { -// counter = await new TestCounter__factory(ethersSigner).deploy() -// const count = await counter.populateTransaction.count() -// execCounterCount = await wallet.populateTransaction.exec(counter.address, 0, count.data!) -// walletExecCounterFromEntryPoint = await wallet.populateTransaction.execFromEntryPoint(counter.address, 0, count.data!) -// }) - -// const wallets: Array<{ w: string, owner: Wallet }> = [] - -// it('batch of create', async () => { -// const ops: UserOperation[] = [] -// let count = 0 -// const maxTxGas = 12e6 -// let opsGasCollected = 0 -// // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -// while (++count) { -// const walletOwner1 = createWalletOwner() -// const wallet1 = getWalletAddress(entryPoint.address, walletOwner1.address) -// await fund(wallet1, '0.5') -// const op1 = await fillAndSign({ -// initCode: getWalletDeployer(entryPoint.address, walletOwner1.address), -// nonce: 0, -// // callData: walletExecCounterFromEntryPoint.data, -// maxPriorityFeePerGas: 1e9 -// }, walletOwner1, entryPoint) -// // requests are the same, so estimate is the same too. -// const { preOpGas } = await entryPoint.callStatic.simulateValidation(op1, { gasPrice: 1e9 }).catch(simulationResultCatch) -// const txgas = BigNumber.from(preOpGas).add(op1.callGasLimit).toNumber() - -// // console.log('colected so far', opsGasCollected, 'estim', verificationGasLimit, 'max', maxTxGas) -// if (opsGasCollected + txgas > maxTxGas) { -// break -// } -// opsGasCollected += txgas -// ops.push(op1) -// wallets.push({ owner: walletOwner1, w: wallet1 }) -// if (wallets.length >= maxCount) break -// } - -// await call_handleOps_and_stats('Create', ops, count) -// }) - -// it('batch of tx', async function () { -// this.timeout(30000) -// if (wallets.length === 0) { -// this.skip() -// } - -// const ops: UserOperation[] = [] -// for (const { w, owner } of wallets) { -// const op1 = await fillAndSign({ -// sender: w, -// callData: walletExecCounterFromEntryPoint.data, -// maxPriorityFeePerGas: 1e9, -// verificationGasLimit: 1.3e6 -// }, owner, entryPoint) -// ops.push(op1) - -// if (once) { -// once = false -// console.log('direct call:', await counter.estimateGas.count()) -// console.log('through wallet:', await ethers.provider.estimateGas({ -// from: walletOwner.address, -// to: wallet.address, -// data: execCounterCount.data! -// }), 'datacost=', callDataCost(execCounterCount.data!)) -// console.log('through handleOps:', await entryPoint.estimateGas.handleOps([op1], beneficiaryAddress)) -// } -// } - -// await call_handleOps_and_stats('Simple Ops', ops, ops.length) -// }) - -// it('batch of expensive ops', async function () { -// this.timeout(30000) -// if (wallets.length === 0) { -// this.skip() -// } - -// const waster = await counter.populateTransaction.gasWaster(40, '') -// const walletExecFromEntryPoint_waster: PopulatedTransaction = -// await wallet.populateTransaction.execFromEntryPoint(counter.address, 0, waster.data!) - -// const ops: UserOperation[] = [] -// for (const { w, owner } of wallets) { -// const op1 = await fillAndSign({ -// sender: w, -// callData: walletExecFromEntryPoint_waster.data, -// maxPriorityFeePerGas: 1e9, -// verificationGasLimit: 1.3e6 -// }, owner, entryPoint) -// ops.push(op1) -// } - -// await call_handleOps_and_stats('Expensive Ops', ops, ops.length) -// }) - -// it('batch of large ops', async function () { -// this.timeout(30000) -// if (wallets.length === 0) { -// this.skip() -// } - -// const waster = await counter.populateTransaction.gasWaster(0, '1'.repeat(16384)) -// const walletExecFromEntryPoint_waster: PopulatedTransaction = -// await wallet.populateTransaction.execFromEntryPoint(counter.address, 0, waster.data!) - -// const ops: UserOperation[] = [] -// for (const { w, owner } of wallets) { -// const op1 = await fillAndSign({ -// sender: w, -// callData: walletExecFromEntryPoint_waster.data, -// maxPriorityFeePerGas: 1e9, -// verificationGasLimit: 1.3e6 -// }, owner, entryPoint) -// ops.push(op1) -// } - -// await call_handleOps_and_stats('Large (16k) Ops', ops, ops.length) -// }) -// }) -// }) - -// async function call_handleOps_and_stats (title: string, ops: UserOperation[], count: number): Promise { -// const beneficiaryAddress = createAddress() -// const sender = ethersSigner // ethers.provider.getSigner(5) -// const senderPrebalance = await ethers.provider.getBalance(await sender.getAddress()) -// const entireTxEncoded = toBuffer(await entryPoint.populateTransaction.handleOps(ops, beneficiaryAddress).then(tx => tx.data)) - -// function callDataCost (data: Buffer | string): number { -// if (typeof data === 'string') { -// data = toBuffer(data) -// } -// return data.map(b => b === 0 ? 4 : 16).reduce((sum, b) => sum + b) -// } - -// // data cost of entire bundle -// const entireTxDataCost = callDataCost(entireTxEncoded) -// // data cost of a single op in the bundle: -// const handleOpFunc = Object.values(entryPoint.interface.functions).find(func => func.name === 'handleOp')! -// const opEncoding = handleOpFunc.inputs[0] -// const opEncoded = defaultAbiCoder.encode([opEncoding], [ops[0]]) -// const opDataCost = callDataCost(opEncoded) -// console.log('== calldataoverhead=', entireTxDataCost, 'len=', entireTxEncoded.length / 2, 'opcost=', opDataCost, opEncoded.length / 2) -// console.log('== per-op overhead:', entireTxDataCost - (opDataCost * count), 'count=', count) -// // for slack testing, we set TX priority same as UserOp -// // (real miner may create tx with priorityFee=0, to avoid paying from the "sender" to coinbase) -// const { maxPriorityFeePerGas } = ops[0] -// const ret = await entryPoint.connect(sender).handleOps(ops, beneficiaryAddress, { -// gasLimit: 13e6, -// maxPriorityFeePerGas -// }).catch((rethrow())).then(async r => await r!.wait()) -// // const allocatedGas = ops.map(op => parseInt(op.callGasLimit.toString()) + parseInt(op.verificationGasLimit.toString())).reduce((sum, x) => sum + x) -// // console.log('total allocated gas (callGasLimit+verificationGasLimit):', allocatedGas) - -// // remove "revert reason" events -// const events1 = ret.events!.filter((e: any) => e.event === 'UserOperationEvent')! -// // console.log(events1.map(e => ({ev: e.event, ...objdump(e.args!)}))) - -// if (events1.length !== ret.events!.length) { -// console.log('== reverted: ', ret.events!.length - events1.length) -// } -// // note that in theory, each could can have different gasPrice (depends on its prio/max), but in our -// // test they are all the same. -// const { actualGasPrice } = events1[0]!.args! -// const totalEventsGasCost = parseInt(events1.map((x: any) => x.args!.actualGasCost).reduce((sum: any, x: any) => sum.add(x)).toString()) - -// const senderPaid = parseInt(senderPrebalance.sub(await ethers.provider.getBalance(await sender.getAddress())).toString()) -// let senderRedeemed = await ethers.provider.getBalance(beneficiaryAddress).then(tonumber) - -// expect(senderRedeemed).to.equal(totalEventsGasCost) - -// // for slack calculations, add the calldataoverhead. should be part of the relayer fee. -// senderRedeemed += entireTxDataCost * actualGasPrice -// console.log('provider gasprice:', await ethers.provider.getGasPrice()) -// console.log('userop gasPrice:', actualGasPrice) -// const opGasUsed = Math.floor(senderPaid / actualGasPrice / count) -// const opGasPaid = Math.floor(senderRedeemed / actualGasPrice / count) -// console.log('senderPaid= ', senderPaid, '(wei)\t', (senderPaid / actualGasPrice).toFixed(0), '(gas)', opGasUsed, '(gas/op)', count) -// console.log('redeemed= ', senderRedeemed, '(wei)\t', (senderRedeemed / actualGasPrice).toFixed(0), '(gas)', opGasPaid, '(gas/op)') - -// // console.log('slack=', ((senderRedeemed - senderPaid) * 100 / senderPaid).toFixed(2), '%', opGasUsed - opGasPaid) -// const dumpResult = async (): Promise => { -// console.log('==>', `${title} (count=${count}) : `.padEnd(30), 'per-op gas overpaid:', opGasPaid - opGasUsed) -// } -// await dumpResult() -// results.push(dumpResult) -// } -// }) diff --git a/packages/boba/bundler/package.json b/packages/boba/bundler/package.json index 7c4ed67acd..1e17cf0b3d 100644 --- a/packages/boba/bundler/package.json +++ b/packages/boba/bundler/package.json @@ -11,10 +11,10 @@ }, "scripts": { "bundler": "ts-node ./src/exec.ts --config ./localconfig/bundler.config.json", - "build": "tsc -p ./tsconfig.json && hardhat compile", + "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": "yarn test", + "test:coverage:stop": "yarn test", "test-flows": "npx hardhat test --network localhost --grep \"Flow\"", "hardhat-test": "hardhat test --grep '/^((?!Flow).)*$/'", "hardhat-node": "npx hardhat node --no-deploy" diff --git a/packages/boba/bundler/src/UserOpMethodHandler.ts b/packages/boba/bundler/src/UserOpMethodHandler.ts index 8b30f9bb7b..a021a0ad6e 100644 --- a/packages/boba/bundler/src/UserOpMethodHandler.ts +++ b/packages/boba/bundler/src/UserOpMethodHandler.ts @@ -238,7 +238,8 @@ export class UserOpMethodHandler { )}` ) - await this.simulateUserOp(userOp1, entryPointInput) + // 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) diff --git a/packages/boba/bundler/src/runBundler.ts b/packages/boba/bundler/src/runBundler.ts index 8b3ee9e2e8..810ca2c208 100644 --- a/packages/boba/bundler/src/runBundler.ts +++ b/packages/boba/bundler/src/runBundler.ts @@ -57,16 +57,16 @@ function getCommandLineParams(programOpts: any): Partial { export async function connectContractsViaAddressManager ( providerL1: BaseProvider, - providerL2: BaseProvider, + wallet: Wallet, addressManagerAddress: string): Promise<{ entryPoint: EntryPoint, bundlerHelper: BundlerHelper }> { const addressManager = getAddressManager(providerL1, addressManagerAddress) const bundlerHelperAddress = await addressManager.getAddress('L2_Boba_BundlerHelper') const entryPointAddress = await addressManager.getAddress('L2_Boba_EntryPoint') - const entryPoint = EntryPoint__factory.connect(entryPointAddress, providerL2) + const entryPoint = EntryPoint__factory.connect(entryPointAddress, wallet) - const bundlerHelper = BundlerHelper__factory.connect(bundlerHelperAddress, providerL2) + const bundlerHelper = BundlerHelper__factory.connect(bundlerHelperAddress, wallet) return { entryPoint, @@ -185,7 +185,7 @@ export async function runBundler( } let methodHandler: UserOpMethodHandler if (config.addressManager.length > 0) { - const { entryPoint } = await connectContractsViaAddressManager(providerL1, provider, config.addressManager) + const { entryPoint } = await connectContractsViaAddressManager(providerL1, wallet, config.addressManager) config.entryPoint = entryPoint.address methodHandler = new UserOpMethodHandler(provider, wallet, config, entryPoint) } else { diff --git a/packages/boba/bundler_sdk/package.json b/packages/boba/bundler_sdk/package.json index 7487e9f6f2..8217aa72a6 100644 --- a/packages/boba/bundler_sdk/package.json +++ b/packages/boba/bundler_sdk/package.json @@ -8,12 +8,12 @@ "README.md" ], "scripts": { - "build": "tsc -p ./tsconfig.json", + "build:stopp": "tsc -p ./tsconfig.json", "clean": "rimraf dist/ cache node_modules ./tsconfig.tsbuildinfo", "lint": "eslint -f unix .", "lint-fix": "eslint -f unix . --fix", "test": "hardhat test", - "test:coverage": "COVERAGE=1 hardhat coverage", + "test:coverage:stop": "COVERAGE=1 hardhat coverage", "hardhat-test": "hardhat test", "tsc": "tsc", "watch-tsc": "tsc -w --preserveWatchOutput" diff --git a/packages/boba/bundler_sdk/src/BaseWalletAPI.ts b/packages/boba/bundler_sdk/src/BaseWalletAPI.ts index c15fa47ebf..abe36b840a 100644 --- a/packages/boba/bundler_sdk/src/BaseWalletAPI.ts +++ b/packages/boba/bundler_sdk/src/BaseWalletAPI.ts @@ -1,8 +1,9 @@ -import { ethers, BigNumber, BigNumberish } from 'ethers' +import { ethers, BigNumber, BigNumberish, Contract } from 'ethers' import { Provider } from '@ethersproject/providers' import { EntryPoint, EntryPoint__factory, - UserOperationStruct + UserOperationStruct, + SenderCreator__factory } from '@boba/accountabstraction' import { TransactionDetailsForUserOp } from './TransactionDetailsForUserOp' @@ -14,6 +15,7 @@ import { calcPreVerificationGas, GasOverheads } from './calcPreVerificationGas' export interface BaseApiParams { provider: Provider entryPointAddress: string + senderCreatorAddress?: string walletAddress?: string overheads?: Partial paymasterAPI?: PaymasterAPI @@ -46,6 +48,7 @@ export abstract class BaseWalletAPI { provider: Provider overheads?: Partial entryPointAddress: string + senderCreatorAddress?: string walletAddress?: string paymasterAPI?: PaymasterAPI @@ -57,6 +60,7 @@ export abstract class BaseWalletAPI { this.provider = params.provider this.overheads = params.overheads this.entryPointAddress = params.entryPointAddress + this.senderCreatorAddress = params.senderCreatorAddress this.walletAddress = params.walletAddress this.paymasterAPI = params.paymasterAPI @@ -123,12 +127,17 @@ export abstract class BaseWalletAPI { const initCode = this.getWalletInitCode() // use entryPoint to query wallet address (factory can provide a helper method to do the same, but // this method attempts to be generic - try { - await this.entryPointView.callStatic.getSenderAddress(initCode) - } catch (e: any) { - return e.errorArgs.sender + 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') } - throw new Error('must handle revert') } /** @@ -242,12 +251,12 @@ export abstract class BaseWalletAPI { maxPriorityFeePerGas } = info if (maxFeePerGas == null || maxPriorityFeePerGas == null) { - const feeData = await this.provider.getFeeData() + const feeData = await this.provider.getGasPrice() if (maxFeePerGas == null) { - maxFeePerGas = feeData.maxFeePerGas ?? undefined + maxFeePerGas = feeData ?? undefined } if (maxPriorityFeePerGas == null) { - maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? undefined + maxPriorityFeePerGas = feeData.lt(ethers.utils.parseUnits('1', 'gwei')) ? feeData : ethers.utils.parseUnits('1', 'gwei') ?? undefined } } diff --git a/packages/boba/bundler_sdk/src/Provider.ts b/packages/boba/bundler_sdk/src/Provider.ts index 2ca2d5e521..548e7d0ed6 100644 --- a/packages/boba/bundler_sdk/src/Provider.ts +++ b/packages/boba/bundler_sdk/src/Provider.ts @@ -1,4 +1,5 @@ import { JsonRpcProvider } from '@ethersproject/providers' +import { Wallet } from 'ethers' import { EntryPoint__factory, @@ -21,25 +22,36 @@ const debug = Debug('aa.wrapProvider') * @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( originalProvider: JsonRpcProvider, config: ClientConfig, - originalSigner: Signer = originalProvider.getSigner() + originalSigner: Signer = originalProvider.getSigner(), + wallet?: Wallet, + senderCreatorAddress?: string ): Promise { 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) + const detDeployer = new DeterministicDeployer(originalProvider, wallet) const simpleWalletDeployer = await detDeployer.deterministicDeploy( SimpleAccountDeployer__factory.bytecode ) + let smartWalletAPIOwner + if (wallet != null) { + smartWalletAPIOwner = wallet + } else { + smartWalletAPIOwner = originalSigner + } const smartWalletAPI = new SimpleAccountAPI({ provider: originalProvider, entryPointAddress: entryPoint.address, - owner: originalSigner, + senderCreatorAddress: senderCreatorAddress, + owner: smartWalletAPIOwner, factoryAddress: simpleWalletDeployer, paymasterAPI: config.paymasterAPI, }) diff --git a/packages/boba/bundler_sdk/src/calcPreVerificationGas.ts b/packages/boba/bundler_sdk/src/calcPreVerificationGas.ts index 8a55925980..d4abd76c15 100644 --- a/packages/boba/bundler_sdk/src/calcPreVerificationGas.ts +++ b/packages/boba/bundler_sdk/src/calcPreVerificationGas.ts @@ -67,6 +67,9 @@ export function calcPreVerificationGas (userOp: Partial x === 0 ? ov.zeroByte : ov.nonZeroByte).reduce((sum, x) => sum + x) const ret = Math.round( diff --git a/packages/boba/bundler_utils/package.json b/packages/boba/bundler_utils/package.json index 28bb0bd420..4d19b93cd1 100644 --- a/packages/boba/bundler_utils/package.json +++ b/packages/boba/bundler_utils/package.json @@ -11,7 +11,7 @@ "README.md" ], "scripts": { - "build": "tsc -p ./tsconfig.json && hardhat compile", + "build:stopp": "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/gateway/package.json b/packages/boba/gateway/package.json index 5e3e379224..8cb8b3596a 100644 --- a/packages/boba/gateway/package.json +++ b/packages/boba/gateway/package.json @@ -35,6 +35,7 @@ "axios": "^0.21.1", "bignumber.js": "^9.0.1", "bn.js": "^5.1.3", + "dayjs": "^1.11.7", "dotenv": "^8.2.0", "eslint-config-react-app": "^7.0.0", "ethers": "^5.5.4", @@ -42,7 +43,6 @@ "html-react-parser": "^1.4.0", "human-standard-token-abi": "^2.0.0", "lodash": "^4.17.21", - "moment": "^2.24.0", "node-forge": "^1.3.1", "patch-package": "^6.2.2", "react": "^17.0.2", diff --git a/packages/boba/gateway/src/components/Table/TransactionTableRow.js b/packages/boba/gateway/src/components/Table/TransactionTableRow.js index cb3850960e..2dc6090a00 100644 --- a/packages/boba/gateway/src/components/Table/TransactionTableRow.js +++ b/packages/boba/gateway/src/components/Table/TransactionTableRow.js @@ -9,7 +9,7 @@ import L2ToL1Icon from 'components/icons/L2ToL1Icon'; import LinkIcon from 'components/icons/LinkIcon'; import UpIcon from 'components/icons/UpIcon'; -import moment from 'moment'; +import {formatDate} from 'util/dates'; import truncate from 'truncate-middle'; import { @@ -63,7 +63,7 @@ function TransactionTableRow({ chainLink, index, ...data }) { }} > Swapped - {moment.unix(data.timeStamp).format('lll')} + {formatDate(data.timeStamp,'lll' )} diff --git a/packages/boba/gateway/src/components/availableBridges/availableBridges.js b/packages/boba/gateway/src/components/availableBridges/availableBridges.js index b54967c579..9ea7e314ab 100644 --- a/packages/boba/gateway/src/components/availableBridges/availableBridges.js +++ b/packages/boba/gateway/src/components/availableBridges/availableBridges.js @@ -4,11 +4,25 @@ import * as S from './availableBridges.styles' import { Link, Typography } from '@mui/material' import networkService from 'services/networkService' +import { BANXA_URL } from 'util/constant' -function AvailableBridges({ token = null }) { +function AvailableBridges({ token = null, walletAddress = "" }) { const [ bridges, setBridges ] = useState([]) + const banxaUrl = () => { + const banxaUrl = BANXA_URL; + const config = { + coinType: 'ETH', + fiatType: 'USD', + fiatAmount: '', + blockChain: 'BOBA', + walletAddress: walletAddress + } + + return `${banxaUrl}coinType=${config.coinType}&fiatType=${config.fiatType}&fiatAmount=${config.fiatAmount}&blockchain=${config.blockChain}&walletAddress=${walletAddress}` + } + useEffect(() => { if (token) { let res = networkService.getTokenSpecificBridges(token.symbol) @@ -18,11 +32,22 @@ function AvailableBridges({ token = null }) { return - - Third party bridges - + + Third party bridges + + + + Banxa + + {bridges.map((bridge) => { return ({ backdropFilter: 'blur(50px)', flex: 1, minHeight: 'fit-content', - padding: '20px', + padding: '24px', width: '100%', + maxWidth: '600px', })) export const Wrapper = styled(Box)(({ theme }) => ({ - maxHeight: '300px', - overflowY: 'scroll', display: 'flex', flexDirection: 'column', gap: '5px' @@ -29,7 +28,7 @@ export const LabelContainer = styled(Box)(({ theme }) => ({ margin: '10px 0px' })) -export const BridgeContent = styled(Box)(({ theme, border }) => ({ +export const BridgeContent = styled(Box)(({ theme }) => ({ borderRadius: theme.palette.primary.borderRadius, background: theme.palette.background.secondaryLight, padding: '5px 10px', diff --git a/packages/boba/gateway/src/components/disconnect/Disconnect.js b/packages/boba/gateway/src/components/disconnect/Disconnect.js index d98b11b429..c1ab62499c 100644 --- a/packages/boba/gateway/src/components/disconnect/Disconnect.js +++ b/packages/boba/gateway/src/components/disconnect/Disconnect.js @@ -14,34 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; -import { useDispatch } from 'react-redux'; import { LoginOutlined } from '@mui/icons-material'; import { IconButton, Tooltip } from '@mui/material'; -import { - setLayer, - setConnect, - setConnectBOBA, - setConnectETH, - setEnableAccount, - setWalletConnected - } from 'actions/setupAction'; - -import networkService from 'services/networkService'; +import useDisconnect from 'hooks/useDisconnect'; function Disconnect () { - const dispatch = useDispatch(); - - const disconnect = async () => { - await networkService.walletService.disconnectWallet() - dispatch(setLayer(null)) - dispatch(setConnect(false)) - dispatch(setConnectBOBA(false)) - dispatch(setConnectETH(false)) - dispatch(setWalletConnected(false)) - dispatch(setEnableAccount(false)) - } + const { disconnect } = useDisconnect(); return ( <> diff --git a/packages/boba/gateway/src/components/icons/chain/L1/FantomIcon.js b/packages/boba/gateway/src/components/icons/chain/L1/FantomIcon.js index c7d9bcc6a1..bd209c22dc 100644 --- a/packages/boba/gateway/src/components/icons/chain/L1/FantomIcon.js +++ b/packages/boba/gateway/src/components/icons/chain/L1/FantomIcon.js @@ -1,6 +1,6 @@ import * as React from "react" -function EthereumIcon({ selected = false }) { +function FantomIcon({ selected = false }) { if (!selected) { @@ -21,4 +21,4 @@ function EthereumIcon({ selected = false }) { ) } -export default EthereumIcon +export default FantomIcon diff --git a/packages/boba/gateway/src/components/icons/chain/L1/MoonbeamIcon.js b/packages/boba/gateway/src/components/icons/chain/L1/MoonbeamIcon.js index b2be77d929..c6f9613125 100644 --- a/packages/boba/gateway/src/components/icons/chain/L1/MoonbeamIcon.js +++ b/packages/boba/gateway/src/components/icons/chain/L1/MoonbeamIcon.js @@ -6,9 +6,9 @@ function MoonbeamIcon({ selected = false }) { return - + - + @@ -21,9 +21,9 @@ function MoonbeamIcon({ selected = false }) { return ( - + - + diff --git a/packages/boba/gateway/src/components/icons/chain/L2/BobaFantomIcon.js b/packages/boba/gateway/src/components/icons/chain/L2/BobaFantomIcon.js index 1ce663a5f7..b0d7826f6c 100644 --- a/packages/boba/gateway/src/components/icons/chain/L2/BobaFantomIcon.js +++ b/packages/boba/gateway/src/components/icons/chain/L2/BobaFantomIcon.js @@ -1,7 +1,7 @@ import * as React from "react" -function EthereumIcon({ selected = false }) { +function BobaFantomIcon({ selected = false }) { if (!selected) { return @@ -47,4 +47,4 @@ function EthereumIcon({ selected = false }) { ) } -export default EthereumIcon +export default BobaFantomIcon diff --git a/packages/boba/gateway/src/components/icons/chain/L2/BobaIcon.js b/packages/boba/gateway/src/components/icons/chain/L2/BobaIcon.js index 359b5741cf..7867e23d77 100644 --- a/packages/boba/gateway/src/components/icons/chain/L2/BobaIcon.js +++ b/packages/boba/gateway/src/components/icons/chain/L2/BobaIcon.js @@ -1,6 +1,6 @@ import * as React from "react" -function EthereumIcon({ selected = false }) { +function BobaIcon({ selected = false }) { if (!selected) { return @@ -56,4 +56,4 @@ function EthereumIcon({ selected = false }) { } -export default EthereumIcon +export default BobaIcon diff --git a/packages/boba/gateway/src/components/icons/chain/L2/BobabeamIcon.js b/packages/boba/gateway/src/components/icons/chain/L2/BobabeamIcon.js index 47976fcfe5..5224d2bbcf 100644 --- a/packages/boba/gateway/src/components/icons/chain/L2/BobabeamIcon.js +++ b/packages/boba/gateway/src/components/icons/chain/L2/BobabeamIcon.js @@ -1,6 +1,6 @@ import * as React from "react" -function EthereumIcon({ selected = false }) { +function BobaBeamIcon({ selected = false }) { if (!selected) { return @@ -8,21 +8,21 @@ function EthereumIcon({ selected = false }) { - + - + - + - - + + - - + + - + } @@ -35,7 +35,7 @@ function EthereumIcon({ selected = false }) { - + @@ -43,7 +43,7 @@ function EthereumIcon({ selected = false }) { - + @@ -52,4 +52,4 @@ function EthereumIcon({ selected = false }) { ) } -export default EthereumIcon +export default BobaBeamIcon diff --git a/packages/boba/gateway/src/components/listContract/listContract.js b/packages/boba/gateway/src/components/listContract/listContract.js index 16d0424433..f5698ff5c8 100644 --- a/packages/boba/gateway/src/components/listContract/listContract.js +++ b/packages/boba/gateway/src/components/listContract/listContract.js @@ -1,6 +1,7 @@ import React from 'react' import { connect } from 'react-redux' -import { isEqual } from 'lodash' +import { isEqual } from 'util/lodash'; + import Button from 'components/button/Button' import { Typography } from '@mui/material' import * as S from "./ListContract.styles" diff --git a/packages/boba/gateway/src/components/listEarn/ListEarn.js b/packages/boba/gateway/src/components/listEarn/ListEarn.js index 43cb039861..b61cb3e346 100644 --- a/packages/boba/gateway/src/components/listEarn/ListEarn.js +++ b/packages/boba/gateway/src/components/listEarn/ListEarn.js @@ -1,6 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; -import { isEqual } from 'lodash'; +import { isEqual } from 'util/lodash'; + import { logAmount, powAmount } from 'util/amountConvert'; import { BigNumber } from 'ethers'; @@ -261,7 +262,7 @@ class ListEarn extends React.Component { logo
{symbol} - {name} + {name}
diff --git a/packages/boba/gateway/src/components/listEarn/ListEarn.styles.js b/packages/boba/gateway/src/components/listEarn/ListEarn.styles.js index 22402d8933..c754a41fde 100644 --- a/packages/boba/gateway/src/components/listEarn/ListEarn.styles.js +++ b/packages/boba/gateway/src/components/listEarn/ListEarn.styles.js @@ -18,7 +18,8 @@ export const Wrapper = styled(Box)(({ theme, ...props }) => ({ export const GridContainer = styled(Grid)(({ theme }) => ({ background: theme.palette.background.glassy, - margin: '-10px -8px', + margin: '-6px', + paddingBottom: '10px', [theme.breakpoints.down('md')]:{ justifyContent: 'flex-start', width: '100%', @@ -48,13 +49,13 @@ export const DropdownWrapper = styled(Box)` `; export const DropdownContent = styled(Box)(({ theme }) => ({ - width: '80%', + width: '100%', display: 'flex', justifyContent: 'space-between', backgroundColor: theme.palette.background.glassy, - borderRadius: '20px', - margin: '5px', - padding: '20px 40px', + borderRadius: theme.palette.primary.borderRadius, + margin: '5px 0px', + padding: '20px', [theme.breakpoints.down('md')]: { flexDirection: 'column', gap: '5px', diff --git a/packages/boba/gateway/src/components/listNFT/listNFT.js b/packages/boba/gateway/src/components/listNFT/listNFT.js index f0a728152b..e9f6a5530e 100644 --- a/packages/boba/gateway/src/components/listNFT/listNFT.js +++ b/packages/boba/gateway/src/components/listNFT/listNFT.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ import { Typography } from '@mui/material' -import { isEqual } from 'lodash' +import { isEqual } from 'util/lodash'; import React from 'react' import { connect } from 'react-redux' import ReactCardFlip from 'react-card-flip' diff --git a/packages/boba/gateway/src/components/listProposal/listProposal.js b/packages/boba/gateway/src/components/listProposal/listProposal.js index d7ada031da..5808a6525c 100644 --- a/packages/boba/gateway/src/components/listProposal/listProposal.js +++ b/packages/boba/gateway/src/components/listProposal/listProposal.js @@ -19,10 +19,10 @@ import { makeStyles } from '@mui/styles' import { executeProposal, queueProposal } from 'actions/daoAction' import { openAlert, openModal } from 'actions/uiAction' import Button from 'components/button/Button' -import moment from 'moment' import React, { useEffect, useState } from 'react' import { useDispatch } from 'react-redux' import * as S from "./listProposal.styles" +import {formatDate} from 'util/dates'; const useStyles = makeStyles({ colorPrimary: { @@ -87,8 +87,8 @@ function ListProposal({ return <>{description}; } - const startTime = moment.unix(proposal.startTimestamp).format('lll') - const endTime = moment.unix(proposal.endTimestamp).format('lll') + const startTime = formatDate(proposal.startTimestamp,'lll'); + const endTime = formatDate(proposal.endTimestamp,'lll'); let hasVoted = proposal.hasVoted diff --git a/packages/boba/gateway/src/components/listSave/listSave.js b/packages/boba/gateway/src/components/listSave/listSave.js index 3aa92d48b4..e9aefcbbf2 100644 --- a/packages/boba/gateway/src/components/listSave/listSave.js +++ b/packages/boba/gateway/src/components/listSave/listSave.js @@ -1,10 +1,9 @@ import React from 'react' import { connect } from 'react-redux' -import { isEqual } from 'lodash' +import { isEqual } from 'util/lodash'; import { openAlert, openError } from 'actions/uiAction' -import moment from 'moment' - +import {formatDate} from 'util/dates' import Button from 'components/button/Button' // eslint-disable-next-line no-unused-vars import { Box, Typography, LinearProgress } from '@mui/material' @@ -66,7 +65,7 @@ class ListSave extends React.Component { } = this.state const timeDeposit_S = stakeInfo.depositTimestamp - const timeDeposit = moment.unix(timeDeposit_S).format('MM/DD/YYYY hh:mm a') + const timeDeposit = formatDate(timeDeposit_S) const timeNow_S = Math.round(Date.now() / 1000) let duration_S = timeNow_S - timeDeposit_S @@ -77,8 +76,8 @@ class ListSave extends React.Component { const residual_S = duration_S % (twoWeeks + twoDays) const timeZero_S = timeNow_S - residual_S - const unlocktimeNextBegin = moment.unix(timeZero_S + twoWeeks).format('MM/DD/YYYY hh:mm a') - const unlocktimeNextEnd = moment.unix(timeZero_S + twoWeeks + twoDays).format('MM/DD/YYYY hh:mm a') + const unlocktimeNextBegin = formatDate(timeZero_S + twoWeeks) + const unlocktimeNextEnd = formatDate(timeZero_S + twoWeeks + twoDays) let locked = true if (residual_S > twoWeeks) locked = false diff --git a/packages/boba/gateway/src/components/listToken/listToken.js b/packages/boba/gateway/src/components/listToken/listToken.js index 5b607a0161..d6104220c7 100644 --- a/packages/boba/gateway/src/components/listToken/listToken.js +++ b/packages/boba/gateway/src/components/listToken/listToken.js @@ -19,8 +19,11 @@ import { amountToUsd, logAmount } from 'util/amountConvert' import { getCoinImage } from 'util/coinImage' import * as S from './listToken.styles' import { BRIDGE_TYPE } from 'util/constant' +import { BN } from 'bn.js' -function ListToken({ token, chain, networkLayer, disabled, loading }) { +function ListToken({ token, chain, networkLayer, disabled, loading, + showBalanceToken +}) { const [dropDownBox, setDropDownBox] = useState(false) const theme = useTheme() @@ -75,6 +78,10 @@ function ListToken({ token, chain, networkLayer, disabled, loading }) { await dispatch(settle_v3OLO()) } + if (showBalanceToken && token.balance.lte(new BN(1000000))) { + return null; + } + if (isMobile) { return ( @@ -140,7 +147,7 @@ function ListToken({ token, chain, networkLayer, disabled, loading }) { BRIDGE_TYPE.CLASSIC_BRIDGE ) }} - color="secondary" + color="primary" variant="outlined" disabled={disabled} tooltip="Classic Bridge to Boba L2. This option is always available but is generally more expensive than the swap-based system ('Fast Bridge')." diff --git a/packages/boba/gateway/src/components/listToken/listToken.styles.js b/packages/boba/gateway/src/components/listToken/listToken.styles.js index 2da77fa83e..321e9b4a4b 100644 --- a/packages/boba/gateway/src/components/listToken/listToken.styles.js +++ b/packages/boba/gateway/src/components/listToken/listToken.styles.js @@ -20,7 +20,9 @@ export const TableBody = styled(Box)(({ theme }) => ({ }, })) -export const TableCell = styled(Box)(({ theme, isMobile }) => ({ +export const TableCell = styled(Box, { + shouldForwardProp: (prop) => prop !== 'isMobile' +})(({ theme, isMobile }) => ({ display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/packages/boba/gateway/src/components/mainMenu/feeSwitcher/FeeSwitcher.js b/packages/boba/gateway/src/components/mainMenu/feeSwitcher/FeeSwitcher.js index 17bd1d3b4b..21c4017cd0 100644 --- a/packages/boba/gateway/src/components/mainMenu/feeSwitcher/FeeSwitcher.js +++ b/packages/boba/gateway/src/components/mainMenu/feeSwitcher/FeeSwitcher.js @@ -30,7 +30,7 @@ import { switchFee } from 'actions/setupAction.js' import * as S from './FeeSwitcher.styles.js' import Select from 'components/select/Select' import Tooltip from 'components/tooltip/Tooltip.js' -import { isEqual } from 'lodash' +import { isEqual } from 'util/lodash'; import BN from 'bignumber.js' import { logAmount } from 'util/amountConvert.js' diff --git a/packages/boba/gateway/src/components/mainMenu/layerSwitcher/LayerSwitcher.js b/packages/boba/gateway/src/components/mainMenu/layerSwitcher/LayerSwitcher.js index 66c937e928..6b78f7e317 100644 --- a/packages/boba/gateway/src/components/mainMenu/layerSwitcher/LayerSwitcher.js +++ b/packages/boba/gateway/src/components/mainMenu/layerSwitcher/LayerSwitcher.js @@ -22,7 +22,6 @@ import { IconButton, } from '@mui/material' import { useTheme } from '@mui/styles' -import { setConnect, setConnectBOBA, setConnectETH, setLayer, setWalletConnected } from 'actions/setupAction.js' import React, { useCallback, useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' @@ -34,7 +33,7 @@ import { selectConnectBOBA, selectConnect, selectWalletConnected, - selectChainIdChanged, + selectChainIdChanged } from 'selectors/setupSelector' import { @@ -48,7 +47,15 @@ import * as S from './LayerSwitcher.styles.js' import networkService from 'services/networkService' import truncate from 'truncate-middle' -import { setEnableAccount, setWalletAddress } from 'actions/setupAction' +import { + setEnableAccount, + setWalletAddress, + setConnect, + setConnectBOBA, + setConnectETH, + setLayer, + setWalletConnected +} from 'actions/setupAction' import { fetchTransactions } from 'actions/networkAction' @@ -128,7 +135,7 @@ function LayerSwitcher({ visisble = true, isButton = false }) { try { if (networkService.walletService.provider) { if (await networkService.switchChain(layer)) { - if (layer === 'L1') { + if (layer === 'L2') { dispatch(setConnectBOBA(false)) } else { dispatch(setConnectETH(false)) diff --git a/packages/boba/gateway/src/components/mainMenu/menuItems/MenuItems.js b/packages/boba/gateway/src/components/mainMenu/menuItems/MenuItems.js index ab840cf258..536b53c049 100644 --- a/packages/boba/gateway/src/components/mainMenu/menuItems/MenuItems.js +++ b/packages/boba/gateway/src/components/mainMenu/menuItems/MenuItems.js @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react' import { useSelector } from 'react-redux' -import { intersection } from 'lodash' +import { intersection } from 'util/lodash'; + import { selectMonster } from 'selectors/setupSelector' import { MENU_LIST } from './menu.config' import { useLocation } from 'react-router-dom' diff --git a/packages/boba/gateway/src/components/mainMenu/menuItems/menu.config.js b/packages/boba/gateway/src/components/mainMenu/menuItems/menu.config.js index 3bed2788a9..c31acc95e2 100644 --- a/packages/boba/gateway/src/components/mainMenu/menuItems/menu.config.js +++ b/packages/boba/gateway/src/components/mainMenu/menuItems/menu.config.js @@ -56,11 +56,5 @@ export const MENU_LIST = [ icon: "VoteIcon", title: "Dao", url: ROUTES_PATH.DAO - }, - { - key: 'LinksToBobaChains', - icon: "LinksToBobaChainsIcon", - title: "BOBA Chains", - url: ROUTES_PATH.BOBA_CHAINS } ] diff --git a/packages/boba/gateway/src/components/pulse/PulsingBadge.js b/packages/boba/gateway/src/components/pulse/PulsingBadge.js index ede9cff4bd..d4c7645ead 100644 --- a/packages/boba/gateway/src/components/pulse/PulsingBadge.js +++ b/packages/boba/gateway/src/components/pulse/PulsingBadge.js @@ -140,16 +140,16 @@ const PulsingBadge = ({ const classes = useStyles(); return ( -
-
-
+ + {children} ); }; -export default PulsingBadge; \ No newline at end of file +export default PulsingBadge; diff --git a/packages/boba/gateway/src/components/transaction/Transaction.js b/packages/boba/gateway/src/components/transaction/Transaction.js index 2ad1257f30..7f7e8b2d11 100644 --- a/packages/boba/gateway/src/components/transaction/Transaction.js +++ b/packages/boba/gateway/src/components/transaction/Transaction.js @@ -24,9 +24,11 @@ import { useTheme } from '@emotion/react' import truncate from 'truncate-middle' import networkService from 'services/networkService' +import { formatDate } from 'util/dates' function Transaction({ time, + timeLabel = null, chain, typeTX, blockNumber, @@ -166,7 +168,7 @@ function Transaction({ variant="body2" color="fade" > - {time} + {timeLabel ? timeLabel : formatDate(time, 'lll')} {completion === '' && ( ({ alignItems: 'center', '&.active': { color: '#031313', - background: '#BAE21A', + background: theme.palette.mode === 'dark'? '#BAE21A' :'#1CD6D1', } }, [ theme.breakpoints.down('sm') ]: { diff --git a/packages/boba/gateway/src/containers/VoteAndDao/Dao/Dao.js b/packages/boba/gateway/src/containers/VoteAndDao/Dao/Dao.js index d7c31114e0..ad1e7ad75c 100644 --- a/packages/boba/gateway/src/containers/VoteAndDao/Dao/Dao.js +++ b/packages/boba/gateway/src/containers/VoteAndDao/Dao/Dao.js @@ -18,7 +18,7 @@ import { useDispatch, useSelector } from 'react-redux' import { Box, Typography } from '@mui/material' import { openError, openModal } from 'actions/uiAction' -import { orderBy } from 'lodash' +import { orderBy } from 'util/lodash'; import Button from 'components/button/Button' import ListProposal from 'components/listProposal/listProposal' diff --git a/packages/boba/gateway/src/containers/bobaScope/BobaScope.js b/packages/boba/gateway/src/containers/bobaScope/BobaScope.js index b1c88027b6..6a5a8b0d55 100644 --- a/packages/boba/gateway/src/containers/bobaScope/BobaScope.js +++ b/packages/boba/gateway/src/containers/bobaScope/BobaScope.js @@ -15,7 +15,9 @@ limitations under the License. */ import React, { useEffect, useState } from 'react' import { useDispatch } from 'react-redux' -import { isEqual, orderBy } from 'lodash' +import { isEqual,orderBy } from 'util/lodash'; + + import { useSelector } from 'react-redux' import "react-datepicker/dist/react-datepicker.css" diff --git a/packages/boba/gateway/src/containers/bobaScope/FastExits.js b/packages/boba/gateway/src/containers/bobaScope/FastExits.js index a723245771..8386207b1e 100644 --- a/packages/boba/gateway/src/containers/bobaScope/FastExits.js +++ b/packages/boba/gateway/src/containers/bobaScope/FastExits.js @@ -16,7 +16,7 @@ limitations under the License. */ import React, { useState, useEffect } from 'react' import { Grid, Box } from '@mui/material' import { useSelector } from 'react-redux' -import moment from 'moment' +import {formatDate} from 'util/dates' import { selectLoading } from 'selectors/loadingSelector' import Pager from 'components/pager/Pager' @@ -75,7 +75,7 @@ function FastExits({ searchData, data }) { title={`Hash: ${i.hash}`} blockNumber={`Block ${i.blockNumber}`} oriHash={i.hash} - age={moment.unix(i.timestamp).format('lll')} + age={formatDate(i.timestamp, 'lll')} unixTime={i.timestamp} /> ) diff --git a/packages/boba/gateway/src/containers/bobaScope/Sevens.js b/packages/boba/gateway/src/containers/bobaScope/Sevens.js index a04e3fd5b7..0da1dc15c9 100644 --- a/packages/boba/gateway/src/containers/bobaScope/Sevens.js +++ b/packages/boba/gateway/src/containers/bobaScope/Sevens.js @@ -16,7 +16,7 @@ limitations under the License. */ import React, { useState, useEffect } from 'react' import { Grid, Box } from '@mui/material' import { useSelector } from 'react-redux' -import moment from 'moment' +import {formatDate} from 'util/dates' import { selectLoading } from 'selectors/loadingSelector' import Pager from 'components/pager/Pager' @@ -75,7 +75,7 @@ function Sevens({ searchData, sevens }) { title={`Hash: ${i.hash}`} blockNumber={`Block ${i.blockNumber}`} oriHash={i.hash} - age={moment.unix(i.timestamp).format('lll')} + age={formatDate(i.timestamp, 'lll')} unixTime={i.timestamp} /> ) diff --git a/packages/boba/gateway/src/containers/bridge/Bridge.styles.js b/packages/boba/gateway/src/containers/bridge/Bridge.styles.js index 5ccfc186a6..d74ffe9741 100644 --- a/packages/boba/gateway/src/containers/bridge/Bridge.styles.js +++ b/packages/boba/gateway/src/containers/bridge/Bridge.styles.js @@ -51,7 +51,7 @@ export const ContentWrapper = styled(Box)(({ theme }) => ({ display: 'flex', justifyContent: 'space-between', width: '100%', - alignItems: 'flex-center', + alignItems: 'flex-start', gap: '20px', [ theme.breakpoints.down('sm') ]: { flexDirection: 'column', @@ -66,7 +66,7 @@ export const TitleContainer = styled(Box)(({ theme }) => ({ display: 'flex', justifyContent: 'center', alignItems: 'center', - padding: '70px 50px', + padding: '50px', position: 'relative', minHeight: 'auto', [ theme.breakpoints.down('sm') ]: { diff --git a/packages/boba/gateway/src/containers/bridge/bobaBridge/bobaBridge.js b/packages/boba/gateway/src/containers/bridge/bobaBridge/bobaBridge.js index f96269c525..88a3eaa468 100644 --- a/packages/boba/gateway/src/containers/bridge/bobaBridge/bobaBridge.js +++ b/packages/boba/gateway/src/containers/bridge/bobaBridge/bobaBridge.js @@ -16,7 +16,7 @@ along with this program. If not, see . */ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useNavigate } from 'react-router-dom' @@ -29,7 +29,7 @@ import * as S from './bobaBridge.styles' import BridgeTransfer from './bridgeTransfer/bridgeTransfer' -import { selectAccountEnabled, selectLayer } from 'selectors/setupSelector' +import { selectAccountEnabled, selectLayer, selectWalletAddress } from 'selectors/setupSelector' import { selectBridgeTokens, selectMultiBridgeMode, @@ -53,6 +53,7 @@ function BobaBridge() { const tokens = useSelector(selectBridgeTokens()) const networkName = useSelector(selectActiveNetworkName()) const icon = useSelector(selectActiveNetworkIcon()) + const userWallet = useSelector(selectWalletAddress()) const L1Icon = L1_ICONS[icon] const L2Icon = L2_ICONS[icon] @@ -62,31 +63,38 @@ function BobaBridge() { const theme = useTheme() const iconColor = theme.palette.mode === 'dark' ? '#fff' : '#000' + const isL1 = layer === LAYER.L1; const navigate = useNavigate() - async function connectToETH() { - dispatch(setConnectETH(true)) - } - async function connectToBOBA() { - dispatch(setConnectBOBA(true)) - } + useEffect(() => { + setToL2(isL1); + }, [isL1]); async function switchDirection() { if (accountEnabled) { - if (layer === LAYER.L1) dispatch(setConnectBOBA(true)) + if (isL1) dispatch(setConnectBOBA(true)) else dispatch(setConnectETH(true)) } else { setToL2(!toL2) } } + const handelConnect = async () => { + await dispatch(toL2 ? setConnectETH(true) : setConnectBOBA(true)) + } + + const handleMultiBridge = async () => { + await dispatch(multibridgeMode ? resetToken() : setMultiBridgeMode(!multibridgeMode)) + } + const L1ChainLabel = () => { return ( {networkName[ 'l1' ] || DEFAULT_NETWORK.NAME.L1} ) } + const L2ChainLabel = () => { return ( @@ -117,81 +125,33 @@ function BobaBridge() { } - if (!accountEnabled) { - return ( - - - - Bridge - - Select the bridge direction - - - - - - - From - - - {toL2? : } - - - - - - To - - - {toL2? : } - - - - - - - - - ) - } - + const Bridge = () => { + const config = () => ({ + from: toL2? : , + to: toL2? : + }); - return ( - <> - + return ( + <> + From - {layer === 'L1' ? : } + { config().from } @@ -201,14 +161,50 @@ function BobaBridge() { To - {layer === 'L2' ? : } + { config().to } + + {!userWallet && + + + + } + + ) + } + + if (!accountEnabled) { + return ( + + + + Bridge + + Select the bridge direction + + + + ) + } + return ( + <> - {layer === 'L1' && !multibridgeMode && tokens.length < 1 && ( + + + + {isL1 && !multibridgeMode && tokens.length < 1 && ( Bridge multiple tokens at once? @@ -233,12 +229,7 @@ function BobaBridge() { }, }, }} - onChange={() => { - if (multibridgeMode) { - dispatch(resetToken()) - } - dispatch(setMultiBridgeMode(!multibridgeMode)) - }} + onChange={() => handleMultiBridge } /> )} @@ -246,14 +237,14 @@ function BobaBridge() { - {tokens.length === 1 && } + {tokens.length === 1 && } { navigate('/history') }} - display="flex" - justifyContent="center" > {'Transaction History >'} diff --git a/packages/boba/gateway/src/containers/bridge/bobaBridge/bridgeTransfer/bridgeTransfer.js b/packages/boba/gateway/src/containers/bridge/bobaBridge/bridgeTransfer/bridgeTransfer.js index 389e62c112..eb17ce5f55 100644 --- a/packages/boba/gateway/src/containers/bridge/bobaBridge/bridgeTransfer/bridgeTransfer.js +++ b/packages/boba/gateway/src/containers/bridge/bobaBridge/bridgeTransfer/bridgeTransfer.js @@ -35,6 +35,7 @@ import { LAYER } from 'util/constant' function BridgeTransfer() { const accountEnabled = useSelector(selectAccountEnabled()) + const layer = useSelector(selectLayer()) //const bridgeType = useSelector(selectBridgeType()) const multibridgeMode = useSelector(selectMultiBridgeMode()) diff --git a/packages/boba/gateway/src/containers/dao/OldDao.js b/packages/boba/gateway/src/containers/dao/OldDao.js index 706003d48e..b976bf6f80 100644 --- a/packages/boba/gateway/src/containers/dao/OldDao.js +++ b/packages/boba/gateway/src/containers/dao/OldDao.js @@ -18,7 +18,7 @@ import { useDispatch, useSelector } from 'react-redux' import { Box, Typography } from '@mui/material' import { openError, openModal } from 'actions/uiAction' -import { orderBy } from 'lodash' +import { orderBy } from 'util/lodash'; import Button from 'components/button/Button' import ListProposal from 'components/listProposal/listProposal' @@ -126,7 +126,7 @@ function OldDao() { }}>