AA-41: ERC-4337 Provider, Bundler Server and a Docker Image (#1)

* Initial commit with lerna AA-4337 provider, bundler and helper contract

* Initial CircleCI workflow

* Initial commit for eslint task

* Initial bundler class decomposition

* Initial migration to commander.js

* Transaction is sent by MethodHandler

* Get transaction receipt by requestID

* Create yarn script for Flows with Hardhat-node, Bundler as separate processes

* Add server-side error handling to avoid unreadable errors (still WIP)

* Add docker step

* Added docker-compose.yml file

* Enable depcheck task
This commit is contained in:
Alex Forshtat
2022-08-23 16:20:21 +02:00
committed by GitHub
parent 2af4b12b47
commit a1d487478c
61 changed files with 15467 additions and 2193 deletions

107
.circleci/config.yml Normal file
View File

@@ -0,0 +1,107 @@
version: 2 # use CircleCI 2.0
jobs: # a collection of steps
build: # runs not using Workflows must have a `build` job as entry point
working_directory: ~/aa # directory where steps will run
docker: # run the steps with Docker
- image: cimg/node:16.6.2
steps: # a collection of executable commands
- checkout # special step to check out source code to working directory
- run:
name: package-json-all-deps
command: yarn create-all-deps
- restore_cache: # special step to restore the dependency cache
key: dependency-cache-{{ checksum "yarn.lock" }}-{{ checksum "all.deps" }}
- run:
name: yarn-install-if-no-cache
command: test -d node_modules/truffle || yarn
- save_cache: # special step to save the dependency cache
key: dependency-cache-{{ checksum "yarn.lock" }}-{{ checksum "all.deps" }}
paths:
- ./node_modules
- ./packages/bundler/node_modules
- ./packages/client/node_modules
- ./packages/common/node_modules
- ./packages/contracts/node_modules
- run:
name: yarn-preprocess
command: yarn preprocess
- persist_to_workspace:
root: .
paths:
- .
test:
working_directory: ~/aa # directory where steps will run
docker: # run the steps with Docker
- image: cimg/node:16.6.2
steps: # a collection of executable commands
- attach_workspace:
at: .
- run: # run tests
name: test
command: yarn lerna-test | tee /tmp/test-dev-results.log
- store_test_results: # special step to upload test results for display in Test Summary
path: /tmp/test-dev-results.log
test-flow:
working_directory: ~/aa # directory where steps will run
docker: # run the steps with Docker
- image: cimg/node:16.6.2
steps: # a collection of executable commands
- attach_workspace:
at: .
- run: # run hardhat-node as standalone process fork
name: hardhat-node-process
command: yarn hardhat-node
background: true
- run: # run tests
name: test
command: yarn lerna-test-flows | tee /tmp/test-flows-results.log
- store_test_results: # special step to upload test results for display in Test Summary
path: /tmp/test-flow-results.log
lint:
working_directory: ~/aa # directory where steps will run
docker: # run the steps with Docker
- image: cimg/node:16.6.2
steps: # a collection of executable commands
- attach_workspace:
at: .
- run: # run tests
name: lint
command: yarn lerna-lint
depcheck:
working_directory: ~/aa # directory where steps will run
docker: # run the steps with Docker
- image: cimg/node:16.6.2
steps: # a collection of executable commands
- attach_workspace:
at: .
- run: # run tests
name: depcheck
command: yarn depcheck
workflows:
version: 2
build_and_test:
jobs:
- build
- test:
requires:
- build
- test-flow:
requires:
- build
- lint:
requires:
- build
- depcheck:
requires:
- build

61
.eslintrc.js Normal file
View File

@@ -0,0 +1,61 @@
module.exports = {
env: {
browser: true,
es6: true,
jest: true,
mocha: true,
node: true
},
globals: {
artifacts: false,
assert: false,
contract: false,
web3: false
},
extends:
[
'standard-with-typescript'
],
// This is needed to add configuration to rules with type information
parser: '@typescript-eslint/parser',
parserOptions: {
// The 'tsconfig.packages.json' is needed to add not-compiled files to the project
project: ['./tsconfig.json', './tsconfig.packages.json']
},
ignorePatterns: [
'dist/'
],
rules: {
'no-console': 'off'
},
overrides: [
{
files: [
'**/test/**/*.ts'
],
rules: {
'no-unused-expressions': 'off',
// chai assertions trigger this rule
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-non-null-assertion': 'off'
}
},
{
// otherwise it will raise an error in every JavaScript file
files: ['*.ts'],
rules: {
'@typescript-eslint/prefer-ts-expect-error': 'off',
// allow using '${val}' with numbers, bool and null types
'@typescript-eslint/restrict-template-expressions': [
'error',
{
allowNumber: true,
allowBoolean: true,
allowNullish: true,
allowNullable: true
}
]
}
}
]
}

20
.gitignore vendored
View File

@@ -3,8 +3,22 @@ node_modules
coverage
coverage.json
typechain
typechain-types
#Hardhat files
packages/bundler/src/typechain-types
cache
artifacts
/packages/hardhat/types/
.DS_Store
/.idea/bundler.iml
/.idea/modules.xml
/packages/bundler/tsconfig.tsbuildinfo
/packages/hardhat/deployments/
tsconfig.tsbuildinfo
/packages/common/src/types/
/.idea/codeStyles/Project.xml
/.idea/codeStyles/codeStyleConfig.xml
/.idea/inspectionProfiles/Project_Default.xml
/packages/bundler/typechain-types/
/packages/bundler/deployments/
**/dist/
/packages/aactf/src/types/
/packages/bundler/src/types/

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +0,0 @@
import {HardhatRuntimeEnvironment} from 'hardhat/types';
import {DeployFunction} from 'hardhat-deploy/types';
const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
const {deploy} = hre.deployments;
const [deployer] = await hre.ethers.provider.listAccounts()
if ( !deployer) {
throw new Error( "no deployer. missing MNEMONIC_FILE ?")
}
await deploy('BundlerHelper', {
from: deployer,
args: [],
log: true,
deterministicDeployment: true
});
}
export default func;

View File

@@ -0,0 +1,4 @@
FROM node:13-buster-slim
COPY dist/bundler.js app/
WORKDIR /app/
CMD node --no-deprecation bundler.js

