mirror of
https://github.com/getwax/bundler.git
synced 2026-01-08 23:28:10 -05:00
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:
107
.circleci/config.yml
Normal file
107
.circleci/config.yml
Normal 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
61
.eslintrc.js
Normal 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
20
.gitignore
vendored
@@ -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
8
.idea/.gitignore
generated
vendored
Normal 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
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
865
EntryPoint.json
865
EntryPoint.json
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
4
dockers/bundler/Dockerfile
Normal file
4
dockers/bundler/Dockerfile
Normal 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
22
dockers/bundler/dbuild.sh
Executable 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"
|
||||
|
||||
30
dockers/bundler/webpack.config.js
Normal file
30
dockers/bundler/webpack.config.js
Normal 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'
|
||||
}
|
||||
68
dockers/docker-compose.yml
Normal file
68
dockers/docker-compose.yml
Normal 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:
|
||||
10
dockers/workdir/bundler.config.json
Normal file
10
dockers/workdir/bundler.config.json
Normal 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"
|
||||
}
|
||||
@@ -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
5
lerna.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true
|
||||
}
|
||||
68
package.json
68
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/bundler/.depcheckrc
Normal file
1
packages/bundler/.depcheckrc
Normal file
@@ -0,0 +1 @@
|
||||
ignores: ["@account-abstraction/contracts", "solidity-string-utils"]
|
||||
@@ -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 *;
|
||||
12
packages/bundler/contracts/Import.sol
Normal file
12
packages/bundler/contracts/Import.sol
Normal 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;
|
||||
}
|
||||
23
packages/bundler/deploy/deploy-helper.ts
Normal file
23
packages/bundler/deploy/deploy-helper.ts
Normal 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']
|
||||
51
packages/bundler/hardhat.config.ts
Normal file
51
packages/bundler/hardhat.config.ts
Normal 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
|
||||
56
packages/bundler/package.json
Normal file
56
packages/bundler/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
packages/bundler/src/BundlerConfig.ts
Normal file
33
packages/bundler/src/BundlerConfig.ts
Normal 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'
|
||||
}
|
||||
91
packages/bundler/src/BundlerServer.ts
Normal file
91
packages/bundler/src/BundlerServer.ts
Normal 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
|
||||
}
|
||||
}
|
||||
89
packages/bundler/src/UserOpMethodHandler.ts
Normal file
89
packages/bundler/src/UserOpMethodHandler.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
111
packages/bundler/src/runBundler.ts
Normal file
111
packages/bundler/src/runBundler.ts
Normal 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)
|
||||
})
|
||||
14
packages/bundler/test/BundlerServer.test.ts
Normal file
14
packages/bundler/test/BundlerServer.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
describe('BundleServer', function () {
|
||||
describe('preflightCheck', function () {
|
||||
it('')
|
||||
})
|
||||
describe('', function () {
|
||||
it('')
|
||||
})
|
||||
describe('', function () {
|
||||
it('')
|
||||
})
|
||||
describe('', function () {
|
||||
it('')
|
||||
})
|
||||
})
|
||||
154
packages/bundler/test/Flow.test.ts
Normal file
154
packages/bundler/test/Flow.test.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
170
packages/bundler/test/UserOpMethodHandler.test.ts
Normal file
170
packages/bundler/test/UserOpMethodHandler.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
5
packages/bundler/test/runBundler.test.ts
Normal file
5
packages/bundler/test/runBundler.test.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
describe('runBundler', function () {
|
||||
describe('resolveConfiguration', function () {
|
||||
it('')
|
||||
})
|
||||
})
|
||||
29
packages/bundler/tsconfig.json
Normal file
29
packages/bundler/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
20
packages/bundler/tsconfig.packages.json
Normal file
20
packages/bundler/tsconfig.packages.json
Normal 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
|
||||
}
|
||||
}
|
||||
25
packages/client/package.json
Normal file
25
packages/client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
packages/client/src/ClientConfig.ts
Normal file
6
packages/client/src/ClientConfig.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface ClientConfig {
|
||||
paymasterAddress?: string
|
||||
entryPointAddress: string
|
||||
bundlerUrl: string
|
||||
chainId: number
|
||||
}
|
||||
159
packages/client/src/ERC4337EthersProvider.ts
Normal file
159
packages/client/src/ERC4337EthersProvider.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
98
packages/client/src/ERC4337EthersSigner.ts
Normal file
98
packages/client/src/ERC4337EthersSigner.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
46
packages/client/src/HttpRpcClient.ts
Normal file
46
packages/client/src/HttpRpcClient.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
11
packages/client/src/PaymasterAPI.ts
Normal file
11
packages/client/src/PaymasterAPI.ts
Normal 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
|
||||
}
|
||||
}
|
||||
101
packages/client/src/SimpleWalletAPI.ts
Normal file
101
packages/client/src/SimpleWalletAPI.ts
Normal 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
|
||||
}
|
||||
}
|
||||
6
packages/client/src/TransactionDetailsForUserOp.ts
Normal file
6
packages/client/src/TransactionDetailsForUserOp.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface TransactionDetailsForUserOp {
|
||||
target: string
|
||||
data: string
|
||||
value: string
|
||||
gasLimit: string
|
||||
}
|
||||
31
packages/client/src/UserOpAPI.ts
Normal file
31
packages/client/src/UserOpAPI.ts
Normal 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
|
||||
}
|
||||
}
|
||||
77
packages/client/src/UserOperationEventListener.ts
Normal file
77
packages/client/src/UserOperationEventListener.ts
Normal 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}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
packages/client/src/index.ts
Normal file
33
packages/client/src/index.ts
Normal 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()
|
||||
}
|
||||
20
packages/client/test/ERC4337EthersProvider.test.ts
Normal file
20
packages/client/test/ERC4337EthersProvider.test.ts
Normal 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
|
||||
11
packages/client/test/ERC4337EthersSigner.test.ts
Normal file
11
packages/client/test/ERC4337EthersSigner.test.ts
Normal 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')
|
||||
})
|
||||
23
packages/client/tsconfig.json
Normal file
23
packages/client/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
20
packages/client/tsconfig.packages.json
Normal file
20
packages/client/tsconfig.packages.json
Normal 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
|
||||
}
|
||||
}
|
||||
1
packages/common/.depcheckrc
Normal file
1
packages/common/.depcheckrc
Normal file
@@ -0,0 +1 @@
|
||||
ignores: ["@openzeppelin/contracts"]
|
||||
17
packages/common/contracts/test/SampleRecipient.sol
Normal file
17
packages/common/contracts/test/SampleRecipient.sol
Normal 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);
|
||||
}
|
||||
}
|
||||
26
packages/common/contracts/test/SingletonFactory.sol
Normal file
26
packages/common/contracts/test/SingletonFactory.sol
Normal 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
|
||||
25
packages/common/hardhat.config.ts
Normal file
25
packages/common/hardhat.config.ts
Normal 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
|
||||
30
packages/common/package.json
Normal file
30
packages/common/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
67
packages/common/src/ERC4337Utils.ts
Normal file
67
packages/common/src/ERC4337Utils.ts
Normal 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))
|
||||
}
|
||||
11
packages/common/src/SolidityTypeAliases.ts
Normal file
11
packages/common/src/SolidityTypeAliases.ts
Normal 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
|
||||
16
packages/common/src/UserOperation.ts
Normal file
16
packages/common/src/UserOperation.ts
Normal 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
|
||||
}
|
||||
1
packages/common/src/Version.ts
Normal file
1
packages/common/src/Version.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const erc4337RuntimeVersion: string = require('../../package.json').version
|
||||
1
packages/common/src/index.ts
Normal file
1
packages/common/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Version'
|
||||
20
packages/common/tsconfig.json
Normal file
20
packages/common/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
183
src/bundler.ts
183
src/bundler.ts
@@ -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))
|
||||
@@ -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
3
tsconfig.packages.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user