22
dockers/bundler/dbuild.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash -e
cd `cd \`dirname $0\`;pwd`
#need to preprocess first to have the Version.js
yarn preprocess
test -z "$VERSION" && VERSION=`node -e "console.log(require('../../packages/common/dist/src/Version.js').erc4337RuntimeVersion)"`
echo version=$VERSION
IMAGE=alexforshtat/erc4337bundler
#build docker image of bundler
#rebuild if there is a newer src file:
find ./dbuild.sh ../../packages/*/src/ -type f -newer dist/bundler.js 2>&1 | grep . && {
npx webpack
}
docker build -t $IMAGE .
docker tag $IMAGE $IMAGE:$VERSION
echo "== To publish"
echo " docker push $IMAGE:latest; docker push $IMAGE:$VERSION"

View File

@@ -0,0 +1,30 @@
const path = require('path')
const { IgnorePlugin, ProvidePlugin } = require('webpack')
module.exports = {
plugins: [
new IgnorePlugin({ resourceRegExp: /electron/ }),
new IgnorePlugin({ resourceRegExp: /^scrypt$/ }),
new ProvidePlugin({
WebSocket: 'ws',
fetch: ['node-fetch', 'default'],
}),
],
target: 'node',
entry: '../../packages/bundler/dist/src/runBundler.js',
mode: 'development',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/
}
]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundler.js'
},
stats: 'errors-only'
}

View File

@@ -0,0 +1,68 @@
# You must have the following environment variable set in .env file:
# HOST | my.example.com | Your Relay Server URL exactly as it is accessed by GSN Clients
# DEFAULT_EMAIL | me@example.com | Your e-mail for LetsEncrypt to send SSL alerts to
version: '2'
services:
nginx-proxy:
image: nginxproxy/nginx-proxy
container_name: nginx-proxy
restart: unless-stopped
ports:
- '443:443'
- '80:80'
volumes:
- conf:/etc/nginx/conf.d
- vhost:/etc/nginx/vhost.d
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
logging:
driver: "json-file"
options:
max-size: 10m
max-file: "10"
acme-companion:
image: nginxproxy/acme-companion
container_name: nginx-proxy-acme
restart: unless-stopped
depends_on:
- nginx-proxy
volumes_from:
- nginx-proxy
volumes:
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
bundler:
container_name: bundler
ports: [ '8080:80' ] #bypass https-portal
image: alexforshtat/erc4337bundler:0.1.0
restart: on-failure
volumes:
- ./workdir:/app/workdir:ro
environment:
url: https://${HOST}/
port: 80
LETSENCRYPT_HOST: $HOST
VIRTUAL_HOST: $HOST
VIRTUAL_PATH: /
VIRTUAL_DEST: /
mem_limit: 100M
logging:
driver: "json-file"
options:
max-size: 10m
max-file: "10"
volumes:
conf:
vhost:
html:
certs:
acme:

View File

@@ -0,0 +1,10 @@
{
"mnemonic": "myth like bonus scare over problem client lizard pioneer submit female collect",
"network": "https://goerli.infura.io/v3/f40be2b1a3914db682491dc62a19ad43",
"beneficiary": "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1",
"port": "80",
"helper": "0x214fadBD244c07ECb9DCe782270d3b673cAD0f9c",
"entryPoint": "0x602aB3881Ff3Fa8dA60a8F44Cf633e91bA1FdB69",
"minBalance": "0",
"gasFactor": "1"
}

View File

@@ -1,35 +0,0 @@
import {HardhatUserConfig} from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import 'hardhat-deploy'
import * as fs from "fs";
const mnemonicFile: string | undefined = process.env.MNEMONIC_FILE
const accounts = mnemonicFile == undefined ? undefined : {
mnemonic: fs.readFileSync(process.env.MNEMONIC_FILE as string, 'ascii')
}
const config: HardhatUserConfig = {
solidity: {
version: '0.8.15',
settings: {
optimizer: {enabled: true}
}
},
networks: {
goerli: {
url: `https://goerli.infura.io/v3/${process.env.INFURA_ID}`,
accounts
},
dev: {
url: "http://localhost:8545",
// accounts
}
},
namedAccounts: {
deployer: {
default: 0
}
}
};
export default config;

5
lerna.json Normal file
View File

@@ -0,0 +1,5 @@
{
"version": "0.1.0",
"npmClient": "yarn",
"useWorkspaces": true
}

View File

@@ -1,45 +1,43 @@
{
"private": true,
"name": "aa-bundler",
"version": "0.1.0",
"main": "index.js",
"author": "Dror Tirosh",
"license": "MIT",
"workspaces": {
"packages": [
"packages/*"
],
"nohoist": [
"**eslint**"
]
},
"scripts": {
"compile": "rm -rf artifacts typechain-types; hardhat compile",
"bundler": "tsnode src/bundler.ts --network https://goerli.infura.io/v3/$INFURA_ID --mnemonic ~/.secret/testnet-mnemonic.text",
"deploy": "hardhat deploy --network goerli"
"bundler": "ts-node packages/bundler/src/bundler.ts --network https://goerli.infura.io/v3/f40be2b1a3914db682491dc62a19ad43 --mnemonic ./file",
"create-all-deps": "jq '.dependencies,.devDependencies' packages/*/package.json |sort -u > all.deps",
"depcheck": "lerna exec --no-bail --stream -- npx depcheck",
"hardhat-deploy": "lerna run hardhat-deploy --stream --no-prefix",
"hardhat-node": "lerna run hardhat-node --stream --no-prefix",
"lerna-clear": "lerna run clear",
"lerna-lint": "lerna run lint --stream --parallel",
"lerna-test": "lerna run hardhat-test --stream --no-prefix",
"lerna-test-flows": "lerna run hardhat-test-flows --stream --no-prefix",
"lerna-tsc": "lerna run tsc",
"preprocess": "yarn lerna-clear && lerna run hardhat-compile && yarn lerna-tsc"
},
"dependencies": {
"@account-abstraction/contracts": "^0.1.0",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"cors": "^2.8.5",
"ethers": "^5.4.7",
"express": "^4.18.1",
"hardhat-gas-reporter": "^1.0.8",
"minimist": "^1.2.6",
"solidity-string-utils": "^0.0.8-0"
},
"devDependencies": {
"@ethersproject/abi": "^5.4.7",
"@ethersproject/providers": "^5.4.7",
"@nomicfoundation/hardhat-chai-matchers": "^1.0.0",
"@nomicfoundation/hardhat-network-helpers": "^1.0.0",
"@nomicfoundation/hardhat-toolbox": "^1.0.1",
"@nomiclabs/hardhat-ethers": "^2.0.0",
"@nomiclabs/hardhat-etherscan": "^3.0.0",
"@typechain/ethers-v5": "^10.1.0",
"@typechain/hardhat": "^6.1.2",
"@types/chai": "^4.2.0",
"@types/minimist": "^1.2.2",
"@types/mocha": "^9.1.0",
"@types/node": ">=12.0.0",
"chai": "^4.2.0",
"hardhat": "^2.10.0",
"hardhat-deploy": "^0.11.11",
"solidity-coverage": "^0.7.21",
"ts-node": ">=8.0.0",
"typechain": "^8.1.0",
"typescript": ">=4.5.0"
"@types/testing-library__dom": "^7.5.0",
"@typescript-eslint/eslint-plugin": "^5.33.0",
"@typescript-eslint/parser": "^5.33.0",
"eslint": "^8.21.0",
"eslint-config-standard-with-typescript": "^22.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.2.4",
"eslint-plugin-promise": "^6.0.0",
"lerna": "^5.4.0",
"depcheck": "^1.4.3",
"typescript": "^4.7.4",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0"
}
}

View File

@@ -0,0 +1 @@
ignores: ["@account-abstraction/contracts", "solidity-string-utils"]

View File

@@ -1,8 +1,8 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.15;
import "solidity-string-utils/StringUtils.sol";
import "@account-abstraction/contracts/EntryPoint.sol";
import "solidity-string-utils/StringUtils.sol";
contract BundlerHelper {
using StringUtils for *;

View File

@@ -0,0 +1,12 @@
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// TODO: get hardhat types from '@account-abstraction' and '@erc43337/common' package directly
// only to import the file in hardhat compilation
import "@erc4337/common/contracts/test/SampleRecipient.sol";
import "@erc4337/common/contracts/test/SingletonFactory.sol";
contract Import {
SampleRecipient sampleRecipient;
SingletonFactory singletonFactory;
}

View File

@@ -0,0 +1,23 @@
import { HardhatRuntimeEnvironment } from 'hardhat/types'
import { DeployFunction } from 'hardhat-deploy/types'
const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
const { deploy } = hre.deployments
const accounts = await hre.ethers.provider.listAccounts()
console.log('Available accounts:', accounts)
const deployer = accounts[0]
console.log('Will deploy from account:', deployer)
if (deployer == null) {
throw new Error('no deployer. missing MNEMONIC_FILE ?')
}
await deploy('BundlerHelper', {
from: deployer,
args: [],
log: true,
deterministicDeployment: true
})
}
export default func
func.tags = ['BundlerHelper']

View File

@@ -0,0 +1,51 @@
import '@nomiclabs/hardhat-ethers'
import '@nomicfoundation/hardhat-toolbox'
import 'hardhat-deploy'
import fs from 'fs'
import { HardhatUserConfig } from 'hardhat/config'
import { NetworkUserConfig } from 'hardhat/src/types/config'
const mnemonicFileName = process.env.MNEMONIC_FILE
let mnemonic = 'test '.repeat(11) + 'junk'
if (mnemonicFileName != null && fs.existsSync(mnemonicFileName)) {
console.warn('Hardhat does not seem to ')
mnemonic = fs.readFileSync(mnemonicFileName, 'ascii').replace(/(\r\n|\n|\r)/gm, '')
}
const infuraUrl = (name: string): string => `https://${name}.infura.io/v3/${process.env.INFURA_ID}`
function getNetwork (url: string): NetworkUserConfig {
return {
url,
accounts: {
mnemonic
}
}
}
function getInfuraNetwork (name: string): NetworkUserConfig {
return getNetwork(infuraUrl(name))
}
const config: HardhatUserConfig = {
typechain: {
outDir: 'src/types',
target: 'ethers-v5'
},
networks: {
localhost: {
url: 'http://localhost:8545/'
},
goerli: getInfuraNetwork('goerli')
},
solidity: {
version: '0.8.15',
settings: {
optimizer: { enabled: true }
}
}
}
export default config

View File

@@ -0,0 +1,56 @@
{
"name": "@erc4337/bundler",
"version": "0.1.0",
"files": [
"dist/src/",
"dist/index.js",
"README.md"
],
"scripts": {
"clear": "rm -rf dist artifacts src/types",
"hardhat-compile": "yarn clear && hardhat compile",
"hardhat-node-with-deploy": "npx hardhat node",
"hardhat-test": "hardhat test --grep '/^((?!Flow).)*$/'",
"hardhat-test-flows": "npx hardhat test --network localhost --grep \"Flow\"",
"lint": "eslint -f unix .",
"tsc": "tsc"
},
"dependencies": {
"@account-abstraction/contracts": "^0.1.0",
"@erc4337/common": "0.1.0",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"commander": "^9.4.0",
"cors": "^2.8.5",
"ethers": "^5.7.0",
"express": "^4.18.1",
"hardhat-gas-reporter": "^1.0.8",
"ow": "^0.28.1",
"solidity-string-utils": "^0.0.8-0"
},
"devDependencies": {
"@erc4337/client": "0.1.0",
"@ethersproject/abi": "^5.4.7",
"@ethersproject/providers": "^5.4.7",
"@nomicfoundation/hardhat-chai-matchers": "^1.0.0",
"@nomicfoundation/hardhat-network-helpers": "^1.0.0",
"@nomicfoundation/hardhat-toolbox": "^1.0.1",
"@nomiclabs/hardhat-ethers": "^2.0.0",
"@nomiclabs/hardhat-etherscan": "^3.0.0",
"@typechain/ethers-v5": "^10.1.0",
"@typechain/hardhat": "^6.1.2",
"@types/chai": "^4.2.0",
"@types/mocha": "^9.1.0",
"@types/node": ">=12.0.0",
"@types/sinon": "^10.0.13",
"body-parser": "^1.20.0",
"chai": "^4.2.0",
"chai-as-promised": "^7.1.1",
"hardhat": "^2.10.0",
"hardhat-deploy": "^0.11.11",
"sinon": "^14.0.0",
"solidity-coverage": "^0.7.21",
"ts-node": ">=8.0.0",
"typechain": "^8.1.0"
}
}

View File

@@ -0,0 +1,33 @@
// TODO: consider adopting config-loading approach from hardhat to allow code in config file
import ow from 'ow'
export interface BundlerConfig {
beneficiary: string
entryPoint: string
gasFactor: string
helper: string
minBalance: string
mnemonic: string
network: string
port: string
}
// TODO: implement merging config (args -> config.js -> default) and runtime shape validation
export const BundlerConfigShape = {
beneficiary: ow.string,
entryPoint: ow.string,
gasFactor: ow.string,
helper: ow.string,
minBalance: ow.string,
mnemonic: ow.string,
network: ow.string,
port: ow.string
}
// TODO: consider if we want any default fields at all
// TODO: implement merging config (args -> config.js -> default) and runtime shape validation
export const bundlerConfigDefault: Partial<BundlerConfig> = {
port: '3000',
helper: '0xdD747029A0940e46D20F17041e747a7b95A67242',
entryPoint: '0x602aB3881Ff3Fa8dA60a8F44Cf633e91bA1FdB69'
}

View File

@@ -0,0 +1,91 @@
import bodyParser from 'body-parser'
import cors from 'cors'
import express, { Express, Response, Request } from 'express'
import { JsonRpcRequest } from 'hardhat/types'
import { Provider } from '@ethersproject/providers'
import { Wallet, utils } from 'ethers'
import { hexlify } from 'ethers/lib/utils'
import { erc4337RuntimeVersion } from '@erc4337/common/dist/src/Version'
import { BundlerConfig } from './BundlerConfig'
import { UserOpMethodHandler } from './UserOpMethodHandler'
export class BundlerServer {
app: Express
constructor (
readonly methodHandler: UserOpMethodHandler,
readonly config: BundlerConfig,
readonly provider: Provider,
readonly wallet: Wallet
) {
this.app = express()
this.app.use(cors())
this.app.use(bodyParser.json())
this.app.get('/', this.intro.bind(this))
this.app.post('/', this.intro.bind(this))
// eslint-disable-next-line @typescript-eslint/no-misused-promises
this.app.post('/rpc', this.rpc.bind(this))
this.app.listen(this.config.port)
}
async preflightCheck (): Promise<void> {
const bal = await this.provider.getBalance(this.wallet.address)
console.log('signer', this.wallet.address, 'balance', utils.formatEther(bal))
if (bal.eq(0)) {
this.fatal('cannot run with zero balance')
} else if (bal.lte(this.config.minBalance)) {
console.log('WARNING: initial balance below --minBalance ', utils.formatEther(this.config.minBalance))
}
if (await this.provider.getCode(this.config.helper) === '0x') {
this.fatal('helper not deployed. run "hardhat deploy --network ..."')
}
}
fatal (msg: string): never {
console.error('fatal:', msg)
process.exit(1)
}
intro (req: Request, res: Response): void {
res.send(`Account-Abstraction Bundler v.${erc4337RuntimeVersion}. please use "/rpc"`)
}
async rpc (req: Request, res: Response): Promise<void> {
const { method, params, jsonrpc, id }: JsonRpcRequest = req.body
try {
const result = await this.handleMethod(method, params)
console.log('sent', method, '-', result)
res.send({ jsonrpc, id, result })
} catch (err: any) {
const error = { message: err.error?.reason ?? err.error.message ?? err, code: -32000 }
console.log('failed: ', method, JSON.stringify(error))
res.send({ jsonrpc, id, error })
}
}
async handleMethod (method: string, params: any[]): Promise<void> {
let result: any
switch (method) {
case 'eth_chainId':
// eslint-disable-next-line no-case-declarations
const { chainId } = await this.provider.getNetwork()
result = hexlify(chainId)
break
case 'eth_supportedEntryPoints':
result = await this.methodHandler.getSupportedEntryPoints()
break
case 'eth_sendUserOperation':
result = await this.methodHandler.sendUserOperation(params[0], params[1])
break
default:
throw new Error(`Method ${method} is not supported`)
}
return result
}
}

View File

@@ -0,0 +1,89 @@
import { BigNumber, Wallet } from 'ethers'
import { JsonRpcSigner, Provider } from '@ethersproject/providers'
import { UserOperation } from '@erc4337/common/dist/src/UserOperation'
import { BundlerConfig } from './BundlerConfig'
import { EntryPoint } from '@erc4337/common/dist/src/types'
import { BundlerHelper } from './types'
export class UserOpMethodHandler {
constructor (
readonly provider: Provider,
readonly signer: Wallet | JsonRpcSigner,
readonly config: BundlerConfig,
readonly entryPoint: EntryPoint,
readonly bundlerHelper: BundlerHelper
) {}
async getSupportedEntryPoints (): Promise<string[]> {
return [this.config.entryPoint]
}
async selectBeneficiary (): Promise<string> {
const currentBalance = await this.provider.getBalance(this.signer.getAddress())
let beneficiary = this.config.beneficiary
// below min-balance redeem to the signer, to keep it active.
if (currentBalance.lte(this.config.minBalance)) {
beneficiary = await this.signer.getAddress()
console.log('low balance. using ', beneficiary, 'as beneficiary instead of ', this.config.beneficiary)
}
return beneficiary
}
async sendUserOperation (userOp: UserOperation, entryPointInput: string): Promise<string> {
if (entryPointInput.toLowerCase() !== this.config.entryPoint.toLowerCase()) {
throw new Error(`The EntryPoint at "${entryPointInput}" is not supported. This bundler uses ${this.config.entryPoint}`)
}
console.log(`UserOperation: Sender=${userOp.sender} EntryPoint=${this.config.entryPoint} Paymaster=${userOp.paymaster}`)
const beneficiary = await this.selectBeneficiary()
const requestId = await this.entryPoint.getRequestId(userOp)
// TODO: this is only printing debug info, remove once not necessary
// await this.printGasEstimationDebugInfo(userOp, beneficiary)
let estimated: BigNumber
let factored: BigNumber
try {
// TODO: this is not used and 0 passed insted as transaction does not pay enough
({ estimated, factored } = await this.estimateGasForHelperCall(userOp, beneficiary))
} catch (error: any) {
console.log('estimateGasForHelperCall failed:', error)
throw error.error
}
// TODO: estimate gas and pass gas limit that makes sense
await this.bundlerHelper.handleOps(factored, this.config.entryPoint, [userOp], beneficiary, { gasLimit: estimated.mul(3) })
return requestId
}
async estimateGasForHelperCall (userOp: UserOperation, beneficiary: string): Promise<{
estimated: BigNumber
factored: BigNumber
}> {
const estimateGasRet = await this.bundlerHelper.estimateGas.handleOps(0, this.config.entryPoint, [userOp], beneficiary)
const estimated = estimateGasRet.mul(64).div(63)
const factored = estimated.mul(Math.round(parseFloat(this.config.gasFactor) * 100000)).div(100000)
return { estimated, factored }
}
async printGasEstimationDebugInfo (userOp: UserOperation, beneficiary: string): Promise<void> {
const [estimateGasRet, estHandleOp, staticRet] = await Promise.all([
this.bundlerHelper.estimateGas.handleOps(0, this.config.entryPoint, [userOp], beneficiary),
this.entryPoint.estimateGas.handleOps([userOp], beneficiary),
this.bundlerHelper.callStatic.handleOps(0, this.config.entryPoint, [userOp], beneficiary)
])
const estimateGas = estimateGasRet.mul(64).div(63)
const estimateGasFactored = estimateGas.mul(Math.round(parseInt(this.config.gasFactor) * 100000)).div(100000)
console.log('estimated gas', estimateGas.toString())
console.log('handleop est ', estHandleOp.toString())
console.log('ret=', staticRet)
// eslint-disable-next-line @typescript-eslint/no-base-to-string
console.log('preVerificationGas', parseInt(userOp.preVerificationGas.toString()))
// eslint-disable-next-line @typescript-eslint/no-base-to-string
console.log('verificationGas', parseInt(userOp.verificationGas.toString()))
// eslint-disable-next-line @typescript-eslint/no-base-to-string
console.log('callGas', parseInt(userOp.callGas.toString()))
console.log('Total estimated gas for bundler compensation: ', estimateGasFactored)
}
}

View File

@@ -0,0 +1,111 @@
import ow from 'ow'
import fs from 'fs'
import { program } from 'commander'
import { erc4337RuntimeVersion } from '@erc4337/common/dist/src'
import { ethers, Wallet } from 'ethers'
import { BaseProvider } from '@ethersproject/providers'
import { BundlerConfig, bundlerConfigDefault, BundlerConfigShape } from './BundlerConfig'
import { BundlerServer } from './BundlerServer'
import { UserOpMethodHandler } from './UserOpMethodHandler'
import { EntryPoint, EntryPoint__factory } from '@erc4337/common/dist/src/types'
import { BundlerHelper, BundlerHelper__factory } from './types'
// this is done so that console.log outputs BigNumber as hex string instead of unreadable object
export const inspectCustomSymbol = Symbol.for('nodejs.util.inspect.custom')
// @ts-ignore
ethers.BigNumber.prototype[inspectCustomSymbol] = function () {
return `BigNumber ${parseInt(this._hex)}`
}
const CONFIG_FILE_NAME = 'workdir/bundler.config.json'
program
.version(erc4337RuntimeVersion)
.option('--beneficiary <string>', 'address to receive funds')
.option('--gasFactor <number>')
.option('--minBalance <number>', 'below this signer balance, keep fee for itself, ignoring "beneficiary" address ')
.option('--network <string>', 'network name or url')
.option('--mnemonic <string>', 'signer account secret key mnemonic')
.option('--helper <string>', 'address of the BundlerHelper contract')
.option('--entryPoint <string>', 'address of the supported EntryPoint contract')
.option('--port <number>', 'server listening port (default to 3000)')
.option('--config <string>', `path to config file (default to ${CONFIG_FILE_NAME})`, CONFIG_FILE_NAME)
.parse()
console.log('command-line arguments: ', program.opts())
export function resolveConfiguration (): BundlerConfig {
let fileConfig: Partial<BundlerConfig> = {}
const commandLineParams = getCommandLineParams()
const configFileName = program.opts().config
if (fs.existsSync(configFileName)) {
fileConfig = JSON.parse(fs.readFileSync(configFileName, 'ascii'))
}
const mergedConfig = Object.assign({}, bundlerConfigDefault, fileConfig, commandLineParams)
console.log('Merged configuration:', JSON.stringify(mergedConfig))
ow(mergedConfig, ow.object.exactShape(BundlerConfigShape))
return mergedConfig
}
function getCommandLineParams (): Partial<BundlerConfig> {
const params: any = {}
for (const bundlerConfigShapeKey in BundlerConfigShape) {
const optionValue = program.opts()[bundlerConfigShapeKey]
if (optionValue != null) {
params[bundlerConfigShapeKey] = optionValue
}
}
return params as BundlerConfig
}
export async function connectContracts (
wallet: Wallet,
entryPointAddress: string,
bundlerHelperAddress: string): Promise<{ entryPoint: EntryPoint, bundlerHelper: BundlerHelper }> {
const entryPoint = EntryPoint__factory.connect(entryPointAddress, wallet)
const bundlerHelper = BundlerHelper__factory.connect(bundlerHelperAddress, wallet)
return {
entryPoint,
bundlerHelper
}
}
async function main (): Promise<void> {
const config = resolveConfiguration()
const provider: BaseProvider = ethers.getDefaultProvider(config.network)
const wallet: Wallet = Wallet.fromMnemonic(config.mnemonic).connect(provider)
const { entryPoint, bundlerHelper } = await connectContracts(wallet, config.entryPoint, config.helper)
const methodHandler = new UserOpMethodHandler(
provider,
wallet,
config,
entryPoint,
bundlerHelper
)
const bundlerServer = new BundlerServer(
methodHandler,
config,
provider,
wallet
)
await bundlerServer.preflightCheck()
console.log('connected to network', await provider.getNetwork().then(net => {
return { name: net.name, chainId: net.chainId }
}))
console.log(`running on http://localhost:${config.port}`)
}
main()
.catch(e => {
console.log(e)
process.exit(1)
})

View File

@@ -0,0 +1,14 @@
describe('BundleServer', function () {
describe('preflightCheck', function () {
it('')
})
describe('', function () {
it('')
})
describe('', function () {
it('')
})
describe('', function () {
it('')
})
})

View File

@@ -0,0 +1,154 @@
import chai from 'chai'
import chaiAsPromised from 'chai-as-promised'
import childProcess, { ChildProcessWithoutNullStreams } from 'child_process'
import hre, { ethers } from 'hardhat'
import path from 'path'
import sinon from 'sinon'
import * as SampleRecipientArtifact
from '@erc4337/common/artifacts/contracts/test/SampleRecipient.sol/SampleRecipient.json'
import { BundlerConfig } from '../src/BundlerConfig'
import { ClientConfig } from '@erc4337/client/dist/src/ClientConfig'
import { JsonRpcProvider } from '@ethersproject/providers'
import { newProvider } from '@erc4337/client/dist/src'
import { Signer } from 'ethers'
import { ERC4337EthersSigner } from '@erc4337/client/dist/src/ERC4337EthersSigner'
import { ERC4337EthersProvider } from '@erc4337/client/dist/src/ERC4337EthersProvider'
const { expect } = chai.use(chaiAsPromised)
export async function startBundler (options: BundlerConfig): Promise<ChildProcessWithoutNullStreams> {
const args: any[] = []
args.push('--beneficiary', options.beneficiary)
args.push('--entryPoint', options.entryPoint)
args.push('--gasFactor', options.gasFactor)
args.push('--helper', options.helper)
args.push('--minBalance', options.minBalance)
args.push('--mnemonic', options.mnemonic)
args.push('--network', options.network)
args.push('--port', options.port)
const runServerPath = path.resolve(__dirname, '../dist/src/runBundler.js')
const proc: ChildProcessWithoutNullStreams = childProcess.spawn('./node_modules/.bin/ts-node',
[runServerPath, ...args])
const bundlerlog = (msg: string): void =>
msg.split('\n').forEach(line => console.log(`relay-${proc.pid?.toString()}> ${line}`))
await new Promise((resolve, reject) => {
let lastResponse: string
const listener = (data: any): void => {
const str = data.toString().replace(/\s+$/, '')
lastResponse = str
bundlerlog(str)
if (str.indexOf('connected to network ') >= 0) {
// @ts-ignore
proc.alreadystarted = 1
resolve(proc)
}
}
proc.stdout.on('data', listener)
proc.stderr.on('data', listener)
const doaListener = (code: Object): void => {
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!proc.alreadystarted) {
bundlerlog(`died before init code=${JSON.stringify(code)}`)
reject(new Error(lastResponse))
}
}
proc.on('exit', doaListener.bind(proc))
})
return proc
}
export function stopBundler (proc: ChildProcessWithoutNullStreams): void {
proc?.kill()
}
describe('Flow', function () {
let relayproc: ChildProcessWithoutNullStreams
let entryPointAddress: string
let sampleRecipientAddress: string
let signer: Signer
before(async function () {
signer = await hre.ethers.provider.getSigner()
const beneficiary = await signer.getAddress()
// TODO: extract to Hardhat Fixture and reuse across test file
const SingletonFactoryFactory = await ethers.getContractFactory('SingletonFactory')
const singletonFactory = await SingletonFactoryFactory.deploy()
const sampleRecipientFactory = await ethers.getContractFactory('SampleRecipient')
const sampleRecipient = await sampleRecipientFactory.deploy()
sampleRecipientAddress = sampleRecipient.address
const EntryPointFactory = await ethers.getContractFactory('EntryPoint')
const entryPoint = await EntryPointFactory.deploy(singletonFactory.address, 1, 1)
entryPointAddress = entryPoint.address
const bundleHelperFactory = await ethers.getContractFactory('BundlerHelper')
const bundleHelper = await bundleHelperFactory.deploy()
await signer.sendTransaction({
to: '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1',
value: 10e18.toString()
})
relayproc = await startBundler({
beneficiary,
entryPoint: entryPoint.address,
helper: bundleHelper.address,
gasFactor: '0.2',
minBalance: '0',
mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect',
network: 'http://localhost:8545/',
port: '5555'
})
})
after(async function () {
stopBundler(relayproc)
})
let erc4337Signer: ERC4337EthersSigner
let erc4337Provider: ERC4337EthersProvider
it('should send transaction and make profit', async function () {
const config: ClientConfig = {
entryPointAddress,
bundlerUrl: 'http://localhost:5555/rpc',
chainId: 31337
}
erc4337Provider = await newProvider(
new JsonRpcProvider('http://localhost:8545/'),
config
)
erc4337Signer = erc4337Provider.getSigner()
const simpleWalletPhantomAddress = await erc4337Signer.getAddress()
await signer.sendTransaction({
to: simpleWalletPhantomAddress,
value: 10e18.toString()
})
const sampleRecipientContract =
new ethers.Contract(sampleRecipientAddress, SampleRecipientArtifact.abi, erc4337Signer)
console.log(sampleRecipientContract.address)
const result = await sampleRecipientContract.something('hello world')
console.log(result)
const receipt = await result.wait()
console.log(receipt)
})
it('should refuse transaction that does not make profit', async function () {
sinon.stub(erc4337Signer, 'signUserOperation').returns(Promise.resolve('0x' + '01'.repeat(65)))
const sampleRecipientContract =
new ethers.Contract(sampleRecipientAddress, SampleRecipientArtifact.abi, erc4337Signer)
console.log(sampleRecipientContract.address)
await expect(sampleRecipientContract.something('hello world')).to.be.eventually
.rejectedWith(
'The bundler has failed to include UserOperation in a batch: "ECDSA: invalid signature \'v\' value"')
})
})

View File

@@ -0,0 +1,170 @@
import { BaseProvider, JsonRpcSigner } from '@ethersproject/providers'
import { assert } from 'chai'
import { ethers } from 'hardhat'
import { ERC4337EthersProvider } from '@erc4337/client/dist/src/ERC4337EthersProvider'
import { ERC4337EthersSigner } from '@erc4337/client/dist/src/ERC4337EthersSigner'
import { SimpleWalletAPI } from '@erc4337/client/dist/src/SimpleWalletAPI'
import { UserOpAPI } from '@erc4337/client/dist/src/UserOpAPI'
import { UserOperation } from '@erc4337/common/src/UserOperation'
// noinspection ES6UnusedImports
import type {} from '@erc4337/common/src/types/hardhat'
import { UserOpMethodHandler } from '../src/UserOpMethodHandler'
import {
SimpleWallet,
EntryPoint,
SampleRecipient,
SingletonFactory,
SimpleWallet__factory
} from '@erc4337/common/src/types'
import { BundlerConfig } from '../src/BundlerConfig'
import { BundlerHelper } from '../src/types'
import { ClientConfig } from '@erc4337/client/dist/src/ClientConfig'
describe('UserOpMethodHandler', function () {
const helloWorld = 'hello world'
let methodHandler: UserOpMethodHandler
let provider: BaseProvider
let signer: JsonRpcSigner
let entryPoint: EntryPoint
let bundleHelper: BundlerHelper
let simpleWallet: SimpleWallet
let singletonFactory: SingletonFactory
let sampleRecipient: SampleRecipient
let ownerAddress: string
before(async function () {
provider = ethers.provider
signer = ethers.provider.getSigner()
ownerAddress = await signer.getAddress()
// TODO: extract to Hardhat Fixture and reuse across test file
const SingletonFactoryFactory = await ethers.getContractFactory('SingletonFactory')
singletonFactory = await SingletonFactoryFactory.deploy()
const EntryPointFactory = await ethers.getContractFactory('EntryPoint')
entryPoint = await EntryPointFactory.deploy(singletonFactory.address, 1, 1)
const bundleHelperFactory = await ethers.getContractFactory('BundlerHelper')
bundleHelper = await bundleHelperFactory.deploy()
const sampleRecipientFactory = await ethers.getContractFactory('SampleRecipient')
sampleRecipient = await sampleRecipientFactory.deploy()
const config: BundlerConfig = {
beneficiary: await signer.getAddress(),
entryPoint: entryPoint.address,
gasFactor: '0.2',
helper: bundleHelper.address,
minBalance: '0',
mnemonic: '',
network: '',
port: '3000'
}
methodHandler = new UserOpMethodHandler(
provider,
signer,
config,
entryPoint,
bundleHelper
)
})
describe('preflightCheck', function () {
it('eth_chainId')
})
describe('eth_supportedEntryPoints', function () {
it('')
})
describe('sendUserOperation', function () {
let erc4337EthersProvider: ERC4337EthersProvider
let erc4337EtherSigner: ERC4337EthersSigner
let userOperation: UserOperation
before(async function () {
// TODO: SmartWalletAPI should not accept wallet - this is chicken-and-egg; rework once creation flow is final
const initCode = new SimpleWallet__factory().getDeployTransaction(entryPoint.address, ownerAddress).data
await singletonFactory.deploy(initCode!, ethers.constants.HashZero)
const simpleWalletAddress = await entryPoint.getSenderAddress(initCode!, 0)
await signer.sendTransaction({
to: simpleWalletAddress,
value: 10e18.toString()
})
simpleWallet = SimpleWallet__factory.connect(simpleWalletAddress, signer)
const smartWalletAPI = new SimpleWalletAPI(
simpleWallet,
entryPoint,
provider,
ownerAddress,
0
)
const userOpAPI = new UserOpAPI()
const network = await provider.getNetwork()
const clientConfig: ClientConfig = {
entryPointAddress: entryPoint.address,
bundlerUrl: '',
chainId: network.chainId
}
erc4337EthersProvider = new ERC4337EthersProvider(
clientConfig,
signer,
provider,
// not called here - transaction is created and quitely passed to the Handler
// @ts-ignore
null,
entryPoint,
smartWalletAPI,
userOpAPI
)
await erc4337EthersProvider.init()
erc4337EtherSigner = erc4337EthersProvider.getSigner()
userOperation = await erc4337EthersProvider.createUserOp({
data: sampleRecipient.interface.encodeFunctionData('something', [helloWorld]),
target: sampleRecipient.address,
value: '0',
gasLimit: ''
})
userOperation.signature = await erc4337EtherSigner.signUserOperation(userOperation)
})
it('should send UserOperation transaction to BundlerHelper', async function () {
const requestId = await methodHandler.sendUserOperation(userOperation, entryPoint.address)
const transactionReceipt = await erc4337EthersProvider.getTransactionReceipt(requestId)
assert.isNotNull(transactionReceipt)
const depositedEvent = entryPoint.interface.parseLog(transactionReceipt.logs[0])
const senderEvent = sampleRecipient.interface.parseLog(transactionReceipt.logs[1])
const userOperationEvent = entryPoint.interface.parseLog(transactionReceipt.logs[2])
assert.equal(userOperationEvent.name, 'UserOperationEvent')
assert.equal(userOperationEvent.args.success, true)
assert.equal(senderEvent.name, 'Sender')
const expectedTxOrigin = await methodHandler.signer.getAddress()
assert.equal(senderEvent.args.txOrigin, expectedTxOrigin)
assert.equal(senderEvent.args.msgSender, simpleWallet.address)
assert.equal(depositedEvent.name, 'Deposited')
})
})
describe('', function () {
it('')
})
})

View File

@@ -0,0 +1,5 @@
describe('runBundler', function () {
describe('resolveConfiguration', function () {
it('')
})
})

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"composite": true,
"allowJs": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"noImplicitThis": true,
"alwaysStrict": true,
"declaration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true,
"outDir": "dist",
"typeRoots": [
"./node_modules/@nomiclabs/hardhat-ethers"
]
},
"include": [
"src",
"hardhat.config.ts",
"test/**/*.ts",
"src/typechain-types"
]
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"strict": true,
"composite": true,
"allowJs": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"noImplicitThis": true,
"alwaysStrict": true,
"declaration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true
}
}

View File

@@ -0,0 +1,25 @@
{
"name": "@erc4337/client",
"version": "0.1.0",
"license": "MIT",
"files": [
"dist/*",
"README.md"
],
"scripts": {
"tsc": "tsc",
"clear": "rm -rf dist",
"lint": "eslint -f unix ."
},
"dependencies": {
"@erc4337/common": "0.1.0",
"@ethersproject/networks": "^5.7.0",
"@ethersproject/properties": "^5.7.0",
"ethers": "^5.7.0",
"hardhat": "^2.10.2"
},
"devDependencies": {
"@ethersproject/abstract-signer": "^5.6.2",
"@ethersproject/providers": "^5.4.7"
}
}

View File

@@ -0,0 +1,6 @@
export interface ClientConfig {
paymasterAddress?: string
entryPointAddress: string
bundlerUrl: string
chainId: number
}

View File

@@ -0,0 +1,159 @@
import { BaseProvider, TransactionReceipt, TransactionResponse } from '@ethersproject/providers'
import { BigNumber, ethers, Signer } from 'ethers'
import { Network } from '@ethersproject/networks'
import { hexValue } from 'ethers/lib/utils'
import { EntryPoint } from '@erc4337/common/dist/src/types'
import { UserOperation } from '@erc4337/common/dist/src/UserOperation'
import { getRequestId } from '@erc4337/common/dist/src/ERC4337Utils'
import { ClientConfig } from './ClientConfig'
import { ERC4337EthersSigner } from './ERC4337EthersSigner'
import { PaymasterAPI } from './PaymasterAPI'
import { SimpleWalletAPI } from './SimpleWalletAPI'
import { TransactionDetailsForUserOp } from './TransactionDetailsForUserOp'
import { UserOpAPI } from './UserOpAPI'
import { UserOperationEventListener } from './UserOperationEventListener'
import { HttpRpcClient } from './HttpRpcClient'
export class ERC4337EthersProvider extends BaseProvider {
initializedBlockNumber!: number
readonly isErc4337Provider = true
readonly signer: ERC4337EthersSigner
constructor (
readonly config: ClientConfig,
readonly originalSigner: Signer,
readonly originalProvider: BaseProvider,
readonly httpRpcClient: HttpRpcClient,
readonly entryPoint: EntryPoint,
readonly smartWalletAPI: SimpleWalletAPI,
readonly userOpAPI: UserOpAPI,
readonly paymasterAPI?: PaymasterAPI
) {
super({
name: 'ERC-4337 Custom Network',
chainId: config.chainId
})
this.signer = new ERC4337EthersSigner(config, originalSigner, this, httpRpcClient)
}
async init (): Promise<this> {
this.initializedBlockNumber = await this.originalProvider.getBlockNumber()
await this.smartWalletAPI.init()
// await this.signer.init()
return this
}
getSigner (addressOrIndex?: string | number): ERC4337EthersSigner {
return this.signer
}
async perform (method: string, params: any): Promise<any> {
if (method === 'sendTransaction' || method === 'getTransactionReceipt') {
// TODO: do we need 'perform' method to be available at all?
// there is nobody out there to use it for ERC-4337 methods yet, we have nothing to override in fact.
throw new Error('Should not get here. Investigate.')
}
return await this.originalProvider.perform(method, params)
}
async getTransaction (transactionHash: string | Promise<string>): Promise<TransactionResponse> {
// TODO
return await super.getTransaction(transactionHash)
}
async getTransactionReceipt (transactionHash: string | Promise<string>): Promise<TransactionReceipt> {
const requestId = await transactionHash
const sender = await this.getSenderWalletAddress()
return await new Promise<TransactionReceipt>((resolve, reject) => {
new UserOperationEventListener(
resolve, reject, this.entryPoint, sender, requestId
).start(requestId)
})
}
async getSenderWalletAddress (): Promise<string> {
return await this.smartWalletAPI.getSender()
}
async createUserOp (detailsForUserOp: TransactionDetailsForUserOp): Promise<UserOperation> {
const callData = await this.encodeUserOpCallData(detailsForUserOp)
const nonce = await this.smartWalletAPI.getNonce()
const sender = await this.smartWalletAPI.getSender()
const initCode = await this.smartWalletAPI.getInitCode()
const callGas = await this.smartWalletAPI.getCallGas()
const verificationGas = await this.smartWalletAPI.getVerificationGas()
const preVerificationGas = await this.smartWalletAPI.getPreVerificationGas()
let paymaster: string = ethers.constants.AddressZero
let paymasterData: string = '0x'
if (this.paymasterAPI != null) {
paymaster = await this.paymasterAPI.getPaymasterAddress()
paymasterData = await this.paymasterAPI.getPaymasterData()
}
const {
maxFeePerGas,
maxPriorityFeePerGas
} = await this.getFeeData()
if (maxPriorityFeePerGas == null || maxFeePerGas == null) {
throw new Error('Type-0 not supported')
}
return {
signature: '',
maxFeePerGas,
maxPriorityFeePerGas,
paymaster,
paymasterData,
verificationGas,
preVerificationGas,
callGas,
callData,
nonce,
sender,
initCode
}
}
// fabricate a response in a format usable by ethers users...
async constructUserOpTransactionResponse (userOp: UserOperation): Promise<TransactionResponse> {
const requestId = getRequestId(userOp, this.config.entryPointAddress, this.config.chainId)
const waitPromise = new Promise<TransactionReceipt>((resolve, reject) => {
new UserOperationEventListener(
resolve, reject, this.entryPoint, userOp.sender, requestId, userOp.nonce
).start(requestId)
})
return {
hash: requestId,
confirmations: 0,
from: userOp.sender,
nonce: BigNumber.from(userOp.nonce).toNumber(),
gasLimit: BigNumber.from(userOp.callGas), // ??
value: BigNumber.from(0),
data: hexValue(userOp.callData), // should extract the actual called method from this "execFromSingleton()" call
chainId: this.config.chainId,
wait: async (confirmations?: number): Promise<TransactionReceipt> => {
const transactionReceipt = await waitPromise
if (userOp.initCode.length !== 0) {
// checking if the wallet has been deployed by the transaction; it must be if we are here
await this.smartWalletAPI.checkWalletPhantom()
}
return transactionReceipt
}
}
}
async encodeUserOpCallData (detailsForUserOp: TransactionDetailsForUserOp): Promise<string> {
const encodedData = await this.smartWalletAPI.encodeUserOpCallData(detailsForUserOp)
console.log(encodedData, JSON.stringify(detailsForUserOp))
return encodedData
}
async detectNetwork (): Promise<Network> {
return (this.originalProvider as any).detectNetwork()
}
}

View File

@@ -0,0 +1,98 @@
import { Deferrable, defineReadOnly } from '@ethersproject/properties'
import { Provider, TransactionRequest, TransactionResponse } from '@ethersproject/providers'
import { Signer } from '@ethersproject/abstract-signer'
import { Bytes } from 'ethers'
import { ERC4337EthersProvider } from './ERC4337EthersProvider'
import { getRequestIdForSigning } from '@erc4337/common/dist/src/ERC4337Utils'
import { UserOperation } from '@erc4337/common/src/UserOperation'
import { ClientConfig } from './ClientConfig'
import { HttpRpcClient } from './HttpRpcClient'
export class ERC4337EthersSigner extends Signer {
// TODO: we have 'erc4337provider', remove shared dependencies or avoid two-way reference
constructor (
readonly config: ClientConfig,
readonly originalSigner: Signer,
readonly erc4337provider: ERC4337EthersProvider,
readonly httpRpcClient: HttpRpcClient
) {
super()
defineReadOnly(this, 'provider', erc4337provider.originalProvider)
}
// This one is called by Contract. It signs the request and passes in to Provider to be sent.
async sendTransaction (transaction: Deferrable<TransactionRequest>): Promise<TransactionResponse> {
const tx: TransactionRequest = await this.populateTransaction(transaction)
await this.verifyAllNecessaryFields(tx)
const userOperation = await this.erc4337provider.createUserOp({
target: tx.to ?? '',
data: tx.data?.toString() ?? '',
value: tx.value?.toString() ?? '',
gasLimit: tx.gasLimit?.toString() ?? ''
})
userOperation.signature = await this.signUserOperation(userOperation)
const transactionResponse = await this.erc4337provider.constructUserOpTransactionResponse(userOperation)
try {
const bundlerResponse = await this.httpRpcClient.sendUserOpToBundler(userOperation)
console.log('Bundler response:', bundlerResponse)
} catch (error: any) {
console.error('sendUserOpToBundler failed')
throw this.unwrapError(error)
}
// TODO: handle errors - transaction that is "rejected" by bundler is _not likely_ to ever resolve its "wait()"
return transactionResponse
}
unwrapError (errorIn: any): Error {
if (errorIn.body != null) {
const errorBody = JSON.parse(errorIn.body)
let paymaster: string = ''
let failedOpMessage: string | undefined = errorBody?.error?.message
if (failedOpMessage?.includes('FailedOp') === true) {
// TODO: better error extraction methods will be needed
const matched = failedOpMessage.match(/FailedOp\((.*)\)/)
if (matched != null) {
const split = matched[1].split(',')
paymaster = split[1]
failedOpMessage = split[2]
}
}
const error = new Error(`The bundler has failed to include UserOperation in a batch: ${failedOpMessage} (paymaster address: ${paymaster})`)
error.stack = errorIn.stack
return error
}
return errorIn
}
async verifyAllNecessaryFields (transactionRequest: TransactionRequest): Promise<void> {
if (transactionRequest.to == null) {
throw new Error('Missing call target')
}
if (transactionRequest.data == null && transactionRequest.value == null) {
// TBD: banning no-op UserOps seems to make sense on provider level
throw new Error('Missing call data or value')
}
}
connect (provider: Provider): Signer {
throw new Error('changing providers is not supported')
}
async getAddress (): Promise<string> {
return await this.erc4337provider.getSenderWalletAddress()
}
async signMessage (message: Bytes | string): Promise<string> {
return await this.originalSigner.signMessage(message)
}
async signTransaction (transaction: Deferrable<TransactionRequest>): Promise<string> {
throw new Error('not implemented')
}
async signUserOperation (userOperation: UserOperation): Promise<string> {
const message = getRequestIdForSigning(userOperation, this.config.entryPointAddress, this.config.chainId)
return await this.originalSigner.signMessage(message)
}
}

View File

@@ -0,0 +1,46 @@
import { JsonRpcProvider } from '@ethersproject/providers'
import { ethers } from 'ethers'
import { hexValue } from 'ethers/lib/utils'
import { UserOperation } from '@erc4337/common/dist/src/UserOperation'
export class HttpRpcClient {
private readonly userOpJsonRpcProvider: JsonRpcProvider
constructor (
readonly bundlerUrl: string,
readonly entryPointAddress: string,
readonly chainId: number
) {
this.userOpJsonRpcProvider = new ethers.providers.JsonRpcProvider(this.bundlerUrl, {
name: 'Not actually connected to network, only talking to the Bundler!',
chainId
})
}
async sendUserOpToBundler (userOp: UserOperation): Promise<any> {
const hexifiedUserOp: any =
Object.keys(userOp)
.map(key => {
let val = (userOp as any)[key]
if (typeof val !== 'string' || !val.startsWith('0x')) {
val = hexValue(val)
}
return [key, val]
})
.reduce((set, [k, v]) => ({ ...set, [k]: v }), {})
const jsonRequestData: [UserOperation, string] = [hexifiedUserOp, this.entryPointAddress]
this.printUserOperation(jsonRequestData)
return await this.userOpJsonRpcProvider
.send('eth_sendUserOperation', [hexifiedUserOp, this.entryPointAddress])
}
private printUserOperation ([userOp, entryPointAddress]: [UserOperation, string]): void {
console.log('sending eth_sendUserOperation', {
...userOp,
initCode: (userOp.initCode ?? '').length,
callData: (userOp.callData ?? '').length
}, entryPointAddress)
}
}

View File

@@ -0,0 +1,11 @@
import { ethers } from 'ethers'
export class PaymasterAPI {
async getPaymasterData (): Promise<string> {
return '0x'
}
async getPaymasterAddress (): Promise<string> {
return ethers.constants.AddressZero
}
}

View File

@@ -0,0 +1,101 @@
import { ethers, BigNumber, BytesLike } from 'ethers'
import { BaseProvider } from '@ethersproject/providers'
import { EntryPoint, SimpleWallet, SimpleWallet__factory } from '@erc4337/common/dist/src/types'
import { TransactionDetailsForUserOp } from './TransactionDetailsForUserOp'
/**
* Base class for all Smart Wallet ERC-4337 Clients to implement.
*/
export class SimpleWalletAPI {
readonly simpleWalletFactory: SimpleWallet__factory
private isPhantom: boolean = true
private senderAddress!: string
constructor (
protected simpleWallet: SimpleWallet,
readonly entryPoint: EntryPoint,
readonly originalProvider: BaseProvider,
readonly ownerAddress: string,
readonly index = 0
) {
this.simpleWalletFactory = new SimpleWallet__factory()
}
async init (): Promise<this> {
const initCode = await this._getWalletInitCode()
this.senderAddress = await this.entryPoint.getSenderAddress(initCode, this.index)
await this.checkWalletPhantom()
return this
}
async checkWalletPhantom (): Promise<void> {
const senderAddressCode = await this.originalProvider.getCode(this.senderAddress)
if (senderAddressCode.length > 2) {
console.log(`SimpleWallet Contract already deployed at ${this.senderAddress}`)
this.isPhantom = false
this.simpleWallet = this.simpleWallet.attach(this.senderAddress).connect(this.originalProvider)
} else {
console.log(`SimpleWallet Contract is NOT YET deployed at ${this.senderAddress} - working in "phantom wallet" mode.`)
}
}
// TODO: support transitioning from 'phantom wallet' to 'deployed wallet' states
async _getWalletInitCode (): Promise<BytesLike> {
const deployTransactionData = this.simpleWalletFactory.getDeployTransaction(this.entryPoint.address, this.ownerAddress).data
if (deployTransactionData == null) {
throw new Error('Failed to create initCode')
}
return deployTransactionData
}
async getInitCode (): Promise<BytesLike> {
if (this.isPhantom) {
return await this._getWalletInitCode()
}
return '0x'
}
async getNonce (): Promise<BigNumber> {
if (this.simpleWallet.address === ethers.constants.AddressZero) {
return BigNumber.from(this.index)
}
return await this.simpleWallet.nonce()
}
async getVerificationGas (): Promise<number> {
return 1000000
}
async getPreVerificationGas (): Promise<number> {
// return 21000
return 0
}
/**
* TBD: We are assuming there is only the Wallet that impacts the resulting CallData here.
*/
async encodeUserOpCallData (detailsForUserOp: TransactionDetailsForUserOp): Promise<string> {
let value = BigNumber.from(0)
if (detailsForUserOp.value !== '') {
value = BigNumber.from(detailsForUserOp.value)
}
return this.simpleWallet.interface.encodeFunctionData(
'execFromEntryPoint',
[
detailsForUserOp.target,
value,
detailsForUserOp.data
])
}
async getSender (): Promise<string> {
return this.senderAddress
}
// tbd: not sure this is only dependant on Wallet, but callGas is the gas given to the Wallet, not just target
async getCallGas (): Promise<number> {
return 1000000
}
}

View File

@@ -0,0 +1,6 @@
export interface TransactionDetailsForUserOp {
target: string
data: string
value: string
gasLimit: string
}

View File

@@ -0,0 +1,31 @@
// export type UserOperationStruct = {
// user input:
// sender: PromiseOrValue<string>;
// callData: PromiseOrValue<BytesLike>;
// wallet-specific:
// nonce: PromiseOrValue<BigNumberish>;
// initCode: PromiseOrValue<BytesLike>;
// verificationGas: PromiseOrValue<BigNumberish>;
// paymaster-dependant
// paymaster: PromiseOrValue<string>;
// paymasterData: PromiseOrValue<BytesLike>;
// verificationGas: PromiseOrValue<BigNumberish>;
// UserOp intrinsic logic:
// callGas: PromiseOrValue<BigNumberish>;
// preVerificationGas: PromiseOrValue<BigNumberish>;
// maxFeePerGas: PromiseOrValue<BigNumberish>;
// maxPriorityFeePerGas: PromiseOrValue<BigNumberish>;
// signature: PromiseOrValue<BytesLike>;
// };
// here goes execution after all wallet-specific fields are filled.
// this class fills what is not dependent on Wallet implementation:
export class UserOpAPI {
async getGasFees (): Promise<number> {
return 0
}
}

View File

@@ -0,0 +1,77 @@
import { BigNumberish, Event } from 'ethers'
import { TransactionReceipt } from '@ethersproject/providers'
import { EntryPoint } from '@erc4337/common/dist/src/types'
const DEFAULT_TRANSACTION_TIMEOUT: number = 10000
/**
* This class encapsulates Ethers.js listener function and necessary UserOperation details to
* discover a TransactionReceipt for the operation.
*/
export class UserOperationEventListener {
resolved: boolean = false
boundLisener: (this: any, ...param: any) => Promise<void>
constructor (
readonly resolve: (t: TransactionReceipt) => void,
readonly reject: (reason?: any) => void,
readonly entryPoint: EntryPoint,
readonly sender: string,
readonly requestId: string,
readonly nonce?: BigNumberish,
readonly timeout?: number
) {
console.log('requestId', this.requestId)
this.boundLisener = this.listenerCallback.bind(this)
setTimeout(() => {
this.stop()
this.reject(new Error('Timed out'))
}, this.timeout ?? DEFAULT_TRANSACTION_TIMEOUT)
}
start (requestId: string): void {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
this.entryPoint.on(this.entryPoint.filters.UserOperationEvent(requestId), this.boundLisener) // TODO: i am 90% sure i don't need to bind it again
}
stop (): void {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
this.entryPoint.off('UserOperationEvent', this.boundLisener)
}
async listenerCallback (this: any, ...param: any): Promise<void> {
const event = arguments[arguments.length - 1] as Event
if (event.args == null) {
console.error('got event without args', event)
return
}
if (event.args.requestId !== this.requestId) {
console.log(`== event with wrong requestId: sender/nonce: event.${event.args.sender as string}@${event.args.nonce.toString() as string}!= userOp.${this.sender as string}@${parseInt(this.nonce?.toString())}`)
return
}
const transactionReceipt = await event.getTransactionReceipt()
console.log('got event with status=', event.args.success, 'gasUsed=', transactionReceipt.gasUsed)
// before returning the receipt, update the status from the event.
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!event.args.success) {
await this.extractFailureReason(transactionReceipt)
}
this.stop()
this.resolve(transactionReceipt)
this.resolved = true
}
async extractFailureReason (receipt: TransactionReceipt): Promise<void> {
console.log('mark tx as failed')
receipt.status = 0
const revertReasonEvents = await this.entryPoint.queryFilter(this.entryPoint.filters.UserOperationRevertReason(this.requestId, this.sender), receipt.blockHash)
if (revertReasonEvents[0] != null) {
console.log(`rejecting with reason: ${revertReasonEvents[0].args.revertReason}`)
this.reject(new Error(`UserOp failed with reason: ${revertReasonEvents[0].args.revertReason}`)
)
}
}
}

View File

@@ -0,0 +1,33 @@
import { ethers } from 'ethers'
import { JsonRpcProvider } from '@ethersproject/providers'
import { EntryPoint__factory, SimpleWallet__factory } from '@erc4337/common/dist/src/types'
import { ClientConfig } from './ClientConfig'
import { SimpleWalletAPI } from './SimpleWalletAPI'
import { UserOpAPI } from './UserOpAPI'
import { ERC4337EthersProvider } from './ERC4337EthersProvider'
import { HttpRpcClient } from './HttpRpcClient'
export async function newProvider (
originalProvider: JsonRpcProvider,
config: ClientConfig
): Promise<ERC4337EthersProvider> {
const originalSigner = originalProvider.getSigner()
const ownerAddress = await originalSigner.getAddress()
const entryPoint = new EntryPoint__factory().attach(config.entryPointAddress).connect(originalProvider)
// Initial SimpleWallet instance is not deployed and exists just for the interface
const simpleWallet = new SimpleWallet__factory().attach(ethers.constants.AddressZero)
const smartWalletAPI = new SimpleWalletAPI(simpleWallet, entryPoint, originalProvider, ownerAddress)
const userOpAPI = new UserOpAPI()
const httpRpcClient = new HttpRpcClient(config.bundlerUrl, config.entryPointAddress, 31337)
return await new ERC4337EthersProvider(
config,
originalSigner,
originalProvider,
httpRpcClient,
entryPoint,
smartWalletAPI,
userOpAPI
).init()
}

View File

@@ -0,0 +1,20 @@
// import { expect } from 'chai'
// import hre from 'hardhat'
// import { time } from '@nomicfoundation/hardhat-network-helpers'
//
// describe('Lock', function () {
// it('Should set the right unlockTime', async function () {
// const lockedAmount = 1_000_000_000
// const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60
// const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS
//
// // deploy a lock contract where funds can be withdrawn
// // one year in the future
// const Lock = await hre.ethers.getContractFactory('Lock')
// const lock = await Lock.deploy(unlockTime, { value: lockedAmount })
//
// // assert that the value is correct
// expect(await lock.unlockTime()).to.equal(unlockTime)
// })
// })
// should throw timeout exception if user operation is not mined after x time

View File

@@ -0,0 +1,11 @@
import { deployments } from 'hardhat'
describe('ERC4337EthersSigner', function () {
it('should load deployed hardhat fixture', async function () {
await deployments.fixture(['BundlerHelper'])
const bundlerHelper = await deployments.get('BundlerHelper') // Token is available because the fixture was executed
console.log('bundlerHelper', bundlerHelper.address)
})
it('should use ERC-4337 Signer and Provider to send the UserOperation to the bundler')
})

View File

@@ -0,0 +1,23 @@
{
"include": [
"src"
],
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"strict": true,
"composite": true,
"allowJs": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"noImplicitThis": true,
"alwaysStrict": true,
"declaration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true
}
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"strict": true,
"composite": true,
"allowJs": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"noImplicitThis": true,
"alwaysStrict": true,
"declaration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true
}
}

View File

@@ -0,0 +1 @@
ignores: ["@openzeppelin/contracts"]

View File

@@ -0,0 +1,17 @@
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// TODO: get hardhat types from '@account-abstraction' package directly
// only to import the file in hardhat compilation
import "@account-abstraction/contracts/samples/SimpleWallet.sol";
contract SampleRecipient {
SimpleWallet wallet;
event Sender(address txOrigin, address msgSender, string message);
function something(string memory message) public {
emit Sender(tx.origin, msg.sender, message);
}
}

View File

@@ -0,0 +1,26 @@
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.15;
/**
* @title Singleton Factory (EIP-2470)
* @notice Exposes CREATE2 (EIP-1014) to deploy bytecode on deterministic addresses based on initialization code and salt.
* @author Ricardo Guilherme Schmidt (Status Research & Development GmbH)
*/
contract SingletonFactory {
/**
* @notice Deploys `_initCode` using `_salt` for defining the deterministic address.
* @param _initCode Initialization code.
* @param _salt Arbitrary value to modify resulting address.
* @return createdContract Created contract address.
*/
function deploy(bytes memory _initCode, bytes32 _salt)
public
returns (address payable createdContract)
{
assembly {
createdContract := create2(0, add(_initCode, 0x20), mload(_initCode), _salt)
}
}
}
// IV is a value changed to generate the vanity address.
// IV: 6583047

View File

@@ -0,0 +1,25 @@
import '@nomiclabs/hardhat-ethers'
import '@nomicfoundation/hardhat-toolbox'
import 'hardhat-deploy'
import { HardhatUserConfig } from 'hardhat/config'
const config: HardhatUserConfig = {
networks: {
localhost: {
url: 'http://localhost:8545/'
}
},
typechain: {
outDir: 'src/types',
target: 'ethers-v5'
},
solidity: {
version: '0.8.15',
settings: {
optimizer: { enabled: true }
}
}
}
export default config

View File

@@ -0,0 +1,30 @@
{
"name": "@erc4337/common",
"version": "0.1.0",
"main": "dist/index.js",
"scripts": {
"clear": "rm -rf dist artifacts src/types",
"hardhat-compile": "yarn clear && hardhat compile",
"hardhat-deploy": "hardhat deploy",
"hardhat-node": "hardhat node",
"tsc": "tsc"
},
"files": [
"dist/*",
"contracts/*",
"README.md"
],
"dependencies": {
"@ethersproject/abi": "^5.7.0",
"@ethersproject/bytes": "^5.7.0",
"@ethersproject/providers": "^5.7.0",
"@nomicfoundation/hardhat-toolbox": "^1.0.2",
"@nomiclabs/hardhat-ethers": "^2.0.0",
"@openzeppelin/contracts": "^4.7.3",
"ethers": "^5.7.0",
"hardhat-deploy": "^0.11.12"
},
"devDependencies": {
"hardhat": "^2.10.0"
}
}

View File

@@ -0,0 +1,67 @@
import { arrayify, defaultAbiCoder, keccak256 } from 'ethers/lib/utils'
import { UserOperation } from './UserOperation'
function encode (typevalues: Array<{ type: string, val: any }>, forSignature: boolean): string {
const types = typevalues.map(typevalue => typevalue.type === 'bytes' && forSignature ? 'bytes32' : typevalue.type)
const values = typevalues.map((typevalue) => typevalue.type === 'bytes' && forSignature ? keccak256(typevalue.val) : typevalue.val)
return defaultAbiCoder.encode(types, values)
}
export function packUserOp (op: UserOperation, forSignature = true): string {
if (forSignature) {
// lighter signature scheme (must match UserOperation#pack): do encode a zero-length signature, but strip afterwards the appended zero-length value
const userOpType = {
components: [
{ type: 'address', name: 'sender' },
{ type: 'uint256', name: 'nonce' },
{ type: 'bytes', name: 'initCode' },
{ type: 'bytes', name: 'callData' },
{ type: 'uint256', name: 'callGas' },
{ type: 'uint256', name: 'verificationGas' },
{ type: 'uint256', name: 'preVerificationGas' },
{ type: 'uint256', name: 'maxFeePerGas' },
{ type: 'uint256', name: 'maxPriorityFeePerGas' },
{ type: 'address', name: 'paymaster' },
{ type: 'bytes', name: 'paymasterData' },
{ type: 'bytes', name: 'signature' }
],
name: 'userOp',
type: 'tuple'
}
let encoded = defaultAbiCoder.encode([userOpType as any], [{ ...op, signature: '0x' }])
// remove leading word (total length) and trailing word (zero-length signature)
encoded = '0x' + encoded.slice(66, encoded.length - 64)
return encoded
}
const typevalues = [
{ type: 'address', val: op.sender },
{ type: 'uint256', val: op.nonce },
{ type: 'bytes', val: op.initCode },
{ type: 'bytes', val: op.callData },
{ type: 'uint256', val: op.callGas },
{ type: 'uint256', val: op.verificationGas },
{ type: 'uint256', val: op.preVerificationGas },
{ type: 'uint256', val: op.maxFeePerGas },
{ type: 'uint256', val: op.maxPriorityFeePerGas },
{ type: 'address', val: op.paymaster },
{ type: 'bytes', val: op.paymasterData }
]
if (!forSignature) {
// for the purpose of calculating gas cost, also hash signature
typevalues.push({ type: 'bytes', val: op.signature })
}
return encode(typevalues, forSignature)
}
export function getRequestId (op: UserOperation, entryPoint: string, chainId: number): string {
const userOpHash = keccak256(packUserOp(op, true))
const enc = defaultAbiCoder.encode(
['bytes32', 'address', 'uint256'],
[userOpHash, entryPoint, chainId])
return keccak256(enc)
}
export function getRequestIdForSigning (op: UserOperation, entryPoint: string, chainId: number): Uint8Array {
return arrayify(getRequestId(op, entryPoint, chainId))
}

View File

@@ -0,0 +1,11 @@
// define the same export types as used by export typechain/ethers
import { BigNumberish } from 'ethers'
import { BytesLike } from '@ethersproject/bytes'
export type address = string
export type uint256 = BigNumberish
export type uint64 = BigNumberish
export type bytes = BytesLike
export type bytes32 = BytesLike

View File

@@ -0,0 +1,16 @@
import * as type from './SolidityTypeAliases'
export interface UserOperation {
sender: type.address
nonce: type.uint256
initCode: type.bytes
callData: type.bytes
callGas: type.uint256
verificationGas: type.uint256
preVerificationGas: type.uint256
maxFeePerGas: type.uint256
maxPriorityFeePerGas: type.uint256
paymaster: type.address
paymasterData: type.bytes
signature: type.bytes
}

View File

@@ -0,0 +1 @@
export const erc4337RuntimeVersion: string = require('../../package.json').version

View File

@@ -0,0 +1 @@
export * from './Version'

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"outDir": "dist",
"target": "es2017",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"composite": true,
"allowJs": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"noImplicitThis": true,
"alwaysStrict": true,
"declaration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true
}
}

View File

@@ -1,183 +0,0 @@
import minimist from "minimist";
//import {EntryPoint__factory} from "@account-abstraction/contracts/typechain/factories/EntryPoint__factory";
import {ethers, utils, Wallet} from "ethers";
import * as fs from "fs";
import {formatEther, parseEther} from "ethers/lib/utils";
import express from 'express'
import cors from 'cors'
import bodyParser from 'body-parser'
import {BundlerHelper__factory, EntryPoint__factory} from "../typechain-types";
import {network} from "hardhat";
export const inspect_custom_symbol = Symbol.for('nodejs.util.inspect.custom')
// @ts-ignore
ethers.BigNumber.prototype[inspect_custom_symbol] = function () {
return `BigNumber ${parseInt(this._hex)}`
}
//deploy with "hardhat deploy --network goerli"
const DefaultBundlerHelperAddress = '0xdD747029A0940e46D20F17041e747a7b95A67242';
const supportedEntryPoints = [
'0x602aB3881Ff3Fa8dA60a8F44Cf633e91bA1FdB69'
]
const args = minimist(process.argv.slice(2), {
alias: {
b: 'beneficiary',
f: 'gasFactor',
M: 'minBalance',
n: 'network',
m: 'mnemonic',
H: 'helper',
p: 'port'
}
})
function fatal(msg: string): never {
console.error('fatal:', msg)
process.exit(1)
}
function usage(msg: string) {
console.log(msg)
console.log(`
usage: yarn run bundler [options]
--port - server listening port (default to 3000)
--beneficiary address to receive funds (defaults to signer)
--minBalance - below this signer balance, use itself, not --beneficiary
--gasFactor - require that much on top of estimated gas (default=1)
--network - network name/url
--mnemonic - file
--helper - BundlerHelper contract. deploy with "hardhat deploy"
`)
}
function getParam(name: string, defValue?: string | number): string {
let value = args[name] || process.env[name] || defValue
if (typeof defValue == 'number') {
value = parseFloat(value)
}
if (value == null) {
usage(`missing --${name}`)
}
// console.log(`getParam(${name}) = "${value}"`)
return value
}
const provider = ethers.getDefaultProvider(getParam('network'))
const mnemonic = fs.readFileSync(getParam('mnemonic'), 'ascii').trim()
const signer = Wallet.fromMnemonic(mnemonic).connect(provider)
const beneficiary = getParam('beneficiary', signer.address)
// TODO: this is "hardhat deploy" deterministic address.
const helperAddress = getParam('helper', DefaultBundlerHelperAddress)
const minBalance = parseEther(getParam('minBalance', '0'))
const gasFactor = parseFloat(getParam('gasFactor', 1))
const port = getParam('port', 3000)
const bundlerHelper = BundlerHelper__factory.connect(helperAddress, signer)
// noinspection JSUnusedGlobalSymbols
class MethodHandler {
async eth_chainId() {
return provider.getNetwork().then(net => utils.hexlify(net.chainId))
}
async eth_supportedEntryPoints() {
return supportedEntryPoints
}
async eth_sendUserOperation(userOp: any, entryPointAddress: string) {
const entryPoint = EntryPoint__factory.connect(entryPointAddress, signer)
if (!supportedEntryPoints.includes(utils.getAddress(entryPointAddress))) {
throw new Error(`entryPoint "${entryPointAddress}" not supported. use one of ${supportedEntryPoints.toString()}`)
}
console.log(`userOp ep=${entryPointAddress} sender=${userOp.sender} pm=${userOp.paymaster}`)
const currentBalance = await provider.getBalance(signer.address)
let b = beneficiary
// below min-balance redeem to the signer, to keep it active.
if (currentBalance.lte(minBalance)) {
b = signer.address
console.log('low balance. using ', b, 'as beneficiary instead of ', beneficiary)
}
const [estimateGasRet, estHandleOp, staticRet] = await Promise.all([
bundlerHelper.estimateGas.handleOps(0, entryPointAddress, [userOp], b),
entryPoint.estimateGas.handleOps([userOp], b),
bundlerHelper.callStatic.handleOps(0, entryPointAddress, [userOp], b),
])
const estimateGas = estimateGasRet.mul(64).div(63)
console.log('estimated gas', estimateGas.toString())
console.log('handleop est ', estHandleOp.toString())
console.log('ret=', staticRet)
console.log('preVerificationGas', parseInt(userOp.preVerificationGas))
console.log('verificationGas', parseInt(userOp.verificationGas))
console.log('callGas', parseInt(userOp.callGas))
const reqid = entryPoint.getRequestId(userOp)
const estimateGasFactored = estimateGas.mul(Math.round(gasFactor * 100000)).div(100000)
await bundlerHelper.handleOps(estimateGasFactored, entryPointAddress, [userOp], b)
return await reqid
}
}
const methodHandler: { [key: string]: (...params: any[]) => void } = new MethodHandler() as any
async function handleRpcMethod(method: string, params: any[]): Promise<any> {
const func = methodHandler[method]
if (func == null) {
throw new Error(`method ${method} not found`)
}
return func.apply(methodHandler, params)
}
async function main() {
const bal = await provider.getBalance(signer.address)
console.log('signer', signer.address, 'balance', utils.formatEther(bal))
if (bal.eq(0)) {
fatal(`cannot run with zero balance`)
} else if (bal.lte(minBalance)) {
console.log('WARNING: initial balance below --minBalance ', formatEther(minBalance))
}
if (await provider.getCode(bundlerHelper.address) == '0x') {
fatal('helper not deployed. run "hardhat deploy --network ..."')
}
const app = express()
app.use(cors())
app.use(bodyParser.json())
const intro: any = (req: any, res: any) => {
res.send('Account-Abstraction Bundler. please use "/rpc"')
}
app.get('/', intro)
app.post('/', intro)
app.post('/rpc', function (req, res) {
const {method, params, jsonrpc, id} = req.body
handleRpcMethod(method, params)
.then(result => {
console.log('sent', method, '-', result)
res.send({jsonrpc, id, result})
})
.catch(err => {
const error = {message: err.error?.reason ?? err.error, code: -32000}
console.log('failed: ', method, error)
res.send({jsonrpc, id, error})
})
})
app.listen(port)
console.log(`connected to network`, await provider.getNetwork().then(net => {
net.name, net.chainId
}))
console.log(`running on http://localhost:${port}`)
}
main()
.catch(e => console.log(e))

View File

@@ -1,11 +1,21 @@
{
"compilerOptions": {
"target": "es2020",
"jsx": "react",
"target": "es2017",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"strict": true,
"skipLibCheck": true
"composite": true,
"allowJs": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"noImplicitThis": true,
"alwaysStrict": true,
"declaration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true
}
}

3
tsconfig.packages.json Normal file
View File

@@ -0,0 +1,3 @@
{
}

14391
yarn.lock

File diff suppressed because it is too large Load Diff