├── .circleci └── config.yml ├── .eslintrc.js ├── .gitignore ├── .idea ├── .gitignore └── vcs.xml ├── .prettierrc ├── README.md ├── dockers ├── bundler │ ├── Dockerfile │ ├── dbuild.sh │ └── webpack.config.js ├── docker-compose.yml └── workdir │ └── bundler.config.json ├── lerna.json ├── package.json ├── packages ├── bundler │ ├── .depcheckrc │ ├── contracts │ │ ├── BundlerHelper.sol │ │ └── Import.sol │ ├── deploy │ │ └── deploy-helper.ts │ ├── hardhat.config.ts │ ├── package.json │ ├── src │ │ ├── BundlerConfig.ts │ │ ├── BundlerServer.ts │ │ ├── UserOpMethodHandler.ts │ │ ├── exec.ts │ │ └── runBundler.ts │ ├── test │ │ ├── BundlerServer.test.ts │ │ ├── Flow.test.ts │ │ ├── UserOpMethodHandler.test.ts │ │ └── runBundler.test.ts │ ├── tsconfig.json │ └── tsconfig.packages.json ├── common │ ├── .depcheckrc │ ├── contracts │ │ └── test │ │ │ ├── SampleRecipient.sol │ │ │ └── SingletonFactory.sol │ ├── hardhat.config.ts │ ├── package.json │ ├── src │ │ ├── ERC4337Utils.ts │ │ ├── SolidityTypeAliases.ts │ │ ├── Version.ts │ │ └── index.ts │ └── tsconfig.json ├── paymaster │ ├── package.json │ ├── src │ │ └── server.ts │ └── tsconfig.json ├── scw │ ├── README.md │ ├── assets │ │ ├── architecture-basic.svg │ │ ├── architecture-bundling.svg │ │ └── architecture-paymaster.svg │ ├── package.json │ ├── src │ │ ├── PaymasterAPI.ts │ │ ├── SCWProvider.ts │ │ ├── SCWSigner.ts │ │ └── index.ts │ └── tsconfig.json └── sdk │ ├── hardhat.config.ts │ ├── package.json │ ├── src │ ├── BaseWalletAPI.ts │ ├── ClientConfig.ts │ ├── DeterministicDeployer.ts │ ├── ERC4337EthersProvider.ts │ ├── ERC4337EthersSigner.ts │ ├── HttpRpcClient.ts │ ├── PaymasterAPI.ts │ ├── Provider.ts │ ├── SimpleWalletAPI.ts │ ├── TransactionDetailsForUserOp.ts │ ├── UserOperationEventListener.ts │ └── index.ts │ ├── test │ ├── 0-deterministicDeployer.test.ts │ ├── 1-SimpleWalletAPI.test.ts │ ├── 2-ERC4337EthersProvider.test.ts │ └── 3-ERC4337EthersSigner.test.ts │ ├── tsconfig.json │ └── tsconfig.packages.json ├── tsconfig.json ├── tsconfig.packages.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 # use CircleCI 2.0 2 | jobs: # a collection of steps 3 | build: # runs not using Workflows must have a `build` job as entry point 4 | working_directory: ~/aa # directory where steps will run 5 | docker: # run the steps with Docker 6 | - image: cimg/node:16.6.2 7 | 8 | steps: # a collection of executable commands 9 | - checkout # special step to check out source code to working directory 10 | 11 | - run: 12 | name: package-json-all-deps 13 | command: yarn create-all-deps 14 | 15 | - restore_cache: # special step to restore the dependency cache 16 | key: dependency-cache-{{ checksum "yarn.lock" }}-{{ checksum "all.deps" }} 17 | 18 | - run: 19 | name: yarn-install-if-no-cache 20 | command: test -d node_modules/truffle || yarn 21 | 22 | - save_cache: # special step to save the dependency cache 23 | key: dependency-cache-{{ checksum "yarn.lock" }}-{{ checksum "all.deps" }} 24 | paths: 25 | - ./node_modules 26 | - ./packages/bundler/node_modules 27 | - ./packages/client/node_modules 28 | - ./packages/common/node_modules 29 | - ./packages/contracts/node_modules 30 | 31 | - run: 32 | name: yarn-preprocess 33 | command: yarn preprocess 34 | 35 | - persist_to_workspace: 36 | root: . 37 | paths: 38 | - . 39 | 40 | test: 41 | working_directory: ~/aa # directory where steps will run 42 | docker: # run the steps with Docker 43 | - image: cimg/node:16.6.2 44 | steps: # a collection of executable commands 45 | - attach_workspace: 46 | at: . 47 | - run: # run tests 48 | name: test 49 | command: yarn lerna-test | tee /tmp/test-dev-results.log 50 | - store_test_results: # special step to upload test results for display in Test Summary 51 | path: /tmp/test-dev-results.log 52 | test-flow: 53 | working_directory: ~/aa # directory where steps will run 54 | docker: # run the steps with Docker 55 | - image: cimg/node:16.6.2 56 | steps: # a collection of executable commands 57 | - attach_workspace: 58 | at: . 59 | - run: # run hardhat-node as standalone process fork 60 | name: hardhat-node-process 61 | command: yarn hardhat-node 62 | background: true 63 | - run: # run tests 64 | name: test 65 | command: yarn lerna-test-flows | tee /tmp/test-flows-results.log 66 | - store_test_results: # special step to upload test results for display in Test Summary 67 | path: /tmp/test-flow-results.log 68 | 69 | lint: 70 | working_directory: ~/aa # directory where steps will run 71 | docker: # run the steps with Docker 72 | - image: cimg/node:16.6.2 73 | steps: # a collection of executable commands 74 | - attach_workspace: 75 | at: . 76 | - run: # run tests 77 | name: lint 78 | command: yarn lerna-lint 79 | depcheck: 80 | working_directory: ~/aa # directory where steps will run 81 | docker: # run the steps with Docker 82 | - image: cimg/node:16.6.2 83 | steps: # a collection of executable commands 84 | - attach_workspace: 85 | at: . 86 | - run: # run tests 87 | name: depcheck 88 | command: yarn depcheck 89 | 90 | 91 | workflows: 92 | version: 2 93 | build_and_test: 94 | jobs: 95 | - build 96 | - test: 97 | requires: 98 | - build 99 | - test-flow: 100 | requires: 101 | - build 102 | - lint: 103 | requires: 104 | - build 105 | - depcheck: 106 | requires: 107 | - build 108 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | jest: true, 6 | mocha: true, 7 | node: true, 8 | }, 9 | globals: { 10 | artifacts: false, 11 | assert: false, 12 | contract: false, 13 | web3: false, 14 | }, 15 | extends: ['standard-with-typescript', 'prettier'], 16 | // This is needed to add configuration to rules with type information 17 | parser: '@typescript-eslint/parser', 18 | parserOptions: { 19 | // The 'tsconfig.packages.json' is needed to add not-compiled files to the project 20 | project: ['./tsconfig.json', './tsconfig.packages.json'], 21 | }, 22 | ignorePatterns: ['dist/'], 23 | rules: { 24 | 'no-console': 'off', 25 | }, 26 | overrides: [ 27 | { 28 | files: ['**/test/**/*.ts'], 29 | rules: { 30 | 'no-unused-expressions': 'off', 31 | // chai assertions trigger this rule 32 | '@typescript-eslint/no-unused-expressions': 'off', 33 | '@typescript-eslint/no-non-null-assertion': 'off', 34 | }, 35 | }, 36 | { 37 | // otherwise it will raise an error in every JavaScript file 38 | files: ['*.ts'], 39 | rules: { 40 | '@typescript-eslint/prefer-ts-expect-error': 'off', 41 | // allow using '${val}' with numbers, bool and null types 42 | '@typescript-eslint/restrict-template-expressions': [ 43 | 'error', 44 | { 45 | allowNumber: true, 46 | allowBoolean: true, 47 | allowNullish: true, 48 | allowNullable: true, 49 | }, 50 | ], 51 | }, 52 | }, 53 | ], 54 | }; 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | coverage 4 | coverage.json 5 | typechain 6 | packages/bundler/src/typechain-types 7 | cache 8 | artifacts 9 | /packages/hardhat/types/ 10 | .DS_Store 11 | /.idea/bundler.iml 12 | /.idea/modules.xml 13 | /packages/bundler/tsconfig.tsbuildinfo 14 | /packages/hardhat/deployments/ 15 | tsconfig.tsbuildinfo 16 | /packages/common/src/types/ 17 | /.idea/codeStyles/Project.xml 18 | /.idea/codeStyles/codeStyleConfig.xml 19 | /.idea/inspectionProfiles/Project_Default.xml 20 | /packages/bundler/typechain-types/ 21 | /packages/bundler/deployments/ 22 | **/dist/ 23 | /packages/aactf/src/types/ 24 | /packages/bundler/src/types/ 25 | yarn-error.log 26 | bundler.config.json 27 | wallet* -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Cupcakes allow DAPPs developers access to Smart Contract Wallets. These wallets can be DAPPs specific or User specific. You must read about [Wallets section](#wallets) before using the SDK. 4 | 5 | 6 | ## Getting Started 7 | 8 | A guide for adding a Cupcakes SDK to your application & start bundling transactions. There are two parts of the documentation, [**bundling transaction**](#bundle-transactions) and [**sponsoring gas**](#gassless-experience). 9 | 10 | For both of them you would need to install our SDK. For sponsoring gas, you will have to first create a paymaster contract. To know more about how to create a paymaster contract, read [here](#gassless-experience). 11 | 12 | ### What you'll need 13 | 14 | - [Node.js](https://nodejs.org/en/download/) version 16.14 or above: 15 | - When installing Node.js, you are recommended to check all checkboxes related to dependencies. 16 | 17 | ## Installing SDK 18 | 19 | Our SDK is currently under development, we will be hosting it on NPM soon. The Client SDK will be available in JavaScript with full TypeScript support. 20 | 21 | 22 | ```bash 23 | npm install @cupcakes-sdk/scw 24 | ``` 25 | ```bash 26 | yarn add @cupcakes-sdk/scw 27 | ``` 28 | 29 | # Wallets 30 | 31 | Smart Contract Wallets (SCW) allows DAPP developer to bundle multiple transaction & pay gas fees for their users. You must create a SCW using our SDK for every user. The final bundled call will initiate from user's SCW. If you want to transfer the final assets to the user's current EOA, then you MUST send the transaction to transfer the assets from SCW to EOA separately. 32 | 33 | To know how to create a SCW for a user see [Dapp specific wallets](#dapp-specific-wallets). 34 | 35 | 36 | 37 | > :warning: **Wallet is not deployed instantly**, it will only be deployed once you do the first transaction, resulting in a higher gas fees in the first transaction. Though the scw address is **deterministic** and funds can be sent to the address. 38 | 39 | --- 40 | 41 | ## Dapp specific wallets 42 | 43 | Install our SDK using instructions [here](#installing-sdk). 44 | 45 | ### Initiate a wallet 46 | 47 | Create a Smart Contract Wallet for a user. You MUST pass a signer while creating the SCW. The signer will have the custody of the SCW. 48 | 49 | ```typescript 50 | import { Signer } from 'ethers' 51 | import { SCWProvider } from '@cupcakes-sdk/scw' 52 | 53 | /** 54 | * You can get signer using either the private key 55 | * const signer: Signer = new ether.Wallet(privateKey); 56 | * You can get signer if user has an EOA using wagmi.sh 57 | * const { data: signer } = useSigner(); 58 | */ 59 | 60 | /* Once you have signer & provider, create user's SCW */ 61 | 62 | /** 63 | * @param provder - any BaseProvider from ethers (JSONRpcProvider | window.ethereum | etc) 64 | * @param signer - this will be the owner of the SCW that will be deployed. 65 | */ 66 | const scwProvider: SCWProvider = await SCWProvider.getSCWForOwner(provider, signer) 67 | ``` 68 | 69 | Once the SCW has been initiated, you can use it as a normal signer with ethers/web3/etc to connect & send bundled transactions. 70 | 71 | ### Executing transactions 72 | 73 | You can get `Signer` from the `SCWProvider` we created above & start using it normally as you would use an EOA. 74 | 75 | ```typescript 76 | const scwSigner = scwProvider.getSigner() 77 | const greeter = new ethers.Contract(GREETER_ADDR, GreeterArtifact.abi, scwSigner) 78 | 79 | const tx = await greeter.addGreet({ 80 | value: ethers.utils.parseEther('0.0001'), 81 | }) 82 | console.log(tx) 83 | ``` 84 | 85 | ### Bundling transactions 86 | 87 | You can also send multiple transactions within a single transaction using SCW. Think of approvide `ERC20` tokens & `deposit` them in a single transaction with a single signature from the users. 88 | 89 | Read more about how [here](#bundle-transactions). 90 | 91 | > :warning: The transactions sent using ethers/web3/etc won't be by default bundled or sponsored. Use `sendTransactions` instead to bundle transactions, see [Bundle Transactions](#bundle-transactions). If you want to sponer, make sure you connect a `paymaster`, see [Gassless Experience](#gassless-experience) 92 | 93 | 94 | # Bundle Transactions 95 | 96 | Bundling transactions opens up a plathora of possibilities. We have listed a few of them as example: 97 | 98 | 1. Users won't have to do two transactions for approving an ERC20 token & then depositing it. 99 | 2. You can easily support depositing of any ERC20 in your app. Just add a transaction to swap user's token to your preffered token using any Dex. 100 | 3. Modular Contract designs, deploy only specific contract modules and then join them off-chain using a bundler transactions. 101 | 102 | ## Single chain bundling 103 | 104 | You must have initialised iSDK & created a `SCWProvider`. We have exposed a function in a `SCWSigner` called `sendTransactions` using which you can send multiple transactions. 105 | 106 | ```typescript 107 | const scwSigner = scwProvider.getSigner() 108 | const greeter = new ethers.Contract(GREETER_ADDR, GreeterArtifact.abi, scwSigner) 109 | 110 | const transactionData = greeter.interface.encodeFunctionData('addGreet') 111 | 112 | const tx = await scwProvider.sendTransactions([ 113 | { 114 | to: GREETER_ADDR, 115 | value: ethers.utils.parseEther('0.0001'), 116 | data: transactionData, 117 | }, 118 | { 119 | to: GREETER_ADDR, 120 | value: ethers.utils.parseEther('0.0002'), 121 | data: transactionData, 122 | }, 123 | ]) 124 | console.log(tx) 125 | ``` 126 | 127 | ```typescript title="Getting approval for ERC20 token & depositing together" 128 | await scwProvider.sendTransactions([ 129 | { 130 | to: ERC20_TOKEN_ADDR, 131 | value: ethers.utils.parseEther('0.1'), 132 | data: TOKEN.interface.encodeFunctionData('approve', [ 133 | spenderAddress, 134 | ethers.utils.parseEther(amount * 10), // getting approval from 10 times the amount to be spent 135 | ]), 136 | }, 137 | { 138 | to: myContract.address, 139 | value: ethers.utils.parseEther('0.1'), 140 | data: myContract.interface.encodeFunctionData('stake', [ERC20_TOKEN_ADDR, ethers.utils.parseEther(amount)]), 141 | }, 142 | ]) 143 | ``` 144 | 145 | ## Cross chain bundling 146 | 147 | > :warning: **Cross-chain Bundling** will be coming soon, which will enable you to add bridging transactions to your transactions as well. 148 | 149 | # Gassless Experience 150 | 151 | Cupkaes SDK will enable conditional gassless experience, which includes partial gas-sponsoring. This enables you to have complex integrations like: sponsoring of gas on ethereum upto $5 and 100% on L2/sidechain. 152 | 153 | Before you can start sponsoring gas, you must [deploy](#deploy-a-paymaster) a paymaster contract. The paymaster _MUST_ be staked & should have enough deposit to sponsor for gas. If the deposited amount becomes lesser than the gas required then your transactions will start failing. 154 | 155 | --- 156 | 157 | ## Paymaster 158 | 159 | Paymaster is a contract that sponsors gas fees on behalf of users. To know more about how it works, read in the [architecture section](#overview). 160 | 161 | To enable gas sponsoring these are the steps you must do: 162 | 163 | 1. [Deploy a paymaster](#deploy-a-paymaster) 164 | 2. [Stake paymaster](#stake--deposit-funds) 165 | 3. [Register a webhook](#register-webhook) 166 | 4. [Integrate with frontend](#integrate-with-frontend) 167 | 168 | ### Deploy a paymaster 169 | 170 | Head to our website [https://comingsoon@cupcakes](https://bit.ly/gas_less) and follow the steps shown in the video below to deploy your first paymaster. 171 | 172 | ```mdx-code-block 173 | 174 | ``` 175 | 176 | ### Stake & deposit funds 177 | 178 | Once you have created your paymaster, you will have to stake your funds. The Minimum stake as of now is `x MATIC` with a lock-in of `7 days`. The stake is to make sure no fraudulant activity can be performed by the paymaster. The staked funds will be deductded if any such fraudulant activity is found. 179 | 180 | > :warning: You must have enough deposit left to cover for 100% of the gas fees even if you only want to sponsor a portion of it. If desposit is not enough, the transaction will be reverted. 181 | 182 | Learn more about how your stake can be slashed more in detail [here](#overview). 183 | 184 | ### Register webhook 185 | 186 | You will have to register a webhook, where we will be sending the a `POST` request to verify the sponsoring of the gas. 187 | 188 | The requst will have the following body: 189 | 190 | ```json 191 | { 192 | "auth_code": "b110a339-ff6c-4456-8adb-b236a3da11d3", 193 | "timestamp": 1662805504483, 194 | "userOperation": { 195 | "sender": "0xadb2...asd4", // Sender's address of the SCW 196 | "maxGasCost": 123, // you can use this as the total of all the above gas breakup & use this to make decision of sponsoring 197 | "paymasterDeposit": 123, // the amount of deposit left in your paymaster contract, you can send refill transactions using this if you want to 198 | "paymasterAddress": "0x23rr...", // your paymaster contract address, you should send money to this address if paymasterDeposit is approaching zero 199 | "transactions": [ 200 | // this is the array of transactions that your frontend SDK included for bundling 201 | { 202 | "to": "0x123..", 203 | "value": 4, // value in ethers 204 | "data": "0xadsf..." // call data your SDK passed 205 | } 206 | ], 207 | // The following fields are part of the UserOperation that will be used to generate signature, you can ignore these if you are using our paymaster SDK 208 | "nonce": 123, 209 | "initCode": "0xAxvd3r....adfsg4r", //init code, if not empty means that this wallet doesn't exist and will be deployed in this transaction along with executing the required transaction 210 | "callData": "0xsdfdasf...000", // call data of the execution 211 | "callGas": 123, // the amount of gas the main execution of transaction will take 212 | "verificationGas": 123, //the constant amount of gas which is required to verify sender's ownership 213 | "preVerificationGas": 123, // the constant amount of gas which is required by the bundler for processing the transaction 214 | "maxFeePerGas": 123, // the maximum gas price, this depends on how busy the network is 215 | "maxPriorityFeePerGas": 123 // the fee that will be used to tip the miner 216 | } 217 | } 218 | ``` 219 | 220 | You must verify `auth_code` to check if the call is from our service or not. You will see the `auth_code` once you register a success webhook. 221 | 222 | You must return with a `200` code if you agree to sponsor the transaction. If you choose not to sponsor, you must return with a `403 - Forbidden` status code response. 223 | 224 | ### Integrate with frontend 225 | 226 | You will have to connect your paymaster with the SCW you created in [Wallets section](#initiate-a-wallet). 227 | 228 | ```typescript 229 | import { PaymasterAPI } from '@cupcakes-sdk/scw' 230 | 231 | // You can get the your API KEY when you create a paymaster, every paymaster has a different API KEY 232 | 233 | /* Connect to us to get Paymaster URL & Paymaster API KEY */ 234 | const paymasterAPI = new PaymasterAPI(process.env.REACT_APP_PAYMASTER_URL, process.env.REACT_APP_PAYMASTER_API_KEY) 235 | 236 | /* Connect your paymaster to the provider */ 237 | scwProvider.connectPaymaster(paymasterAPI) 238 | 239 | /* Do transaction as normal */ 240 | const scwSigner = scwProvider.getSigner() 241 | const greeter = new ethers.Contract(GREETER_ADDR, GreeterArtifact.abi, scwSigner) 242 | 243 | const tx = await greeter.addGreet({ 244 | value: ethers.utils.parseEther('0.0001'), 245 | }) 246 | console.log(tx) 247 | 248 | /* Disconnect if you don't want to sponsor amny further */ 249 | scwProvider.disconnectPaymaster() 250 | ``` 251 | 252 | # Overview 253 | 254 | ## Smart Contract Wallet (SCW) 255 | 256 | Each SCW has a signer assiciated with it, 257 | 258 |  259 | 260 | ## Bundling 261 | 262 |  263 | 264 | ## Gassless Experience 265 | 266 |  267 | -------------------------------------------------------------------------------- /dockers/bundler/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:13-buster-slim 2 | COPY dist/bundler.js app/ 3 | WORKDIR /app/ 4 | CMD node --no-deprecation bundler.js 5 | -------------------------------------------------------------------------------- /dockers/bundler/dbuild.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd `cd \`dirname $0\`;pwd` 3 | 4 | #need to preprocess first to have the Version.js 5 | yarn preprocess 6 | 7 | test -z "$VERSION" && VERSION=`node -e "console.log(require('../../packages/common/dist/src/Version.js').erc4337RuntimeVersion)"` 8 | echo version=$VERSION 9 | 10 | IMAGE=alexforshtat/erc4337bundler 11 | 12 | #build docker image of bundler 13 | #rebuild if there is a newer src file: 14 | find ./dbuild.sh ../../packages/*/src/ -type f -newer dist/bundler.js 2>&1 | grep . && { 15 | npx webpack 16 | } 17 | 18 | docker build -t $IMAGE . 19 | docker tag $IMAGE $IMAGE:$VERSION 20 | echo "== To publish" 21 | echo " docker push $IMAGE:latest; docker push $IMAGE:$VERSION" 22 | 23 | -------------------------------------------------------------------------------- /dockers/bundler/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { IgnorePlugin, ProvidePlugin } = require('webpack') 3 | 4 | module.exports = { 5 | plugins: [ 6 | new IgnorePlugin({ resourceRegExp: /electron/ }), 7 | new IgnorePlugin({ resourceRegExp: /^scrypt$/ }), 8 | new ProvidePlugin({ 9 | WebSocket: 'ws', 10 | fetch: ['node-fetch', 'default'], 11 | }), 12 | ], 13 | target: 'node', 14 | entry: '../../packages/bundler/dist/src/runBundler.js', 15 | mode: 'development', 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.tsx?$/, 20 | use: 'ts-loader', 21 | exclude: /node_modules/ 22 | } 23 | ] 24 | }, 25 | output: { 26 | path: path.resolve(__dirname, 'dist'), 27 | filename: 'bundler.js' 28 | }, 29 | stats: 'errors-only' 30 | } 31 | -------------------------------------------------------------------------------- /dockers/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # You must have the following environment variable set in .env file: 2 | # HOST | my.example.com | Your Relay Server URL exactly as it is accessed by GSN Clients 3 | # DEFAULT_EMAIL | me@example.com | Your e-mail for LetsEncrypt to send SSL alerts to 4 | 5 | version: '2' 6 | 7 | services: 8 | nginx-proxy: 9 | image: nginxproxy/nginx-proxy 10 | container_name: nginx-proxy 11 | restart: unless-stopped 12 | ports: 13 | - '443:443' 14 | - '80:80' 15 | volumes: 16 | - conf:/etc/nginx/conf.d 17 | - vhost:/etc/nginx/vhost.d 18 | - html:/usr/share/nginx/html 19 | - certs:/etc/nginx/certs:ro 20 | - /var/run/docker.sock:/tmp/docker.sock:ro 21 | logging: 22 | driver: "json-file" 23 | options: 24 | max-size: 10m 25 | max-file: "10" 26 | 27 | acme-companion: 28 | image: nginxproxy/acme-companion 29 | container_name: nginx-proxy-acme 30 | restart: unless-stopped 31 | depends_on: 32 | - nginx-proxy 33 | volumes_from: 34 | - nginx-proxy 35 | volumes: 36 | - certs:/etc/nginx/certs:rw 37 | - acme:/etc/acme.sh 38 | - /var/run/docker.sock:/var/run/docker.sock:ro 39 | 40 | bundler: 41 | container_name: bundler 42 | ports: [ '8080:80' ] #bypass https-portal 43 | image: alexforshtat/erc4337bundler:0.1.0 44 | restart: on-failure 45 | 46 | volumes: 47 | - ./workdir:/app/workdir:ro 48 | 49 | environment: 50 | url: https://${HOST}/ 51 | port: 80 52 | LETSENCRYPT_HOST: $HOST 53 | VIRTUAL_HOST: $HOST 54 | VIRTUAL_PATH: / 55 | VIRTUAL_DEST: / 56 | mem_limit: 100M 57 | logging: 58 | driver: "json-file" 59 | options: 60 | max-size: 10m 61 | max-file: "10" 62 | 63 | volumes: 64 | conf: 65 | vhost: 66 | html: 67 | certs: 68 | acme: 69 | -------------------------------------------------------------------------------- /dockers/workdir/bundler.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mnemonic": "myth like bonus scare over problem client lizard pioneer submit female collect", 3 | "network": "https://goerli.infura.io/v3/f40be2b1a3914db682491dc62a19ad43", 4 | "beneficiary": "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1", 5 | "port": "80", 6 | "helper": "0x214fadBD244c07ECb9DCe782270d3b673cAD0f9c", 7 | "entryPoint": "0x602aB3881Ff3Fa8dA60a8F44Cf633e91bA1FdB69", 8 | "minBalance": "0", 9 | "gasFactor": "1" 10 | } 11 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "npmClient": "yarn", 4 | "useWorkspaces": true 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "aa-bundler", 4 | "version": "0.2.0", 5 | "author": "Dror Tirosh", 6 | "license": "MIT", 7 | "workspaces": { 8 | "packages": [ 9 | "packages/*" 10 | ], 11 | "nohoist": [ 12 | "**eslint**" 13 | ] 14 | }, 15 | "scripts": { 16 | "bundler": "ts-node packages/bundler/src/exec", 17 | "paymaster": "yarn workspace paymaster-service start", 18 | "create-all-deps": "jq '.dependencies,.devDependencies' packages/*/package.json |sort -u > all.deps", 19 | "depcheck": "lerna exec --no-bail --stream --parallel -- npx depcheck", 20 | "hardhat-deploy": "lerna run hardhat-deploy --stream --no-prefix", 21 | "hardhat-node": "lerna run hardhat-node --stream --no-prefix", 22 | "lerna-clear": "lerna run clear", 23 | "lerna-lint": "lerna run lint --stream --parallel", 24 | "lerna-test": "lerna run hardhat-test --stream", 25 | "lerna-test-flows": "lerna run hardhat-test-flows --stream --no-prefix", 26 | "lerna-tsc": "lerna run tsc", 27 | "lerna-watch-tsc": "lerna run --parallel watch-tsc", 28 | "lint-fix": "lerna run lint-fix --parallel", 29 | "preprocess": "lerna run hardhat-compile && yarn lerna-tsc" 30 | }, 31 | "dependencies": { 32 | "@typescript-eslint/eslint-plugin": "^5.33.0", 33 | "@typescript-eslint/parser": "^5.33.0", 34 | "depcheck": "^1.4.3", 35 | "eslint": "^8.21.0", 36 | "eslint-config-standard-with-typescript": "^22.0.0", 37 | "eslint-plugin-import": "^2.26.0", 38 | "eslint-plugin-n": "^15.2.4", 39 | "eslint-plugin-promise": "^6.0.0", 40 | "lerna": "^5.4.0", 41 | "typescript": "^4.7.4", 42 | "webpack": "^5.74.0", 43 | "webpack-cli": "^4.10.0" 44 | }, 45 | "devDependencies": { 46 | "eslint-config-prettier": "^8.5.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/bundler/.depcheckrc: -------------------------------------------------------------------------------- 1 | ignores: ["solidity-string-utils"] 2 | -------------------------------------------------------------------------------- /packages/bundler/contracts/BundlerHelper.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0 2 | pragma solidity ^0.8.15; 3 | 4 | import "@account-abstraction/contracts/core/EntryPoint.sol"; 5 | import "solidity-string-utils/StringUtils.sol"; 6 | 7 | contract BundlerHelper { 8 | using StringUtils for *; 9 | 10 | /** 11 | * run handleop. require to get refund for the used gas. 12 | */ 13 | function handleOps(uint expectedPaymentGas, EntryPoint ep, UserOperation[] calldata ops, address payable beneficiary) 14 | public returns (uint paid, uint gasPrice){ 15 | gasPrice = tx.gasprice; 16 | uint expectedPayment = expectedPaymentGas * gasPrice; 17 | uint preBalance = beneficiary.balance; 18 | ep.handleOps(ops, beneficiary); 19 | paid = beneficiary.balance - preBalance; 20 | if (paid < expectedPayment) { 21 | revert(string.concat( 22 | "didn't pay enough: paid ", paid.toString(), 23 | " expected ", expectedPayment.toString(), 24 | " gasPrice ", gasPrice.toString() 25 | )); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/bundler/contracts/Import.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | // TODO: get hardhat types from '@account-abstraction' and '@erc43337/common' package directly 5 | // only to import the file in hardhat compilation 6 | import '@cupcakes-sdk/common/contracts/test/SampleRecipient.sol'; 7 | import '@cupcakes-sdk/common/contracts/test/SingletonFactory.sol'; 8 | 9 | contract Import { 10 | SampleRecipient sampleRecipient; 11 | SingletonFactory singletonFactory; 12 | } 13 | -------------------------------------------------------------------------------- /packages/bundler/deploy/deploy-helper.ts: -------------------------------------------------------------------------------- 1 | import { HardhatRuntimeEnvironment } from 'hardhat/types' 2 | import { DeployFunction } from 'hardhat-deploy/types' 3 | 4 | const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { 5 | const { deploy } = hre.deployments 6 | const accounts = await hre.ethers.provider.listAccounts() 7 | console.log('Available accounts:', accounts) 8 | const deployer = accounts[0] 9 | console.log('Will deploy from account:', deployer) 10 | 11 | if (deployer == null) { 12 | throw new Error('no deployer. missing MNEMONIC_FILE ?') 13 | } 14 | await deploy('BundlerHelper', { 15 | from: deployer, 16 | args: [], 17 | log: true, 18 | deterministicDeployment: true 19 | }) 20 | } 21 | 22 | export default func 23 | func.tags = ['BundlerHelper'] 24 | -------------------------------------------------------------------------------- /packages/bundler/hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import '@nomiclabs/hardhat-ethers' 2 | import '@nomicfoundation/hardhat-toolbox' 3 | import 'hardhat-deploy' 4 | 5 | import fs from 'fs' 6 | 7 | import { HardhatUserConfig } from 'hardhat/config' 8 | import { NetworkUserConfig } from 'hardhat/src/types/config' 9 | 10 | const mnemonicFileName = process.env.MNEMONIC_FILE 11 | let mnemonic = 'test '.repeat(11) + 'junk' 12 | if (mnemonicFileName != null && fs.existsSync(mnemonicFileName)) { 13 | console.warn('Hardhat does not seem to ') 14 | mnemonic = fs.readFileSync(mnemonicFileName, 'ascii').replace(/(\r\n|\n|\r)/gm, '') 15 | } 16 | 17 | const config: HardhatUserConfig = { 18 | typechain: { 19 | outDir: 'src/types', 20 | target: 'ethers-v5', 21 | }, 22 | networks: { 23 | localhost: { 24 | url: 'http://localhost:8545/', 25 | }, 26 | goerli: { 27 | chainId: 5, 28 | url: process.env.GOERLI_RPC, 29 | accounts: [mnemonic], 30 | }, 31 | }, 32 | solidity: { 33 | version: '0.8.15', 34 | settings: { 35 | optimizer: { enabled: true }, 36 | }, 37 | }, 38 | } 39 | 40 | export default config 41 | -------------------------------------------------------------------------------- /packages/bundler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@erc4337/bundler", 3 | "version": "0.2.0", 4 | "license": "MIT", 5 | "files": [ 6 | "dist/src/", 7 | "dist/index.js", 8 | "README.md" 9 | ], 10 | "scripts": { 11 | "clear": "rm -rf dist artifacts src/types", 12 | "hardhat-compile": "yarn clear && hardhat compile", 13 | "hardhat-node-with-deploy": "npx hardhat node", 14 | "hardhat-test": "hardhat test --grep '/^((?!Flow).)*$/'", 15 | "hardhat-test-flows": "npx hardhat test --network localhost --grep \"Flow\"", 16 | "lint": "eslint -f unix .", 17 | "lint-fix": "eslint -f unix . --fix", 18 | "tsc": "tsc", 19 | "watch-tsc": "tsc -w --preserveWatchOutput" 20 | }, 21 | "dependencies": { 22 | "@account-abstraction/contracts": "cupcakes-3/aa-contracts", 23 | "@cupcakes-sdk/common": "0.2.0", 24 | "@ethersproject/abi": "^5.7.0", 25 | "@ethersproject/providers": "^5.7.0", 26 | "@types/cors": "^2.8.12", 27 | "@types/express": "^4.17.13", 28 | "commander": "^9.4.0", 29 | "cors": "^2.8.5", 30 | "ethers": "^5.7.0", 31 | "express": "^4.18.1", 32 | "hardhat-gas-reporter": "^1.0.8", 33 | "ow": "^0.28.1", 34 | "source-map-support": "^0.5.21" 35 | }, 36 | "devDependencies": { 37 | "@cupcakes-sdk/sdk": "0.2.1", 38 | "@nomicfoundation/hardhat-chai-matchers": "^1.0.3", 39 | "@nomicfoundation/hardhat-network-helpers": "^1.0.0", 40 | "@nomicfoundation/hardhat-toolbox": "^1.0.2", 41 | "@nomiclabs/hardhat-ethers": "^2.0.0", 42 | "@nomiclabs/hardhat-etherscan": "^3.0.0", 43 | "@typechain/ethers-v5": "^10.1.0", 44 | "@typechain/hardhat": "^6.1.2", 45 | "@types/chai": "^4.2.0", 46 | "@types/mocha": "^9.1.0", 47 | "@types/node": ">=12.0.0", 48 | "@types/sinon": "^10.0.13", 49 | "body-parser": "^1.20.0", 50 | "chai": "^4.2.0", 51 | "chai-as-promised": "^7.1.1", 52 | "hardhat": "^2.11.0", 53 | "hardhat-deploy": "^0.11.11", 54 | "sinon": "^14.0.0", 55 | "solidity-coverage": "^0.7.21", 56 | "solidity-string-utils": "^0.0.8-0", 57 | "ts-node": ">=8.0.0", 58 | "typechain": "^8.1.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/bundler/src/BundlerConfig.ts: -------------------------------------------------------------------------------- 1 | // TODO: consider adopting config-loading approach from hardhat to allow code in config file 2 | import ow from 'ow' 3 | 4 | export interface BundlerConfig { 5 | beneficiary: string 6 | entryPoint: string 7 | gasFactor: string 8 | helper: string 9 | minBalance: string 10 | mnemonic: string 11 | network: string 12 | port: string 13 | } 14 | 15 | // TODO: implement merging config (args -> config.js -> default) and runtime shape validation 16 | export const BundlerConfigShape = { 17 | beneficiary: ow.string, 18 | entryPoint: ow.string, 19 | gasFactor: ow.string, 20 | helper: ow.string, 21 | minBalance: ow.string, 22 | mnemonic: ow.string, 23 | network: ow.string, 24 | port: ow.string 25 | } 26 | 27 | // TODO: consider if we want any default fields at all 28 | // TODO: implement merging config (args -> config.js -> default) and runtime shape validation 29 | export const bundlerConfigDefault: Partial = { 30 | port: '3000', 31 | helper: '0xdD747029A0940e46D20F17041e747a7b95A67242', 32 | entryPoint: '0x602aB3881Ff3Fa8dA60a8F44Cf633e91bA1FdB69' 33 | } 34 | -------------------------------------------------------------------------------- /packages/bundler/src/BundlerServer.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser' 2 | import cors from 'cors' 3 | import express, { Express, Response, Request } from 'express' 4 | import { JsonRpcRequest } from 'hardhat/types' 5 | import { Provider } from '@ethersproject/providers' 6 | import { Wallet, utils } from 'ethers' 7 | import { hexlify, parseEther } from 'ethers/lib/utils' 8 | 9 | import { erc4337RuntimeVersion } from '@cupcakes-sdk/common' 10 | 11 | import { BundlerConfig } from './BundlerConfig' 12 | import { UserOpMethodHandler } from './UserOpMethodHandler' 13 | import { Server } from 'http' 14 | 15 | export class BundlerServer { 16 | app: Express 17 | private readonly httpServer: Server 18 | 19 | constructor( 20 | readonly methodHandler: UserOpMethodHandler, 21 | readonly config: BundlerConfig, 22 | readonly provider: Provider, 23 | readonly wallet: Wallet 24 | ) { 25 | this.app = express() 26 | this.app.use(cors()) 27 | this.app.use(bodyParser.json()) 28 | 29 | this.app.get('/', this.intro.bind(this)) 30 | this.app.post('/', this.intro.bind(this)) 31 | 32 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 33 | this.app.post('/rpc', this.rpc.bind(this)) 34 | 35 | this.httpServer = this.app.listen(Number(this.config.port), '0.0.0.0') 36 | this.startingPromise = this._preflightCheck() 37 | } 38 | 39 | startingPromise: Promise 40 | 41 | async asyncStart(): Promise { 42 | await this.startingPromise 43 | } 44 | 45 | async stop(): Promise { 46 | this.httpServer.close() 47 | } 48 | 49 | async _preflightCheck(): Promise { 50 | if ((await this.provider.getCode(this.config.entryPoint)) === '0x') { 51 | this.fatal(`entrypoint not deployed at ${this.config.entryPoint}`) 52 | } 53 | 54 | if ((await this.provider.getCode(this.config.helper)) === '0x') { 55 | this.fatal(`helper not deployed at ${this.config.helper}. run "hardhat deploy --network ..."`) 56 | } 57 | const bal = await this.provider.getBalance(this.wallet.address) 58 | console.log('signer', this.wallet.address, 'balance', utils.formatEther(bal)) 59 | if (bal.eq(0)) { 60 | this.fatal('cannot run with zero balance') 61 | } else if (bal.lt(parseEther(this.config.minBalance))) { 62 | console.log('WARNING: initial balance below --minBalance ', this.config.minBalance) 63 | } 64 | } 65 | 66 | fatal(msg: string): never { 67 | console.error('fatal:', msg) 68 | process.exit(1) 69 | } 70 | 71 | intro(req: Request, res: Response): void { 72 | res.send(`Account-Abstraction Bundler v.${erc4337RuntimeVersion}. please use "/rpc"`) 73 | } 74 | 75 | async rpc(req: Request, res: Response): Promise { 76 | const { method, params, jsonrpc, id }: JsonRpcRequest = req.body 77 | console.log('here-------?') 78 | try { 79 | const result = await this.handleMethod(method, params) 80 | console.log('sent', method, '-', result) 81 | res.send({ jsonrpc, id, result }) 82 | } catch (err: any) { 83 | console.log('ex err=', err) 84 | const error = { 85 | message: err.error?.reason ?? err.error?.message ?? err, 86 | code: -32000, 87 | } 88 | console.log('failed: ', method, JSON.stringify(error)) 89 | res.send({ jsonrpc, id, error }) 90 | } 91 | } 92 | 93 | async handleMethod(method: string, params: any[]): Promise { 94 | let result: any 95 | console.log(method, '-----') 96 | switch (method) { 97 | case 'eth_chainId': 98 | // eslint-disable-next-line no-case-declarations 99 | const { chainId } = await this.provider.getNetwork() 100 | result = hexlify(chainId) 101 | break 102 | case 'eth_supportedEntryPoints': 103 | result = await this.methodHandler.getSupportedEntryPoints() 104 | break 105 | case 'eth_sendUserOperation': 106 | result = await this.methodHandler.sendUserOperation(params[0], params[1]) 107 | break 108 | default: 109 | throw new Error(`Method ${method} is not supported`) 110 | } 111 | return result 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /packages/bundler/src/UserOpMethodHandler.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber, Wallet } from 'ethers'; 2 | import { JsonRpcSigner, Provider } from '@ethersproject/providers'; 3 | 4 | import { BundlerConfig } from './BundlerConfig'; 5 | import { BundlerHelper, EntryPoint } from './types'; 6 | import { UserOperationStruct } from './types/contracts/BundlerHelper'; 7 | import { hexValue, resolveProperties } from 'ethers/lib/utils'; 8 | 9 | export class UserOpMethodHandler { 10 | constructor( 11 | readonly provider: Provider, 12 | readonly signer: Wallet | JsonRpcSigner, 13 | readonly config: BundlerConfig, 14 | readonly entryPoint: EntryPoint, 15 | readonly bundlerHelper: BundlerHelper 16 | ) {} 17 | 18 | async getSupportedEntryPoints(): Promise { 19 | return [this.config.entryPoint]; 20 | } 21 | 22 | async selectBeneficiary(): Promise { 23 | const currentBalance = await this.provider.getBalance( 24 | this.signer.getAddress() 25 | ); 26 | let beneficiary = this.config.beneficiary; 27 | // below min-balance redeem to the signer, to keep it active. 28 | if (currentBalance.lte(this.config.minBalance)) { 29 | beneficiary = await this.signer.getAddress(); 30 | console.log( 31 | 'low balance. using ', 32 | beneficiary, 33 | 'as beneficiary instead of ', 34 | this.config.beneficiary 35 | ); 36 | } 37 | return beneficiary; 38 | } 39 | 40 | async sendUserOperation( 41 | userOp1: UserOperationStruct, 42 | entryPointInput: string 43 | ): Promise { 44 | console.log('here?'); 45 | const userOp = await resolveProperties(userOp1); 46 | if ( 47 | entryPointInput.toLowerCase() !== this.config.entryPoint.toLowerCase() 48 | ) { 49 | throw new Error( 50 | `The EntryPoint at "${entryPointInput}" is not supported. This bundler uses ${this.config.entryPoint}` 51 | ); 52 | } 53 | 54 | console.log( 55 | `UserOperation: Sender=${ 56 | userOp.sender 57 | } EntryPoint=${entryPointInput} Paymaster=${hexValue( 58 | userOp.paymasterAndData 59 | )}` 60 | ); 61 | 62 | const beneficiary = await this.selectBeneficiary(); 63 | const requestId = await this.entryPoint.getRequestId(userOp); 64 | 65 | // TODO: this is only printing debug info, remove once not necessary 66 | // await this.printGasEstimationDebugInfo(userOp, beneficiary) 67 | 68 | let estimated: BigNumber; 69 | let factored: BigNumber; 70 | try { 71 | // TODO: this is not used and 0 passed instead as transaction does not pay enough 72 | ({ estimated, factored } = await this.estimateGasForHelperCall( 73 | userOp, 74 | beneficiary 75 | )); 76 | } catch (error: any) { 77 | console.log('estimateGasForHelperCall failed:', error); 78 | throw error.error; 79 | } 80 | // TODO: estimate gas and pass gas limit that makes sense 81 | await this.bundlerHelper.handleOps( 82 | factored, 83 | this.config.entryPoint, 84 | [userOp], 85 | beneficiary, 86 | { gasLimit: estimated.mul(3) } 87 | ); 88 | return requestId 89 | } 90 | 91 | async estimateGasForHelperCall( 92 | userOp: UserOperationStruct, 93 | beneficiary: string 94 | ): Promise<{ 95 | estimated: BigNumber; 96 | factored: BigNumber; 97 | }> { 98 | const estimateGasRet = await this.bundlerHelper.estimateGas.handleOps( 99 | 0, 100 | this.config.entryPoint, 101 | [userOp], 102 | beneficiary 103 | ); 104 | const estimated = estimateGasRet.mul(64).div(63); 105 | const factored = estimated 106 | .mul(Math.round(parseFloat(this.config.gasFactor) * 100000)) 107 | .div(100000); 108 | return { estimated, factored }; 109 | } 110 | 111 | async printGasEstimationDebugInfo( 112 | userOp1: UserOperationStruct, 113 | beneficiary: string 114 | ): Promise { 115 | const userOp = await resolveProperties(userOp1); 116 | 117 | const [estimateGasRet, estHandleOp, staticRet] = await Promise.all([ 118 | this.bundlerHelper.estimateGas.handleOps( 119 | 0, 120 | this.config.entryPoint, 121 | [userOp], 122 | beneficiary 123 | ), 124 | this.entryPoint.estimateGas.handleOps([userOp], beneficiary), 125 | this.bundlerHelper.callStatic.handleOps( 126 | 0, 127 | this.config.entryPoint, 128 | [userOp], 129 | beneficiary 130 | ), 131 | ]); 132 | const estimateGas = estimateGasRet.mul(64).div(63); 133 | const estimateGasFactored = estimateGas 134 | .mul(Math.round(parseInt(this.config.gasFactor) * 100000)) 135 | .div(100000); 136 | console.log('estimated gas', estimateGas.toString()); 137 | console.log('handleOp est ', estHandleOp.toString()); 138 | console.log('ret=', staticRet); 139 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 140 | console.log( 141 | 'preVerificationGas', 142 | parseInt(userOp.preVerificationGas.toString()) 143 | ); 144 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 145 | console.log( 146 | 'verificationGas', 147 | parseInt(userOp.verificationGasLimit.toString()) 148 | ); 149 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 150 | console.log('callGas', parseInt(userOp.callGasLimit.toString())); 151 | console.log( 152 | 'Total estimated gas for bundler compensation: ', 153 | estimateGasFactored 154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /packages/bundler/src/exec.ts: -------------------------------------------------------------------------------- 1 | import { runBundler } from './runBundler' 2 | 3 | runBundler(process.argv) 4 | .catch(e => { 5 | console.log(e) 6 | process.exit(1) 7 | }) 8 | -------------------------------------------------------------------------------- /packages/bundler/src/runBundler.ts: -------------------------------------------------------------------------------- 1 | import ow from 'ow' 2 | import fs from 'fs' 3 | 4 | import { Command } from 'commander' 5 | import { erc4337RuntimeVersion } from '@cupcakes-sdk/common' 6 | import { ethers, Wallet } from 'ethers' 7 | import { BaseProvider } from '@ethersproject/providers' 8 | 9 | import { BundlerConfig, bundlerConfigDefault, BundlerConfigShape } from './BundlerConfig' 10 | import { BundlerServer } from './BundlerServer' 11 | import { UserOpMethodHandler } from './UserOpMethodHandler' 12 | import { EntryPoint, EntryPoint__factory } from '@account-abstraction/contracts' 13 | 14 | import { BundlerHelper, BundlerHelper__factory } from './types' 15 | 16 | // this is done so that console.log outputs BigNumber as hex string instead of unreadable object 17 | export const inspectCustomSymbol = Symbol.for('nodejs.util.inspect.custom') 18 | // @ts-ignore 19 | ethers.BigNumber.prototype[inspectCustomSymbol] = function () { 20 | return `BigNumber ${parseInt(this._hex)}` 21 | } 22 | 23 | const CONFIG_FILE_NAME = './bundler.config.json' 24 | 25 | export function resolveConfiguration(programOpts: any): BundlerConfig { 26 | let fileConfig: Partial = {} 27 | 28 | const commandLineParams = getCommandLineParams(programOpts) 29 | const configFileName = programOpts.config 30 | if (fs.existsSync(configFileName)) { 31 | fileConfig = JSON.parse(fs.readFileSync(configFileName, 'ascii')) 32 | } 33 | const mergedConfig = Object.assign({}, bundlerConfigDefault, fileConfig, commandLineParams) 34 | console.log('Merged configuration:', JSON.stringify(mergedConfig)) 35 | ow(mergedConfig, ow.object.exactShape(BundlerConfigShape)) 36 | return mergedConfig 37 | } 38 | 39 | function getCommandLineParams(programOpts: any): Partial { 40 | const params: any = {} 41 | for (const bundlerConfigShapeKey in BundlerConfigShape) { 42 | const optionValue = programOpts[bundlerConfigShapeKey] 43 | if (optionValue != null) { 44 | params[bundlerConfigShapeKey] = optionValue 45 | } 46 | } 47 | return params as BundlerConfig 48 | } 49 | 50 | export async function connectContracts( 51 | wallet: Wallet, 52 | entryPointAddress: string, 53 | bundlerHelperAddress: string 54 | ): Promise<{ entryPoint: EntryPoint; bundlerHelper: BundlerHelper }> { 55 | const entryPoint = EntryPoint__factory.connect(entryPointAddress, wallet) 56 | const bundlerHelper = BundlerHelper__factory.connect(bundlerHelperAddress, wallet) 57 | return { 58 | entryPoint, 59 | bundlerHelper, 60 | } 61 | } 62 | 63 | /** 64 | * start the bundler server. 65 | * this is an async method, but only to resolve configuration. after it returns, the server is only active after asyncInit() 66 | * @param argv 67 | * @param overrideExit 68 | */ 69 | export async function runBundler(argv: string[], overrideExit = true): Promise { 70 | const program = new Command() 71 | 72 | if (overrideExit) { 73 | ;(program as any)._exit = (exitCode: any, code: any, message: any) => { 74 | class CommandError extends Error { 75 | constructor(message: string, readonly code: any, readonly exitCode: any) { 76 | super(message) 77 | } 78 | } 79 | throw new CommandError(message, code, exitCode) 80 | } 81 | } 82 | 83 | program 84 | .version(erc4337RuntimeVersion) 85 | .option('--beneficiary ', 'address to receive funds') 86 | .option('--gasFactor ', '', '1') 87 | .option('--minBalance ', 'below this signer balance, keep fee for itself, ignoring "beneficiary" address ') 88 | .option('--network ', 'network name or url') 89 | .option('--mnemonic ', 'mnemonic/private-key file of signer account') 90 | .option('--helper ', 'address of the BundlerHelper contract') 91 | .option('--entryPoint ', 'address of the supported EntryPoint contract') 92 | .option('--port ', 'server listening port', '9080') 93 | .option('--config ', 'path to config file)', CONFIG_FILE_NAME) 94 | 95 | const programOpts = program.parse(argv).opts() 96 | 97 | console.log('command-line arguments: ', program.opts()) 98 | 99 | const config = resolveConfiguration(programOpts) 100 | const provider: BaseProvider = 101 | // eslint-disable-next-line 102 | config.network === 'hardhat' ? require('hardhat').ethers.provider : ethers.getDefaultProvider(config.network) 103 | let mnemonic: string 104 | let wallet: Wallet 105 | try { 106 | // mnemonic = fs.readFileSync(config.mnemonic, 'ascii'); 107 | // wallet = Wallet.fromMnemonic(mnemonic).connect(provider); 108 | wallet = new ethers.Wallet(config.mnemonic, provider) 109 | } catch (e: any) { 110 | throw new Error(`Unable to read --mnemonic ${config.mnemonic}: ${e.message as string}`) 111 | } 112 | 113 | const { entryPoint, bundlerHelper } = await connectContracts(wallet, config.entryPoint, config.helper) 114 | 115 | const methodHandler = new UserOpMethodHandler(provider, wallet, config, entryPoint, bundlerHelper) 116 | 117 | const bundlerServer = new BundlerServer(methodHandler, config, provider, wallet) 118 | 119 | void bundlerServer.asyncStart().then(async () => { 120 | console.log( 121 | 'connected to network', 122 | await provider.getNetwork().then((net) => { 123 | return { 124 | name: net.name, 125 | chainId: net.chainId, 126 | } 127 | }) 128 | ) 129 | console.log(`running on http://localhost:${config.port}/rpc`) 130 | }) 131 | 132 | return bundlerServer 133 | } 134 | -------------------------------------------------------------------------------- /packages/bundler/test/BundlerServer.test.ts: -------------------------------------------------------------------------------- 1 | describe('BundleServer', function () { 2 | describe('preflightCheck', function () { 3 | it('') 4 | }) 5 | describe('', function () { 6 | it('') 7 | }) 8 | describe('', function () { 9 | it('') 10 | }) 11 | describe('', function () { 12 | it('') 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /packages/bundler/test/Flow.test.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | import chaiAsPromised from 'chai-as-promised' 3 | import hre, { ethers } from 'hardhat' 4 | import sinon from 'sinon' 5 | 6 | import * as SampleRecipientArtifact from '@cupcakes-sdk/common/artifacts/contracts/test/SampleRecipient.sol/SampleRecipient.json' 7 | 8 | import { BundlerConfig } from '../src/BundlerConfig' 9 | import { ERC4337EthersProvider, ERC4337EthersSigner, ClientConfig, newProvider } from '@cupcakes-sdk/sdk' 10 | import { Signer, Wallet } from 'ethers' 11 | import { runBundler } from '../src/runBundler' 12 | import { BundlerServer } from '../src/BundlerServer' 13 | import fs from 'fs' 14 | 15 | const { expect } = chai.use(chaiAsPromised) 16 | 17 | export async function startBundler(options: BundlerConfig): Promise { 18 | const args: any[] = [] 19 | args.push('--beneficiary', options.beneficiary) 20 | args.push('--entryPoint', options.entryPoint) 21 | args.push('--gasFactor', options.gasFactor) 22 | args.push('--helper', options.helper) 23 | args.push('--minBalance', options.minBalance) 24 | args.push('--mnemonic', options.mnemonic) 25 | args.push('--network', options.network) 26 | args.push('--port', options.port) 27 | 28 | return await runBundler(['node', 'cmd', ...args], true) 29 | } 30 | 31 | describe('Flow', function () { 32 | let bundlerServer: BundlerServer 33 | let entryPointAddress: string 34 | let sampleRecipientAddress: string 35 | let signer: Signer 36 | let chainId: number 37 | before(async function () { 38 | signer = await hre.ethers.provider.getSigner() 39 | chainId = await hre.ethers.provider.getNetwork().then((net) => net.chainId) 40 | const beneficiary = await signer.getAddress() 41 | 42 | const sampleRecipientFactory = await ethers.getContractFactory('SampleRecipient') 43 | const sampleRecipient = await sampleRecipientFactory.deploy() 44 | sampleRecipientAddress = sampleRecipient.address 45 | 46 | const EntryPointFactory = await ethers.getContractFactory('EntryPoint') 47 | const entryPoint = await EntryPointFactory.deploy(1, 1) 48 | entryPointAddress = entryPoint.address 49 | 50 | const bundleHelperFactory = await ethers.getContractFactory('BundlerHelper') 51 | const bundleHelper = await bundleHelperFactory.deploy() 52 | await signer.sendTransaction({ 53 | to: '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1', 54 | value: (10e18).toString(), 55 | }) 56 | 57 | const mnemonic = 'myth like bonus scare over problem client lizard pioneer submit female collect' 58 | const mnemonicFile = '/tmp/mnemonic.tmp' 59 | fs.writeFileSync(mnemonicFile, mnemonic) 60 | bundlerServer = await startBundler({ 61 | beneficiary, 62 | entryPoint: entryPoint.address, 63 | helper: bundleHelper.address, 64 | gasFactor: '0.2', 65 | minBalance: '0', 66 | mnemonic: mnemonicFile, 67 | network: 'http://localhost:8545/', 68 | port: '5555', 69 | }) 70 | }) 71 | 72 | after(async function () { 73 | await bundlerServer?.stop() 74 | }) 75 | 76 | let erc4337Signer: ERC4337EthersSigner 77 | let erc4337Provider: ERC4337EthersProvider 78 | 79 | it('should send transaction and make profit', async function () { 80 | const config: ClientConfig = { 81 | entryPointAddress, 82 | bundlerUrl: 'http://localhost:5555/rpc', 83 | chainId, 84 | } 85 | 86 | // use this as signer (instead of node's first account) 87 | const ownerAccount = Wallet.createRandom() 88 | erc4337Provider = await newProvider( 89 | ethers.provider, 90 | // new JsonRpcProvider('http://localhost:8545/'), 91 | config, 92 | ownerAccount 93 | ) 94 | erc4337Signer = erc4337Provider.getSigner() 95 | const simpleWalletPhantomAddress = await erc4337Signer.getAddress() 96 | 97 | await signer.sendTransaction({ 98 | to: simpleWalletPhantomAddress, 99 | value: (10e18).toString(), 100 | }) 101 | 102 | const sampleRecipientContract = new ethers.Contract( 103 | sampleRecipientAddress, 104 | SampleRecipientArtifact.abi, 105 | erc4337Signer 106 | ) 107 | console.log(sampleRecipientContract.address) 108 | 109 | const result = await sampleRecipientContract.something('hello world') 110 | console.log(result) 111 | const receipt = await result.wait() 112 | console.log(receipt) 113 | }) 114 | 115 | it.skip('should refuse transaction that does not make profit', async function () { 116 | sinon.stub(erc4337Signer, 'signUserOperation').returns(Promise.resolve('0x' + '01'.repeat(65))) 117 | const sampleRecipientContract = new ethers.Contract( 118 | sampleRecipientAddress, 119 | SampleRecipientArtifact.abi, 120 | erc4337Signer 121 | ) 122 | console.log(sampleRecipientContract.address) 123 | await expect(sampleRecipientContract.something('hello world')).to.be.eventually.rejectedWith( 124 | 'The bundler has failed to include UserOperation in a batch: "ECDSA: invalid signature \'v\' value"' 125 | ) 126 | }) 127 | }) 128 | -------------------------------------------------------------------------------- /packages/bundler/test/UserOpMethodHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { BaseProvider, JsonRpcSigner } from '@ethersproject/providers' 2 | import { assert, expect } from 'chai' 3 | import { ethers } from 'hardhat' 4 | import { parseEther } from 'ethers/lib/utils' 5 | 6 | import { UserOpMethodHandler } from '../src/UserOpMethodHandler' 7 | 8 | import { BundlerConfig } from '../src/BundlerConfig' 9 | import { BundlerHelper, SampleRecipient } from '../src/types' 10 | import { EntryPoint, SimpleWalletDeployer__factory, UserOperationStruct } from '@account-abstraction/contracts' 11 | 12 | import 'source-map-support/register' 13 | import { SimpleWalletAPI } from '@cupcakes-sdk/sdk' 14 | import { DeterministicDeployer } from '@cupcakes-sdk/sdk/src/DeterministicDeployer' 15 | 16 | describe('UserOpMethodHandler', function () { 17 | const helloWorld = 'hello world' 18 | 19 | let methodHandler: UserOpMethodHandler 20 | let provider: BaseProvider 21 | let signer: JsonRpcSigner 22 | 23 | let entryPoint: EntryPoint 24 | let bundleHelper: BundlerHelper 25 | let sampleRecipient: SampleRecipient 26 | 27 | before(async function () { 28 | provider = ethers.provider 29 | signer = ethers.provider.getSigner() 30 | 31 | const EntryPointFactory = await ethers.getContractFactory('EntryPoint') 32 | entryPoint = await EntryPointFactory.deploy(1, 1) 33 | 34 | const bundleHelperFactory = await ethers.getContractFactory('BundlerHelper') 35 | bundleHelper = await bundleHelperFactory.deploy() 36 | 37 | const sampleRecipientFactory = await ethers.getContractFactory('SampleRecipient') 38 | sampleRecipient = await sampleRecipientFactory.deploy() 39 | 40 | const config: BundlerConfig = { 41 | beneficiary: await signer.getAddress(), 42 | entryPoint: entryPoint.address, 43 | gasFactor: '0.2', 44 | helper: bundleHelper.address, 45 | minBalance: '0', 46 | mnemonic: '', 47 | network: '', 48 | port: '3000', 49 | } 50 | 51 | methodHandler = new UserOpMethodHandler(provider, signer, config, entryPoint, bundleHelper) 52 | }) 53 | 54 | describe('eth_supportedEntryPoints', function () { 55 | it('eth_supportedEntryPoints', async () => { 56 | await expect(await methodHandler.getSupportedEntryPoints()).to.eql([entryPoint.address]) 57 | }) 58 | }) 59 | 60 | describe('sendUserOperation', function () { 61 | let userOperation: UserOperationStruct 62 | let walletAddress: string 63 | 64 | before(async function () { 65 | const walletDeployerAddress = await DeterministicDeployer.deploy(SimpleWalletDeployer__factory.bytecode) 66 | 67 | const smartWalletAPI = new SimpleWalletAPI( 68 | provider, 69 | entryPoint.address, 70 | undefined, 71 | signer, 72 | walletDeployerAddress, 73 | 0 74 | ) 75 | walletAddress = await smartWalletAPI.getWalletAddress() 76 | await signer.sendTransaction({ 77 | to: walletAddress, 78 | value: parseEther('1'), 79 | }) 80 | 81 | userOperation = await smartWalletAPI.createSignedUserOp({ 82 | data: sampleRecipient.interface.encodeFunctionData('something', [helloWorld]), 83 | target: sampleRecipient.address, 84 | }) 85 | }) 86 | 87 | it('should send UserOperation transaction to BundlerHelper', async function () { 88 | const requestId = await methodHandler.sendUserOperation(userOperation, entryPoint.address) 89 | const req = await entryPoint.queryFilter(entryPoint.filters.UserOperationEvent(requestId)) 90 | const transactionReceipt = await req[0].getTransactionReceipt() 91 | 92 | assert.isNotNull(transactionReceipt) 93 | const depositedEvent = entryPoint.interface.parseLog(transactionReceipt.logs[0]) 94 | const senderEvent = sampleRecipient.interface.parseLog(transactionReceipt.logs[1]) 95 | const userOperationEvent = entryPoint.interface.parseLog(transactionReceipt.logs[2]) 96 | assert.equal(userOperationEvent.name, 'UserOperationEvent') 97 | assert.equal(userOperationEvent.args.success, true) 98 | 99 | assert.equal(senderEvent.name, 'Sender') 100 | const expectedTxOrigin = await methodHandler.signer.getAddress() 101 | assert.equal(senderEvent.args.txOrigin, expectedTxOrigin) 102 | assert.equal(senderEvent.args.msgSender, walletAddress) 103 | 104 | assert.equal(depositedEvent.name, 'Deposited') 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /packages/bundler/test/runBundler.test.ts: -------------------------------------------------------------------------------- 1 | describe('runBundler', function () { 2 | describe('resolveConfiguration', function () { 3 | it('') 4 | }) 5 | }) 6 | -------------------------------------------------------------------------------- /packages/bundler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "composite": true, 9 | "allowJs": true, 10 | "resolveJsonModule": true, 11 | "moduleResolution": "node", 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "declaration": true, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true, 17 | "sourceMap": true, 18 | "outDir": "dist", 19 | "typeRoots": [ 20 | "./node_modules/@nomiclabs/hardhat-ethers" 21 | ] 22 | }, 23 | "include": [ 24 | "./**/*.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/bundler/tsconfig.packages.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "outDir": "dist", 8 | "strict": true, 9 | "composite": true, 10 | "allowJs": true, 11 | "resolveJsonModule": true, 12 | "moduleResolution": "node", 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "declaration": true, 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true, 18 | "sourceMap": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/common/.depcheckrc: -------------------------------------------------------------------------------- 1 | ignores: ["@openzeppelin/contracts"] 2 | -------------------------------------------------------------------------------- /packages/common/contracts/test/SampleRecipient.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | // TODO: get hardhat types from '@account-abstraction' package directly 5 | // only to import the file in hardhat compilation 6 | import "@account-abstraction/contracts/samples/SimpleWallet.sol"; 7 | 8 | contract SampleRecipient { 9 | 10 | SimpleWallet wallet; 11 | 12 | event Sender(address txOrigin, address msgSender, string message); 13 | 14 | function something(string memory message) public { 15 | emit Sender(tx.origin, msg.sender, message); 16 | } 17 | 18 | function reverting() public { 19 | revert( "test revert"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/common/contracts/test/SingletonFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: CC0-1.0 2 | pragma solidity ^0.8.15; 3 | 4 | /** 5 | * @title Singleton Factory (EIP-2470) 6 | * @notice Exposes CREATE2 (EIP-1014) to deploy bytecode on deterministic addresses based on initialization code and salt. 7 | * @author Ricardo Guilherme Schmidt (Status Research & Development GmbH) 8 | */ 9 | contract SingletonFactory { 10 | /** 11 | * @notice Deploys `_initCode` using `_salt` for defining the deterministic address. 12 | * @param _initCode Initialization code. 13 | * @param _salt Arbitrary value to modify resulting address. 14 | * @return createdContract Created contract address. 15 | */ 16 | function deploy(bytes memory _initCode, bytes32 _salt) 17 | public 18 | returns (address payable createdContract) 19 | { 20 | assembly { 21 | createdContract := create2(0, add(_initCode, 0x20), mload(_initCode), _salt) 22 | } 23 | } 24 | } 25 | // IV is a value changed to generate the vanity address. 26 | // IV: 6583047 27 | -------------------------------------------------------------------------------- /packages/common/hardhat.config.ts: -------------------------------------------------------------------------------- 1 | // import '@nomiclabs/hardhat-ethers' 2 | import '@nomicfoundation/hardhat-toolbox' 3 | 4 | import { HardhatUserConfig } from 'hardhat/config' 5 | 6 | const config: HardhatUserConfig = { 7 | typechain: { 8 | outDir: 'src/types', 9 | target: 'ethers-v5' 10 | }, 11 | solidity: { 12 | version: '0.8.15', 13 | settings: { 14 | optimizer: { enabled: true } 15 | } 16 | } 17 | } 18 | 19 | export default config 20 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cupcakes-sdk/common", 3 | "version": "0.2.1", 4 | "main": "./dist/src/index.js", 5 | "license": "MIT", 6 | "files": [ 7 | "src/*", 8 | "dist/*", 9 | "contracts/*", 10 | "README.md" 11 | ], 12 | "scripts": { 13 | "clear": "rm -rf dist artifacts cache src/types", 14 | "hardhat-compile": "yarn clear && hardhat compile", 15 | "hardhat-deploy": "hardhat deploy", 16 | "hardhat-node": "hardhat node", 17 | "lint-fix": "eslint -f unix . --fix", 18 | "watch-tsc": "tsc -w --preserveWatchOutput", 19 | "tsc": "tsc" 20 | }, 21 | "dependencies": { 22 | "@account-abstraction/contracts": "cupcakes-3/aa-contracts", 23 | "@ethersproject/abi": "^5.7.0", 24 | "@ethersproject/bytes": "^5.7.0", 25 | "@ethersproject/providers": "^5.7.0", 26 | "@openzeppelin/contracts": "^4.7.3", 27 | "ethers": "^5.7.0" 28 | }, 29 | "devDependencies": { 30 | "@nomicfoundation/hardhat-toolbox": "^1.0.2", 31 | "@nomiclabs/hardhat-ethers": "^2.0.0", 32 | "hardhat": "^2.11.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/common/src/ERC4337Utils.ts: -------------------------------------------------------------------------------- 1 | import { arrayify, defaultAbiCoder, keccak256 } from 'ethers/lib/utils' 2 | import { UserOperationStruct } from '@account-abstraction/contracts' 3 | import { abi as entryPointAbi } from '@account-abstraction/contracts/artifacts/IEntryPoint.json' 4 | 5 | // UserOperation is the first parameter of simulateValidation 6 | const UserOpType = entryPointAbi.find(entry => entry.name === 'simulateValidation')?.inputs[0] 7 | 8 | // reverse "Deferrable" or "PromiseOrValue" fields 9 | export type NotPromise = { 10 | [P in keyof T]: Exclude> 11 | } 12 | 13 | function encode (typevalues: Array<{ type: string, val: any }>, forSignature: boolean): string { 14 | const types = typevalues.map(typevalue => typevalue.type === 'bytes' && forSignature ? 'bytes32' : typevalue.type) 15 | const values = typevalues.map((typevalue) => typevalue.type === 'bytes' && forSignature ? keccak256(typevalue.val) : typevalue.val) 16 | return defaultAbiCoder.encode(types, values) 17 | } 18 | 19 | export function packUserOp (op: NotPromise, forSignature = true): string { 20 | if (forSignature) { 21 | // lighter signature scheme (must match UserOperation#pack): do encode a zero-length signature, but strip afterwards the appended zero-length value 22 | const userOpType = { 23 | components: [ 24 | { type: 'address', name: 'sender' }, 25 | { type: 'uint256', name: 'nonce' }, 26 | { type: 'bytes', name: 'initCode' }, 27 | { type: 'bytes', name: 'callData' }, 28 | { type: 'uint256', name: 'callGasLimit' }, 29 | { type: 'uint256', name: 'verificationGasLimit' }, 30 | { type: 'uint256', name: 'preVerificationGas' }, 31 | { type: 'uint256', name: 'maxFeePerGas' }, 32 | { type: 'uint256', name: 'maxPriorityFeePerGas' }, 33 | { type: 'bytes', name: 'paymasterAndData' }, 34 | { type: 'bytes', name: 'signature' } 35 | ], 36 | name: 'userOp', 37 | type: 'tuple' 38 | } 39 | // console.log('hard-coded userOpType', userOpType) 40 | // console.log('from ABI userOpType', UserOpType) 41 | let encoded = defaultAbiCoder.encode([userOpType as any], [{ ...op, signature: '0x' }]) 42 | // remove leading word (total length) and trailing word (zero-length signature) 43 | encoded = '0x' + encoded.slice(66, encoded.length - 64) 44 | return encoded 45 | } 46 | const typedValues = (UserOpType as any).components.map((c: {name: keyof typeof op, type: string}) => ({ 47 | type: c.type, 48 | val: op[c.name] 49 | })) 50 | const typevalues = [ 51 | { type: 'address', val: op.sender }, 52 | { type: 'uint256', val: op.nonce }, 53 | { type: 'bytes', val: op.initCode }, 54 | { type: 'bytes', val: op.callData }, 55 | { type: 'uint256', val: op.callGasLimit }, 56 | { type: 'uint256', val: op.verificationGasLimit }, 57 | { type: 'uint256', val: op.preVerificationGas }, 58 | { type: 'uint256', val: op.maxFeePerGas }, 59 | { type: 'uint256', val: op.maxPriorityFeePerGas }, 60 | { type: 'bytes', val: op.paymasterAndData } 61 | ] 62 | console.log('hard-coded typedvalues', typevalues) 63 | console.log('from ABI typedValues', typedValues) 64 | if (!forSignature) { 65 | // for the purpose of calculating gas cost, also hash signature 66 | typevalues.push({ type: 'bytes', val: op.signature }) 67 | } 68 | return encode(typevalues, forSignature) 69 | } 70 | 71 | export function getRequestId (op: NotPromise, entryPoint: string, chainId: number): string { 72 | const userOpHash = keccak256(packUserOp(op, true)) 73 | const enc = defaultAbiCoder.encode( 74 | ['bytes32', 'address', 'uint256'], 75 | [userOpHash, entryPoint, chainId]) 76 | return keccak256(enc) 77 | } 78 | 79 | export function getRequestIdForSigning (op: NotPromise, entryPoint: string, chainId: number): Uint8Array { 80 | return arrayify(getRequestId(op, entryPoint, chainId)) 81 | } 82 | -------------------------------------------------------------------------------- /packages/common/src/SolidityTypeAliases.ts: -------------------------------------------------------------------------------- 1 | // define the same export types as used by export typechain/ethers 2 | import { BigNumberish } from 'ethers' 3 | import { BytesLike } from '@ethersproject/bytes' 4 | 5 | export type address = string 6 | 7 | export type uint256 = BigNumberish 8 | export type uint64 = BigNumberish 9 | 10 | export type bytes = BytesLike 11 | export type bytes32 = BytesLike 12 | -------------------------------------------------------------------------------- /packages/common/src/Version.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | export const erc4337RuntimeVersion: string = require('../../package.json').version 3 | -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Version' 2 | export * from './ERC4337Utils' 3 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./**/*.ts"], 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "target": "es2017", 6 | "module": "commonjs", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "composite": true, 11 | "allowJs": true, 12 | "resolveJsonModule": true, 13 | "moduleResolution": "node", 14 | "noImplicitThis": true, 15 | "alwaysStrict": true, 16 | "declaration": true, 17 | "experimentalDecorators": true, 18 | "emitDecoratorMetadata": true, 19 | "sourceMap": true, 20 | "skipLibCheck": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/paymaster/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paymaster-service", 3 | "version": "0.1.0", 4 | "description": "This is the paymaster service by cupcakes.", 5 | "main": "src/server.ts", 6 | "repository": "https://github.com/cupcakes-3/sdk", 7 | "author": "Garvit Khatri (https://mirror.xyz/plusminushalf.eth)", 8 | "license": "MIT", 9 | "private": true, 10 | "scripts": { 11 | "start": "npx nodemon src/server.ts" 12 | }, 13 | "dependencies": { 14 | "@account-abstraction/contracts": "cupcakes-3/aa-contracts", 15 | "@types/express": "^4.17.14", 16 | "cors": "^2.8.5", 17 | "dotenv": "^16.0.2", 18 | "ethers": "^5.7.1", 19 | "express": "^4.18.1", 20 | "express-async-handler": "^1.2.0" 21 | }, 22 | "devDependencies": { 23 | "nodemon": "^2.0.20", 24 | "ts-node": "^10.9.1", 25 | "typescript": "^4.8.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/paymaster/src/server.ts: -------------------------------------------------------------------------------- 1 | import express, { Express, Request, Response } from 'express' 2 | import dotenv from 'dotenv' 3 | import { ethers, BigNumber, BigNumberish, Signer } from 'ethers' 4 | import asyncHandler from 'express-async-handler' 5 | import { EntryPoint__factory, VerifyingPaymaster__factory } from '@account-abstraction/contracts' 6 | // import SimpleWalletArtifact from './abi/SimpleWallet.json' 7 | // import EntryPointArtifact from './abi/EntryPoint.json' 8 | // import PaymasterArtifact from './abi/VerifyingPaymaster.json' 9 | import { hexConcat } from 'ethers/lib/utils' 10 | import { BytesLike } from '@ethersproject/bytes' 11 | 12 | import cors from 'cors' 13 | 14 | export type address = string 15 | export type uint256 = BigNumberish 16 | export type uint = BigNumberish 17 | export type uint64 = BigNumberish 18 | export type bytes = BytesLike 19 | export type bytes32 = BytesLike 20 | export type uint112 = BigNumber 21 | export type uint32 = BigNumberish 22 | 23 | dotenv.config() 24 | 25 | const app: Express = express() 26 | const port = process.env.PORT ?? 8080 27 | 28 | const ENTRY_POINT_CONTRACT = process.env.ENTRYPOINT_ADDR ?? '' 29 | 30 | app.use(cors()) 31 | app.use(express.json()) 32 | 33 | interface UserOperation { 34 | sender: address 35 | nonce: uint256 36 | initCode: bytes 37 | callData: bytes 38 | callGasLimit: uint256 39 | verificationGasLimit: uint256 40 | preVerificationGas: uint256 41 | maxFeePerGas: uint256 42 | maxPriorityFeePerGas: uint256 43 | paymasterAndData: bytes 44 | signature: bytes 45 | } 46 | 47 | interface DepositInfo { 48 | deposit: uint112 49 | staked: boolean 50 | stake: uint112 51 | unstakeDelaySec: uint32 52 | withdrawTime: uint64 53 | } 54 | 55 | const getHash = async ( 56 | paymasterAddr: string, 57 | entryPointAddr: string, 58 | signer: Signer, 59 | userOp: UserOperation 60 | ): Promise => { 61 | const Paymaster = VerifyingPaymaster__factory.connect(paymasterAddr, signer) 62 | 63 | const EntryPoint = EntryPoint__factory.connect(entryPointAddr, signer) 64 | 65 | const depositInfo: DepositInfo = await EntryPoint.getDepositInfo(paymasterAddr) 66 | console.log('depositInfo', depositInfo) 67 | 68 | console.log(await signer.getAddress()) 69 | if (!depositInfo.staked) { 70 | console.log('we are not staked, adding stake') 71 | const tx = await Paymaster.addStake(100, { 72 | value: ethers.utils.parseEther('1'), 73 | }) 74 | await tx.wait() 75 | } 76 | 77 | // if (depositInfo.deposit.lt(ethers.utils.parseEther('0.1'))) { 78 | // const tx = await Paymaster.deposit({ 79 | // value: ethers.utils.parseEther('0.1'), 80 | // }) 81 | // await tx.wait() 82 | // console.log(tx) 83 | // } 84 | 85 | const UserOp = [ 86 | 'sender', 87 | 'nonce', 88 | 'initCode', 89 | 'callData', 90 | 'callGasLimit', 91 | 'verificationGasLimit', 92 | 'preVerificationGas', 93 | 'maxFeePerGas', 94 | 'maxPriorityFeePerGas', 95 | 'paymasterAndData', 96 | 'signature', 97 | ] 98 | 99 | userOp = { 100 | sender: userOp?.sender ?? '0x', 101 | nonce: ethers.BigNumber.from(userOp?.nonce ?? '0x'), 102 | initCode: userOp?.initCode ?? '0x', 103 | callData: userOp?.callData ?? '0x', 104 | callGasLimit: ethers.BigNumber.from(userOp?.callGasLimit ?? '0x'), 105 | verificationGasLimit: ethers.BigNumber.from(userOp?.verificationGasLimit ?? '0x'), 106 | preVerificationGas: ethers.BigNumber.from(userOp?.preVerificationGas ?? '0x'), 107 | maxFeePerGas: ethers.BigNumber.from(userOp?.maxFeePerGas ?? '0x'), 108 | maxPriorityFeePerGas: ethers.BigNumber.from(userOp?.maxPriorityFeePerGas ?? '0x'), 109 | paymasterAndData: userOp?.paymasterAndData ?? '0x', 110 | signature: userOp?.signature ?? '0x', 111 | } 112 | 113 | UserOp.forEach((key) => { 114 | userOp[key] = userOp[key] ?? '0x' 115 | }) 116 | 117 | return await Paymaster.getHash(userOp) 118 | } 119 | 120 | const supportedKeys = process.env.SUPORTED_KEYS.split(' ') 121 | const paymasters = process.env.PAYMASTERS.split(' ') 122 | 123 | const keyWebhookMap = supportedKeys.reduce((result, key, index) => { 124 | return { 125 | ...result, 126 | [key]: paymasters[index], 127 | } 128 | }, {}) 129 | 130 | app.get('/', (req: Request, res: Response) => { 131 | res.json({ 132 | status: 200, 133 | }) 134 | }) 135 | 136 | app.post( 137 | '/signPaymaster', 138 | asyncHandler(async (req: Request, res: Response): Promise => { 139 | const { userOp, apiKey } = req.body 140 | 141 | if (!supportedKeys.includes(apiKey) || keyWebhookMap[apiKey] !== '0x14d239e4f31eeFBbB5B91F0Cee5F82dc639BD7d4') { 142 | res.json({ 143 | paymasterAndData: `0x`, 144 | }) 145 | return 146 | } 147 | 148 | const provider = new ethers.providers.JsonRpcProvider(process.env.RPC) 149 | const wallet = new ethers.Wallet(process.env.PRIVATE_KEY ?? '', provider) 150 | const hash = await getHash(keyWebhookMap[apiKey], ENTRY_POINT_CONTRACT, wallet, userOp) 151 | const paymasterAndData = hexConcat([keyWebhookMap[apiKey], await wallet.signMessage(ethers.utils.arrayify(hash))]) 152 | res.json({ paymasterAndData }) 153 | }) 154 | ) 155 | 156 | app.listen(port, () => { 157 | console.log(`⚡️[server]: Server is running at https://localhost:${port}`) 158 | }) 159 | -------------------------------------------------------------------------------- /packages/paymaster/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "outDir": "dist" 9 | }, 10 | "lib": ["es2015"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/scw/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Cupcakes allow DAPPs developers access to Smart Contract Wallets. These wallets can be DAPPs specific or User specific. You must read about [Wallets section](#wallets) before using the SDK. 4 | 5 | 6 | ## Getting Started 7 | 8 | A guide for adding a Cupcakes SDK to your application & start bundling transactions. There are two parts of the documentation, [**bundling transaction**](#bundle-transactions) and [**sponsoring gas**](#gassless-experience). 9 | 10 | For both of them you would need to install our SDK. For sponsoring gas, you will have to first create a paymaster contract. To know more about how to create a paymaster contract, read [here](#gassless-experience). 11 | 12 | ### What you'll need 13 | 14 | - [Node.js](https://nodejs.org/en/download/) version 16.14 or above: 15 | - When installing Node.js, you are recommended to check all checkboxes related to dependencies. 16 | 17 | ## Installing SDK 18 | 19 | Our SDK is currently under development, we will be hosting it on NPM soon. The Client SDK will be available in JavaScript with full TypeScript support. 20 | 21 | 22 | ```bash 23 | npm install @cupcakes-sdk/scw 24 | ``` 25 | ```bash 26 | yarn add @cupcakes-sdk/scw 27 | ``` 28 | 29 | # Wallets 30 | 31 | Smart Contract Wallets (SCW) allows DAPP developer to bundle multiple transaction & pay gas fees for their users. You must create a SCW using our SDK for every user. The final bundled call will initiate from user's SCW. If you want to transfer the final assets to the user's current EOA, then you MUST send the transaction to transfer the assets from SCW to EOA separately. 32 | 33 | To know how to create a SCW for a user see [Dapp specific wallets](#dapp-specific-wallets). 34 | 35 | 36 | 37 | > :warning: **Wallet is not deployed instantly**, it will only be deployed once you do the first transaction, resulting in a higher gas fees in the first transaction. Though the scw address is **deterministic** and funds can be sent to the address. 38 | 39 | --- 40 | 41 | ## Dapp specific wallets 42 | 43 | Install our SDK using instructions [here](#installing-sdk). 44 | 45 | ### Initiate a wallet 46 | 47 | Create a Smart Contract Wallet for a user. You MUST pass a signer while creating the SCW. The signer will have the custody of the SCW. 48 | 49 | ```typescript 50 | import { Signer } from 'ethers' 51 | import { SCWProvider } from '@cupcakes-sdk/scw' 52 | 53 | /** 54 | * You can get signer using either the private key 55 | * const signer: Signer = new ether.Wallet(privateKey); 56 | * You can get signer if user has an EOA using wagmi.sh 57 | * const { data: signer } = useSigner(); 58 | */ 59 | 60 | /* Once you have signer & provider, create user's SCW */ 61 | 62 | /** 63 | * @param provder - any BaseProvider from ethers (JSONRpcProvider | window.ethereum | etc) 64 | * @param signer - this will be the owner of the SCW that will be deployed. 65 | */ 66 | const scwProvider: SCWProvider = await SCWProvider.getSCWForOwner(provider, signer) 67 | ``` 68 | 69 | Once the SCW has been initiated, you can use it as a normal signer with ethers/web3/etc to connect & send bundled transactions. 70 | 71 | ### Executing transactions 72 | 73 | You can get `Signer` from the `SCWProvider` we created above & start using it normally as you would use an EOA. 74 | 75 | ```typescript 76 | const scwSigner = scwProvider.getSigner() 77 | const greeter = new ethers.Contract(GREETER_ADDR, GreeterArtifact.abi, scwSigner) 78 | 79 | const tx = await greeter.addGreet({ 80 | value: ethers.utils.parseEther('0.0001'), 81 | }) 82 | console.log(tx) 83 | ``` 84 | 85 | ### Bundling transactions 86 | 87 | You can also send multiple transactions within a single transaction using SCW. Think of approvide `ERC20` tokens & `deposit` them in a single transaction with a single signature from the users. 88 | 89 | Read more about how [here](#bundle-transactions). 90 | 91 | > :warning: The transactions sent using ethers/web3/etc won't be by default bundled or sponsored. Use `sendTransactions` instead to bundle transactions, see [Bundle Transactions](#bundle-transactions). If you want to sponer, make sure you connect a `paymaster`, see [Gassless Experience](#gassless-experience) 92 | 93 | 94 | # Bundle Transactions 95 | 96 | Bundling transactions opens up a plathora of possibilities. We have listed a few of them as example: 97 | 98 | 1. Users won't have to do two transactions for approving an ERC20 token & then depositing it. 99 | 2. You can easily support depositing of any ERC20 in your app. Just add a transaction to swap user's token to your preffered token using any Dex. 100 | 3. Modular Contract designs, deploy only specific contract modules and then join them off-chain using a bundler transactions. 101 | 102 | ## Single chain bundling 103 | 104 | You must have initialised iSDK & created a `SCWProvider`. We have exposed a function in a `SCWSigner` called `sendTransactions` using which you can send multiple transactions. 105 | 106 | ```typescript 107 | const scwSigner = scwProvider.getSigner() 108 | const greeter = new ethers.Contract(GREETER_ADDR, GreeterArtifact.abi, scwSigner) 109 | 110 | const transactionData = greeter.interface.encodeFunctionData('addGreet') 111 | 112 | const tx = await scwProvider.sendTransactions([ 113 | { 114 | to: GREETER_ADDR, 115 | value: ethers.utils.parseEther('0.0001'), 116 | data: transactionData, 117 | }, 118 | { 119 | to: GREETER_ADDR, 120 | value: ethers.utils.parseEther('0.0002'), 121 | data: transactionData, 122 | }, 123 | ]) 124 | console.log(tx) 125 | ``` 126 | 127 | ```typescript title="Getting approval for ERC20 token & depositing together" 128 | await scwProvider.sendTransactions([ 129 | { 130 | to: ERC20_TOKEN_ADDR, 131 | value: ethers.utils.parseEther('0.1'), 132 | data: TOKEN.interface.encodeFunctionData('approve', [ 133 | spenderAddress, 134 | ethers.utils.parseEther(amount * 10), // getting approval from 10 times the amount to be spent 135 | ]), 136 | }, 137 | { 138 | to: myContract.address, 139 | value: ethers.utils.parseEther('0.1'), 140 | data: myContract.interface.encodeFunctionData('stake', [ERC20_TOKEN_ADDR, ethers.utils.parseEther(amount)]), 141 | }, 142 | ]) 143 | ``` 144 | 145 | ## Cross chain bundling 146 | 147 | > :warning: **Cross-chain Bundling** will be coming soon, which will enable you to add bridging transactions to your transactions as well. 148 | 149 | # Gassless Experience 150 | 151 | Cupkaes SDK will enable conditional gassless experience, which includes partial gas-sponsoring. This enables you to have complex integrations like: sponsoring of gas on ethereum upto $5 and 100% on L2/sidechain. 152 | 153 | Before you can start sponsoring gas, you must [deploy](#deploy-a-paymaster) a paymaster contract. The paymaster _MUST_ be staked & should have enough deposit to sponsor for gas. If the deposited amount becomes lesser than the gas required then your transactions will start failing. 154 | 155 | --- 156 | 157 | ## Paymaster 158 | 159 | Paymaster is a contract that sponsors gas fees on behalf of users. To know more about how it works, read in the [architecture section](#overview). 160 | 161 | To enable gas sponsoring these are the steps you must do: 162 | 163 | 1. [Deploy a paymaster](#deploy-a-paymaster) 164 | 2. [Stake paymaster](#stake--deposit-funds) 165 | 3. [Register a webhook](#register-webhook) 166 | 4. [Integrate with frontend](#integrate-with-frontend) 167 | 168 | ### Deploy a paymaster 169 | 170 | Head to our website [https://comingsoon@cupcakes](https://bit.ly/gas_less) and follow the steps shown in the video below to deploy your first paymaster. 171 | 172 | ```mdx-code-block 173 | 174 | ``` 175 | 176 | ### Stake & deposit funds 177 | 178 | Once you have created your paymaster, you will have to stake your funds. The Minimum stake as of now is `x MATIC` with a lock-in of `7 days`. The stake is to make sure no fraudulant activity can be performed by the paymaster. The staked funds will be deductded if any such fraudulant activity is found. 179 | 180 | > :warning: You must have enough deposit left to cover for 100% of the gas fees even if you only want to sponsor a portion of it. If desposit is not enough, the transaction will be reverted. 181 | 182 | Learn more about how your stake can be slashed more in detail [here](#overview). 183 | 184 | ### Register webhook 185 | 186 | You will have to register a webhook, where we will be sending the a `POST` request to verify the sponsoring of the gas. 187 | 188 | The requst will have the following body: 189 | 190 | ```json 191 | { 192 | "auth_code": "b110a339-ff6c-4456-8adb-b236a3da11d3", 193 | "timestamp": 1662805504483, 194 | "userOperation": { 195 | "sender": "0xadb2...asd4", // Sender's address of the SCW 196 | "maxGasCost": 123, // you can use this as the total of all the above gas breakup & use this to make decision of sponsoring 197 | "paymasterDeposit": 123, // the amount of deposit left in your paymaster contract, you can send refill transactions using this if you want to 198 | "paymasterAddress": "0x23rr...", // your paymaster contract address, you should send money to this address if paymasterDeposit is approaching zero 199 | "transactions": [ 200 | // this is the array of transactions that your frontend SDK included for bundling 201 | { 202 | "to": "0x123..", 203 | "value": 4, // value in ethers 204 | "data": "0xadsf..." // call data your SDK passed 205 | } 206 | ], 207 | // The following fields are part of the UserOperation that will be used to generate signature, you can ignore these if you are using our paymaster SDK 208 | "nonce": 123, 209 | "initCode": "0xAxvd3r....adfsg4r", //init code, if not empty means that this wallet doesn't exist and will be deployed in this transaction along with executing the required transaction 210 | "callData": "0xsdfdasf...000", // call data of the execution 211 | "callGas": 123, // the amount of gas the main execution of transaction will take 212 | "verificationGas": 123, //the constant amount of gas which is required to verify sender's ownership 213 | "preVerificationGas": 123, // the constant amount of gas which is required by the bundler for processing the transaction 214 | "maxFeePerGas": 123, // the maximum gas price, this depends on how busy the network is 215 | "maxPriorityFeePerGas": 123 // the fee that will be used to tip the miner 216 | } 217 | } 218 | ``` 219 | 220 | You must verify `auth_code` to check if the call is from our service or not. You will see the `auth_code` once you register a success webhook. 221 | 222 | You must return with a `200` code if you agree to sponsor the transaction. If you choose not to sponsor, you must return with a `403 - Forbidden` status code response. 223 | 224 | ### Integrate with frontend 225 | 226 | You will have to connect your paymaster with the SCW you created in [Wallets section](#initiate-a-wallet). 227 | 228 | ```typescript 229 | import { PaymasterAPI } from '@cupcakes-sdk/scw' 230 | 231 | // You can get the your API KEY when you create a paymaster, every paymaster has a different API KEY 232 | 233 | /* Connect to us to get Paymaster URL & Paymaster API KEY */ 234 | const paymasterAPI = new PaymasterAPI(process.env.REACT_APP_PAYMASTER_URL, process.env.REACT_APP_PAYMASTER_API_KEY) 235 | 236 | /* Connect your paymaster to the provider */ 237 | scwProvider.connectPaymaster(paymasterAPI) 238 | 239 | /* Do transaction as normal */ 240 | const scwSigner = scwProvider.getSigner() 241 | const greeter = new ethers.Contract(GREETER_ADDR, GreeterArtifact.abi, scwSigner) 242 | 243 | const tx = await greeter.addGreet({ 244 | value: ethers.utils.parseEther('0.0001'), 245 | }) 246 | console.log(tx) 247 | 248 | /* Disconnect if you don't want to sponsor amny further */ 249 | scwProvider.disconnectPaymaster() 250 | ``` 251 | 252 | # Overview 253 | 254 | ## Smart Contract Wallet (SCW) 255 | 256 | Each SCW has a signer assiciated with it, 257 | 258 |  259 | 260 | ## Bundling 261 | 262 |  263 | 264 | ## Gassless Experience 265 | 266 |  267 | -------------------------------------------------------------------------------- /packages/scw/assets/architecture-basic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | BASIC SCW interactionBASIC SCW interactionYour APP's ClientYour APP's ClientInitiate a walletInitiate a walletConnect returned to a ethers ContractConnect returned to a et...Initiate a transactionInitiate a transactionClient's SDKClient's SDKReturn a ethers SignerReturn a ethers SignerYESYESNONOis wallet deployed?is wallet deploy...Create a userOperation ObjectCreate a userOperation O...Build initCode for the wallet's deploymentBuild initCode for the w...Sign the userOperationSign the userOperationBundler ServiceBundler ServiceRelays the transaction to blockchainRelays the transaction t...Get gas estimatesGet gas estimatesEntryPoint ContractEntryPoint ContractNONOYESYESIs Wallet DeployedIs Wallet Deploy...Deploy SCW Deploy SCW Verify WalletVerify WalletCall wallet to execute transactionCall wallet to execute t...Smart Contract WalletSmart Contract WalletInitialiseInitialiseVerify the signatureVerify the signatureCall the DAPP from SCWCall the DAPP from SCWDAPP's ContractDAPP's ContractExecute sender's requestExecute sender's requestText is not SVG - cannot display -------------------------------------------------------------------------------- /packages/scw/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cupcakes-sdk/scw", 3 | "version": "0.2.6", 4 | "main": "./dist/src/index.js", 5 | "license": "MIT", 6 | "files": [ 7 | "src/*", 8 | "dist/*", 9 | "README.md" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/cupcakes-3/sdk" 14 | }, 15 | "keywords": [ 16 | "solidity", 17 | "ethereum", 18 | "smart", 19 | "contracts", 20 | "accipt-abstraction", 21 | "eip-4337", 22 | "wallets" 23 | ], 24 | "bugs": { 25 | "url": "https://github.com/cupcakes-3/sdk/issues" 26 | }, 27 | "scripts": { 28 | "clear": "rm -rf dist artifacts cache", 29 | "lint": "eslint -f unix .", 30 | "lint-fix": "eslint -f unix . --fix", 31 | "test": "hardhat test", 32 | "hardhat-test": "hardhat test", 33 | "tsc": "tsc", 34 | "watch-tsc": "tsc -w --preserveWatchOutput" 35 | }, 36 | "dependencies": { 37 | "@account-abstraction/contracts": "cupcakes-3/aa-contracts", 38 | "@cupcakes-sdk/sdk": "0.2.1", 39 | "@cupcakes-sdk/common": "0.2.1", 40 | "@ethersproject/abstract-provider": "^5.7.0", 41 | "@ethersproject/abstract-signer": "^5.7.0", 42 | "@ethersproject/networks": "^5.7.0", 43 | "@ethersproject/properties": "^5.7.0", 44 | "@ethersproject/providers": "^5.7.0", 45 | "axios": "^0.27.2", 46 | "dotenv": "^16.0.2", 47 | "ethers": "^5.7.0" 48 | }, 49 | "devDependencies": { 50 | "@nomicfoundation/hardhat-chai-matchers": "^1.0.3", 51 | "@nomicfoundation/hardhat-toolbox": "^1.0.2", 52 | "@nomiclabs/hardhat-ethers": "^2.0.0", 53 | "chai": "^4.3.6", 54 | "hardhat": "^2.11.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/scw/src/PaymasterAPI.ts: -------------------------------------------------------------------------------- 1 | import { UserOperationStruct } from '@account-abstraction/contracts' 2 | import { resolveProperties } from '@ethersproject/properties' 3 | import axios from 'axios' 4 | 5 | export class PaymasterAPI { 6 | constructor(readonly apiUrl: string, readonly apiKey: string) { 7 | axios.defaults.baseURL = apiUrl 8 | } 9 | 10 | async getPaymasterAndData(userOp: Partial): Promise { 11 | userOp = await resolveProperties(userOp) 12 | const result = await axios.post('/signPaymaster', { 13 | apiKey: this.apiKey, 14 | userOp, 15 | }) 16 | 17 | return result.data.paymasterAndData 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/scw/src/SCWProvider.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | import { ClientConfig, ERC4337EthersProvider, PaymasterAPI, SimpleWalletAPI } from '@cupcakes-sdk/sdk' 3 | import { EntryPoint, EntryPoint__factory, SimpleWalletDeployer__factory } from '@account-abstraction/contracts' 4 | import { HttpRpcClient } from '@cupcakes-sdk/sdk/dist/src/HttpRpcClient' 5 | import { Signer } from '@ethersproject/abstract-signer' 6 | import { BaseProvider, TransactionRequest, TransactionResponse } from '@ethersproject/providers' 7 | import { Deferrable } from '@ethersproject/properties' 8 | 9 | export interface BundlerChainMap { 10 | [chainId: number]: string 11 | } 12 | 13 | export interface SCWProviderConfig { 14 | entryPointAddress?: string 15 | walletDeployer?: string 16 | bundlerUrlMapping?: BundlerChainMap 17 | scwIndex?: number 18 | } 19 | 20 | export const defaultSCWProviderConfig: SCWProviderConfig = { 21 | entryPointAddress: '0x2167fA17BA3c80Adee05D98F0B55b666Be6829d6', 22 | walletDeployer: '0x568181CaB8a5EBDEeaD289ae745C3166bbEAfF3a', 23 | bundlerUrlMapping: { 24 | // goerli bundler address 25 | 5: 'https://eip4337-bundler-goerli.protonapp.io/rpc', 26 | }, 27 | scwIndex: 0, 28 | } 29 | 30 | // TODO: Add support for multiple SCW implementations 31 | export class SCWProvider extends ERC4337EthersProvider { 32 | constructor( 33 | readonly config: ClientConfig, 34 | readonly originalSigner: Signer, 35 | readonly originalProvider: BaseProvider, 36 | readonly httpRpcClient: HttpRpcClient, 37 | readonly entryPoint: EntryPoint, 38 | readonly smartWalletAPI: SimpleWalletAPI 39 | ) { 40 | super(config, originalSigner, originalProvider, httpRpcClient, entryPoint, smartWalletAPI) 41 | } 42 | 43 | getSCWOwner = (): Signer => { 44 | return this.originalSigner 45 | } 46 | 47 | sendTransactions = async (transactions: Array>): Promise => { 48 | const txs: TransactionRequest[] = await Promise.all( 49 | transactions.map(async (tx) => await this.signer.populateTransaction(tx)) 50 | ) 51 | await Promise.all(txs.map(async (tx) => await this.signer.verifyAllNecessaryFields(tx))) 52 | 53 | return await this.signer.sendTransaction(await this.smartWalletAPI.getBatchExecutionTransaction(txs)) 54 | } 55 | 56 | connectPaymaster = (paymasterAPI: PaymasterAPI): void => { 57 | this.smartWalletAPI.connectPaymaster(paymasterAPI) 58 | } 59 | 60 | disconnectPaymaster = (): void => { 61 | this.smartWalletAPI.disconnectPaymaster() 62 | } 63 | 64 | isSCWDeployed = async (): Promise => { 65 | const code = await this.originalProvider.getCode(this.getSenderWalletAddress()) 66 | return code !== '0x' 67 | } 68 | 69 | static async getSCWForOwner( 70 | originalProvider: ethers.providers.BaseProvider, 71 | owner: ethers.Signer, 72 | config: SCWProviderConfig = defaultSCWProviderConfig 73 | ): Promise { 74 | config.bundlerUrlMapping = config.bundlerUrlMapping ?? defaultSCWProviderConfig.bundlerUrlMapping ?? '' 75 | config.entryPointAddress = config.entryPointAddress ?? defaultSCWProviderConfig.entryPointAddress ?? '' 76 | config.scwIndex = config.scwIndex ?? defaultSCWProviderConfig.scwIndex ?? 0 77 | config.walletDeployer = config.walletDeployer ?? defaultSCWProviderConfig.walletDeployer ?? '' 78 | 79 | const network = await originalProvider.getNetwork() 80 | const entryPointAddress = config.entryPointAddress 81 | 82 | const providerConfig: ClientConfig = { 83 | entryPointAddress, 84 | bundlerUrl: config.bundlerUrlMapping[network.chainId], 85 | chainId: network.chainId, 86 | } 87 | 88 | const factoryAddress = config.walletDeployer 89 | 90 | const entryPoint = EntryPoint__factory.connect(providerConfig.entryPointAddress, originalProvider) 91 | 92 | // Initial SimpleWallet instance is not deployed and exists just for the interface 93 | 94 | const factory = SimpleWalletDeployer__factory.connect(factoryAddress, originalProvider) 95 | 96 | const ownerAddress = await owner.getAddress() 97 | 98 | const addr = await factory.getDeploymentAddress(entryPointAddress, ownerAddress, config.scwIndex) 99 | 100 | const walletAddress = addr 101 | 102 | const smartWalletAPI = new SimpleWalletAPI( 103 | originalProvider, 104 | entryPoint.address, 105 | walletAddress, 106 | owner, 107 | factoryAddress, 108 | config.scwIndex 109 | ) 110 | 111 | const httpRpcClient = new HttpRpcClient( 112 | providerConfig.bundlerUrl, 113 | providerConfig.entryPointAddress, 114 | network.chainId 115 | ) 116 | 117 | return await new SCWProvider( 118 | providerConfig, 119 | owner, 120 | originalProvider, 121 | httpRpcClient, 122 | entryPoint, 123 | smartWalletAPI 124 | ).init() 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /packages/scw/src/SCWSigner.ts: -------------------------------------------------------------------------------- 1 | import { ClientConfig, ERC4337EthersProvider, ERC4337EthersSigner } from '@cupcakes-sdk/sdk' 2 | import { BaseWalletAPI } from '@cupcakes-sdk/sdk/dist/src/BaseWalletAPI' 3 | import { HttpRpcClient } from '@cupcakes-sdk/sdk/dist/src/HttpRpcClient' 4 | import { Signer } from 'ethers' 5 | 6 | export class SCWSigner extends ERC4337EthersSigner { 7 | constructor( 8 | readonly config: ClientConfig, 9 | readonly originalSigner: Signer, 10 | readonly erc4337provider: ERC4337EthersProvider, 11 | readonly httpRpcClient: HttpRpcClient, 12 | readonly smartWalletAPI: BaseWalletAPI 13 | ) { 14 | super(config, originalSigner, erc4337provider, httpRpcClient, smartWalletAPI) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/scw/src/index.ts: -------------------------------------------------------------------------------- 1 | export { SCWProvider } from './SCWProvider' 2 | export { PaymasterAPI } from './PaymasterAPI' 3 | -------------------------------------------------------------------------------- /packages/scw/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "composite": true, 9 | "allowJs": true, 10 | "resolveJsonModule": true, 11 | "moduleResolution": "node", 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "declaration": true, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true, 17 | "sourceMap": true, 18 | "outDir": "dist", 19 | "skipLibCheck": true 20 | }, 21 | "include": ["./**/*.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/sdk/hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import '@nomiclabs/hardhat-ethers' 2 | import '@nomicfoundation/hardhat-toolbox' 3 | 4 | import { HardhatUserConfig } from 'hardhat/config' 5 | 6 | const config: HardhatUserConfig = { 7 | solidity: { 8 | version: '0.8.15', 9 | settings: { 10 | optimizer: { enabled: true } 11 | } 12 | } 13 | } 14 | 15 | export default config 16 | -------------------------------------------------------------------------------- /packages/sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cupcakes-sdk/sdk", 3 | "version": "0.2.1", 4 | "main": "./dist/src/index.js", 5 | "license": "MIT", 6 | "files": [ 7 | "src/*", 8 | "dist/*", 9 | "README.md" 10 | ], 11 | "scripts": { 12 | "clear": "rm -rf dist artifacts cache", 13 | "lint": "eslint -f unix .", 14 | "lint-fix": "eslint -f unix . --fix", 15 | "test": "hardhat test", 16 | "hardhat-test": "hardhat test", 17 | "tsc": "tsc", 18 | "watch-tsc": "tsc -w --preserveWatchOutput" 19 | }, 20 | "dependencies": { 21 | "@account-abstraction/contracts": "cupcakes-3/aa-contracts", 22 | "@cupcakes-sdk/common": "0.2.1", 23 | "@ethersproject/abstract-provider": "^5.7.0", 24 | "@ethersproject/abstract-signer": "^5.7.0", 25 | "@ethersproject/networks": "^5.7.0", 26 | "@ethersproject/properties": "^5.7.0", 27 | "@ethersproject/providers": "^5.7.0", 28 | "ethers": "^5.7.0" 29 | }, 30 | "devDependencies": { 31 | "@nomicfoundation/hardhat-chai-matchers": "^1.0.3", 32 | "@nomicfoundation/hardhat-toolbox": "^1.0.2", 33 | "@nomiclabs/hardhat-ethers": "^2.0.0", 34 | "chai": "^4.3.6", 35 | "hardhat": "^2.11.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/sdk/src/BaseWalletAPI.ts: -------------------------------------------------------------------------------- 1 | import { ethers, BigNumber, BigNumberish } from 'ethers' 2 | import { Provider } from '@ethersproject/providers' 3 | import { EntryPoint, EntryPoint__factory, UserOperationStruct } from '@account-abstraction/contracts' 4 | 5 | import { TransactionDetailsForUserOp } from './TransactionDetailsForUserOp' 6 | import { resolveProperties } from 'ethers/lib/utils' 7 | import { PaymasterAPI } from './PaymasterAPI' 8 | import { getRequestId } from '@cupcakes-sdk/common' 9 | 10 | /** 11 | * Base class for all Smart Wallet ERC-4337 Clients to implement. 12 | * Subclass should inherit 5 methods to support a specific wallet contract: 13 | * 14 | * - getWalletInitCode - return the value to put into the "initCode" field, if the wallet is not yet deployed. should create the wallet instance using a factory contract. 15 | * - getNonce - return current wallet's nonce value 16 | * - encodeExecute - encode the call from entryPoint through our wallet to the target contract. 17 | * - signRequestId - sign the requestId of a UserOp. 18 | * 19 | * The user can use the following APIs: 20 | * - createUnsignedUserOp - given "target" and "calldata", fill userOp to perform that operation from the wallet. 21 | * - createSignedUserOp - helper to call the above createUnsignedUserOp, and then extract the requestId and sign it 22 | */ 23 | export abstract class BaseWalletAPI { 24 | private senderAddress!: string 25 | private isPhantom = true 26 | // entryPoint connected to "zero" address. allowed to make static calls (e.g. to getSenderAddress) 27 | private readonly entryPointView: EntryPoint 28 | 29 | /** 30 | * subclass MAY initialize to support custom paymaster 31 | */ 32 | paymasterAPI?: PaymasterAPI 33 | 34 | /** 35 | * base constructor. 36 | * subclass SHOULD add parameters that define the owner (signer) of this wallet 37 | * @param provider - read-only provider for view calls 38 | * @param entryPointAddress - the entryPoint to send requests through (used to calculate the request-id, and for gas estimations) 39 | * @param walletAddress. may be empty for new wallet (using factory to determine address) 40 | */ 41 | protected constructor( 42 | readonly provider: Provider, 43 | readonly entryPointAddress: string, 44 | readonly walletAddress?: string 45 | ) { 46 | // factory "connect" define the contract address. the contract "connect" defines the "from" address. 47 | this.entryPointView = EntryPoint__factory.connect(entryPointAddress, provider).connect(ethers.constants.AddressZero) 48 | } 49 | 50 | async init(): Promise { 51 | await this.getWalletAddress() 52 | return this 53 | } 54 | 55 | /** 56 | * return the value to put into the "initCode" field, if the wallet is not yet deployed. 57 | * this value holds the "factory" address, followed by this wallet's information 58 | */ 59 | abstract getWalletInitCode(): Promise 60 | 61 | /** 62 | * return current wallet's nonce. 63 | */ 64 | abstract getNonce(): Promise 65 | 66 | /** 67 | * encode the call from entryPoint through our wallet to the target contract. 68 | * @param target 69 | * @param value 70 | * @param data 71 | */ 72 | abstract encodeExecute(target: string, value: BigNumberish, data: string): Promise 73 | 74 | /** 75 | * sign a userOp's hash (requestId). 76 | * @param requestId 77 | */ 78 | abstract signRequestId(requestId: string): Promise 79 | 80 | /** 81 | * check if the wallet is already deployed. 82 | */ 83 | async checkWalletPhantom(): Promise { 84 | if (!this.isPhantom) { 85 | // already deployed. no need to check anymore. 86 | return this.isPhantom 87 | } 88 | const senderAddressCode = await this.provider.getCode(this.getWalletAddress()) 89 | if (senderAddressCode.length > 2) { 90 | console.log(`SimpleWallet Contract already deployed at ${this.senderAddress}`) 91 | this.isPhantom = false 92 | } else { 93 | // console.log(`SimpleWallet Contract is NOT YET deployed at ${this.senderAddress} - working in "phantom wallet" mode.`) 94 | } 95 | return this.isPhantom 96 | } 97 | 98 | /** 99 | * calculate the wallet address even before it is deployed 100 | */ 101 | async getCounterFactualAddress(): Promise { 102 | const initCode = await this.getWalletInitCode() 103 | // use entryPoint to query wallet address (factory can provide a helper method to do the same, but 104 | // this method attempts to be generic 105 | return await this.entryPointView.callStatic.getSenderAddress(initCode) 106 | } 107 | 108 | /** 109 | * return initCode value to into the UserOp. 110 | * (either deployment code, or empty hex if contract already deployed) 111 | */ 112 | async getInitCode(): Promise { 113 | if (await this.checkWalletPhantom()) { 114 | return await this.getWalletInitCode() 115 | } 116 | return '0x' 117 | } 118 | 119 | /** 120 | * return maximum gas used for verification. 121 | * NOTE: createUnsignedUserOp will add to this value the cost of creation, if the wallet is not yet created. 122 | */ 123 | async getVerificationGasLimit(): Promise { 124 | return 100000 125 | } 126 | 127 | /** 128 | * should cover cost of putting calldata on-chain, and some overhead. 129 | * actual overhead depends on the expected bundle size 130 | */ 131 | async getPreVerificationGas(userOp: Partial): Promise { 132 | const bundleSize = 1 133 | const cost = 21000 134 | // TODO: calculate calldata cost 135 | return Math.floor(cost / bundleSize) 136 | } 137 | 138 | async encodeUserOpCallDataAndGasLimit( 139 | detailsForUserOp: TransactionDetailsForUserOp 140 | ): Promise<{ callData: string; callGasLimit: BigNumber }> { 141 | function parseNumber(a: any): BigNumber | null { 142 | if (a == null || a === '') return null 143 | return BigNumber.from(a.toString()) 144 | } 145 | 146 | const value = parseNumber(detailsForUserOp.value) ?? BigNumber.from(0) 147 | const callData = await this.encodeExecute(detailsForUserOp.target, value, detailsForUserOp.data) 148 | 149 | const callGasLimit = 150 | parseNumber(detailsForUserOp.gasLimit) ?? 151 | (await this.provider.estimateGas({ 152 | from: this.entryPointAddress, 153 | to: this.getWalletAddress(), 154 | data: callData, 155 | })) 156 | 157 | return { 158 | callData, 159 | callGasLimit, 160 | } 161 | } 162 | 163 | /** 164 | * return requestId for signing. 165 | * This value matches entryPoint.getRequestId (calculated off-chain, to avoid a view call) 166 | * @param userOp userOperation, (signature field ignored) 167 | */ 168 | async getRequestId(userOp: UserOperationStruct): Promise { 169 | const op = await resolveProperties(userOp) 170 | const chainId = await this.provider.getNetwork().then((net) => net.chainId) 171 | return getRequestId(op, this.entryPointAddress, chainId) 172 | } 173 | 174 | /** 175 | * return the wallet's address. 176 | * this value is valid even before deploying the wallet. 177 | */ 178 | async getWalletAddress(): Promise { 179 | if (this.senderAddress == null) { 180 | if (this.walletAddress != null) { 181 | this.senderAddress = this.walletAddress 182 | } else { 183 | this.senderAddress = await this.getCounterFactualAddress() 184 | } 185 | } 186 | return this.senderAddress 187 | } 188 | 189 | /** 190 | * create a UserOperation, filling all details (except signature) 191 | * - if wallet is not yet created, add initCode to deploy it. 192 | * - if gas or nonce are missing, read them from the chain (note that we can't fill gaslimit before the wallet is created) 193 | * @param info 194 | */ 195 | async createUnsignedUserOp(info: TransactionDetailsForUserOp): Promise { 196 | const { callData, callGasLimit } = await this.encodeUserOpCallDataAndGasLimit(info) 197 | const initCode = await this.getInitCode() 198 | 199 | let verificationGasLimit = BigNumber.from(await this.getVerificationGasLimit()) 200 | if (initCode.length > 2) { 201 | // add creation to required verification gas 202 | const initGas = await this.entryPointView.estimateGas.getSenderAddress(initCode) 203 | verificationGasLimit = verificationGasLimit.add(initGas) 204 | } 205 | 206 | let { maxFeePerGas, maxPriorityFeePerGas } = info 207 | if (maxFeePerGas == null || maxPriorityFeePerGas == null) { 208 | const feeData = await this.provider.getFeeData() 209 | if (maxFeePerGas == null) { 210 | maxFeePerGas = feeData.maxFeePerGas ?? undefined 211 | } 212 | if (maxPriorityFeePerGas == null) { 213 | maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? undefined 214 | } 215 | } 216 | 217 | const partialUserOp: any = { 218 | sender: this.getWalletAddress(), 219 | nonce: this.getNonce(), 220 | initCode, 221 | callData, 222 | callGasLimit, 223 | verificationGasLimit, 224 | maxFeePerGas, 225 | maxPriorityFeePerGas, 226 | } 227 | 228 | partialUserOp.preVerificationGas = this.getPreVerificationGas(partialUserOp) 229 | 230 | partialUserOp.paymasterAndData = 231 | this.paymasterAPI == null ? '0x' : await this.paymasterAPI.getPaymasterAndData(partialUserOp) 232 | return { 233 | ...partialUserOp, 234 | signature: '', 235 | } 236 | } 237 | 238 | /** 239 | * Sign the filled userOp. 240 | * @param userOp the UserOperation to sign (with signature field ignored) 241 | */ 242 | async signUserOp(userOp: UserOperationStruct): Promise { 243 | const requestId = await this.getRequestId(userOp) 244 | const signature = this.signRequestId(requestId) 245 | return { 246 | ...userOp, 247 | signature, 248 | } 249 | } 250 | 251 | /** 252 | * helper method: create and sign a user operation. 253 | * @param info transaction details for the userOp 254 | */ 255 | async createSignedUserOp(info: TransactionDetailsForUserOp): Promise { 256 | return await this.signUserOp(await this.createUnsignedUserOp(info)) 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /packages/sdk/src/ClientConfig.ts: -------------------------------------------------------------------------------- 1 | export interface ClientConfig { 2 | paymasterAddress?: string 3 | entryPointAddress: string 4 | bundlerUrl: string 5 | chainId: number 6 | } 7 | -------------------------------------------------------------------------------- /packages/sdk/src/DeterministicDeployer.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'hardhat' 2 | import { BigNumber, BigNumberish } from 'ethers' 3 | import { hexConcat, hexlify, hexZeroPad, keccak256 } from 'ethers/lib/utils' 4 | import { TransactionRequest } from '@ethersproject/abstract-provider' 5 | 6 | /** 7 | * wrapper class for Arachnid's deterministic deployer 8 | * (deterministic deployer used by 'hardhat-deployer'. generates the same addresses as "hardhat-deploy") 9 | */ 10 | export class DeterministicDeployer { 11 | /** 12 | * return the address this code will get deployed to. 13 | * @param ctrCode constructor code to pass to CREATE2 14 | * @param salt optional salt. defaults to zero 15 | */ 16 | static async getAddress (ctrCode: string, salt: BigNumberish = 0): Promise { 17 | return await DeterministicDeployer.instance.getDeterministicDeployAddress(ctrCode, salt) 18 | } 19 | 20 | /** 21 | * deploy the contract, unless already deployed 22 | * @param ctrCode constructor code to pass to CREATE2 23 | * @param salt optional salt. defaults to zero 24 | * @return the deployed address 25 | */ 26 | static async deploy (ctrCode: string, salt: BigNumberish = 0): Promise { 27 | return await DeterministicDeployer.instance.deterministicDeploy(ctrCode, salt) 28 | } 29 | 30 | // from: https://github.com/Arachnid/deterministic-deployment-proxy 31 | proxyAddress = '0x4e59b44847b379578588920ca78fbf26c0b4956c' 32 | deploymentTransaction = '0xf8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222' 33 | deploymentSignerAddress = '0x3fab184622dc19b6109349b94811493bf2a45362' 34 | deploymentGasPrice = 100e9 35 | deploymentGasLimit = 100000 36 | 37 | constructor (readonly provider = ethers.provider) { 38 | } 39 | 40 | async isContractDeployed (address: string): Promise { 41 | return await this.provider.getCode(address).then(code => code.length > 2) 42 | } 43 | 44 | async isDeployerDeployed (): Promise { 45 | return await this.isContractDeployed(this.proxyAddress) 46 | } 47 | 48 | async deployDeployer (): Promise { 49 | if (await this.isContractDeployed(this.proxyAddress)) { 50 | return 51 | } 52 | const bal = await this.provider.getBalance(this.deploymentSignerAddress) 53 | const neededBalance = BigNumber.from(this.deploymentGasLimit).mul(this.deploymentGasPrice) 54 | const signer = this.provider.getSigner() 55 | if (bal.lt(neededBalance)) { 56 | await signer.sendTransaction({ 57 | to: this.deploymentSignerAddress, 58 | value: neededBalance, 59 | gasLimit: this.deploymentGasLimit 60 | }) 61 | } 62 | await this.provider.send('eth_sendRawTransaction', [this.deploymentTransaction]) 63 | if (!await this.isContractDeployed(this.proxyAddress)) { 64 | throw new Error('raw TX didn\'t deploy deployer!') 65 | } 66 | } 67 | 68 | async getDeployTransaction (ctrCode: string, salt: BigNumberish = 0): Promise { 69 | await this.deployDeployer() 70 | const saltEncoded = hexZeroPad(hexlify(salt), 32) 71 | return { 72 | to: this.proxyAddress, 73 | data: hexConcat([ 74 | saltEncoded, 75 | ctrCode]) 76 | } 77 | } 78 | 79 | async getDeterministicDeployAddress (ctrCode: string, salt: BigNumberish = 0): Promise { 80 | // this method works only before the contract is already deployed: 81 | // return await this.provider.call(await this.getDeployTransaction(ctrCode, salt)) 82 | const saltEncoded = hexZeroPad(hexlify(salt), 32) 83 | 84 | return '0x' + keccak256(hexConcat([ 85 | '0xff', 86 | this.proxyAddress, 87 | saltEncoded, 88 | keccak256(ctrCode) 89 | ])).slice(-40) 90 | } 91 | 92 | async deterministicDeploy (ctrCode: string, salt: BigNumberish = 0): Promise { 93 | const addr = await this.getDeterministicDeployAddress(ctrCode, salt) 94 | if (!await this.isContractDeployed(addr)) { 95 | await this.provider.getSigner().sendTransaction( 96 | await this.getDeployTransaction(ctrCode, salt)) 97 | } 98 | return addr 99 | } 100 | 101 | static instance = new DeterministicDeployer() 102 | } 103 | -------------------------------------------------------------------------------- /packages/sdk/src/ERC4337EthersProvider.ts: -------------------------------------------------------------------------------- 1 | import { BaseProvider, TransactionReceipt, TransactionRequest, TransactionResponse } from '@ethersproject/providers' 2 | import { BigNumber, Signer } from 'ethers' 3 | import { Network } from '@ethersproject/networks' 4 | import { Deferrable, hexValue, resolveProperties } from 'ethers/lib/utils' 5 | 6 | import { ClientConfig } from './ClientConfig' 7 | import { ERC4337EthersSigner } from './ERC4337EthersSigner' 8 | import { UserOperationEventListener } from './UserOperationEventListener' 9 | import { HttpRpcClient } from './HttpRpcClient' 10 | import { EntryPoint, UserOperationStruct } from '@account-abstraction/contracts' 11 | import { getRequestId } from '@cupcakes-sdk/common' 12 | import { BaseWalletAPI } from './BaseWalletAPI' 13 | 14 | export class ERC4337EthersProvider extends BaseProvider { 15 | initializedBlockNumber!: number 16 | 17 | readonly signer: ERC4337EthersSigner 18 | 19 | constructor( 20 | readonly config: ClientConfig, 21 | readonly originalSigner: Signer, 22 | readonly originalProvider: BaseProvider, 23 | readonly httpRpcClient: HttpRpcClient, 24 | readonly entryPoint: EntryPoint, 25 | readonly smartWalletAPI: BaseWalletAPI 26 | ) { 27 | super({ 28 | name: 'ERC-4337 Custom Network', 29 | chainId: config.chainId, 30 | }) 31 | this.signer = new ERC4337EthersSigner(config, originalSigner, this, httpRpcClient, smartWalletAPI) 32 | } 33 | 34 | async init(): Promise { 35 | this.initializedBlockNumber = await this.originalProvider.getBlockNumber() 36 | await this.smartWalletAPI.init() 37 | // await this.signer.init() 38 | return this 39 | } 40 | 41 | getSigner(): ERC4337EthersSigner { 42 | return this.signer 43 | } 44 | 45 | async estimateGas(transaction: Deferrable): Promise { 46 | const resolvedTransaction = await this._getTransactionRequest(transaction) 47 | const userOp = await resolveProperties( 48 | await this.smartWalletAPI.createUnsignedUserOp({ 49 | target: resolvedTransaction.to ?? '', 50 | data: resolvedTransaction.data?.toString() ?? '', 51 | value: resolvedTransaction.value, 52 | gasLimit: resolvedTransaction.gasLimit, 53 | maxFeePerGas: resolvedTransaction.maxFeePerGas, 54 | maxPriorityFeePerGas: resolvedTransaction.maxPriorityFeePerGas, 55 | }) 56 | ) 57 | 58 | return BigNumber.from(userOp.callGasLimit) 59 | .add(BigNumber.from(userOp.verificationGasLimit)) 60 | .add(BigNumber.from(userOp.preVerificationGas)) 61 | } 62 | 63 | async perform(method: string, params: any): Promise { 64 | if (method === 'sendTransaction' || method === 'getTransactionReceipt') { 65 | // TODO: do we need 'perform' method to be available at all? 66 | // there is nobody out there to use it for ERC-4337 methods yet, we have nothing to override in fact. 67 | throw new Error('Should not get here. Investigate.') 68 | } 69 | return await this.originalProvider.perform(method, params) 70 | } 71 | 72 | async getTransaction(transactionHash: string | Promise): Promise { 73 | // TODO 74 | return await super.getTransaction(transactionHash) 75 | } 76 | 77 | async getTransactionReceipt(transactionHash: string | Promise): Promise { 78 | const requestId = await transactionHash 79 | const sender = await this.getSenderWalletAddress() 80 | return await new Promise((resolve, reject) => { 81 | new UserOperationEventListener(resolve, reject, this.entryPoint, sender, requestId).start() 82 | }) 83 | } 84 | 85 | async getSenderWalletAddress(): Promise { 86 | return await this.smartWalletAPI.getWalletAddress() 87 | } 88 | 89 | async waitForTransaction( 90 | transactionHash: string, 91 | confirmations?: number, 92 | timeout?: number 93 | ): Promise { 94 | const sender = await this.getSenderWalletAddress() 95 | 96 | return await new Promise((resolve, reject) => { 97 | const listener = new UserOperationEventListener( 98 | resolve, 99 | reject, 100 | this.entryPoint, 101 | sender, 102 | transactionHash, 103 | undefined, 104 | timeout 105 | ) 106 | listener.start() 107 | }) 108 | } 109 | 110 | // fabricate a response in a format usable by ethers users... 111 | async constructUserOpTransactionResponse(userOp1: UserOperationStruct): Promise { 112 | const userOp = await resolveProperties(userOp1) 113 | const requestId = getRequestId(userOp, this.config.entryPointAddress, this.config.chainId) 114 | const waitPromise = new Promise((resolve, reject) => { 115 | new UserOperationEventListener(resolve, reject, this.entryPoint, userOp.sender, requestId, userOp.nonce).start() 116 | }) 117 | return { 118 | hash: requestId, 119 | confirmations: 0, 120 | from: userOp.sender, 121 | nonce: BigNumber.from(userOp.nonce).toNumber(), 122 | gasLimit: BigNumber.from(userOp.callGasLimit), // ?? 123 | value: BigNumber.from(0), 124 | data: hexValue(userOp.callData), // should extract the actual called method from this "execFromEntryPoint()" call 125 | chainId: this.config.chainId, 126 | wait: async (confirmations?: number): Promise => { 127 | const transactionReceipt = await waitPromise 128 | if (userOp.initCode.length !== 0) { 129 | // checking if the wallet has been deployed by the transaction; it must be if we are here 130 | await this.smartWalletAPI.checkWalletPhantom() 131 | } 132 | return transactionReceipt 133 | }, 134 | } 135 | } 136 | 137 | async detectNetwork(): Promise { 138 | return (this.originalProvider as any).detectNetwork() 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /packages/sdk/src/ERC4337EthersSigner.ts: -------------------------------------------------------------------------------- 1 | import { Deferrable, defineReadOnly } from '@ethersproject/properties' 2 | import { Provider, TransactionRequest, TransactionResponse } from '@ethersproject/providers' 3 | import { Signer } from '@ethersproject/abstract-signer' 4 | 5 | import { Bytes } from 'ethers' 6 | import { ERC4337EthersProvider } from './ERC4337EthersProvider' 7 | import { ClientConfig } from './ClientConfig' 8 | import { HttpRpcClient } from './HttpRpcClient' 9 | import { UserOperationStruct } from '@account-abstraction/contracts' 10 | import { BaseWalletAPI } from './BaseWalletAPI' 11 | 12 | export class ERC4337EthersSigner extends Signer { 13 | // TODO: we have 'erc4337provider', remove shared dependencies or avoid two-way reference 14 | constructor( 15 | readonly config: ClientConfig, 16 | readonly originalSigner: Signer, 17 | readonly erc4337provider: ERC4337EthersProvider, 18 | readonly httpRpcClient: HttpRpcClient, 19 | readonly smartWalletAPI: BaseWalletAPI 20 | ) { 21 | super() 22 | defineReadOnly(this, 'provider', erc4337provider) 23 | } 24 | 25 | // This one is called by Contract. It signs the request and passes in to Provider to be sent. 26 | async sendTransaction(transaction: Deferrable): Promise { 27 | const tx: TransactionRequest = await this.populateTransaction(transaction) 28 | await this.verifyAllNecessaryFields(tx) 29 | const userOperation = await this.smartWalletAPI.createSignedUserOp({ 30 | target: tx.to ?? '', 31 | data: tx.data?.toString() ?? '', 32 | value: tx.value, 33 | gasLimit: tx.gasLimit, 34 | }) 35 | const transactionResponse = await this.erc4337provider.constructUserOpTransactionResponse(userOperation) 36 | try { 37 | await this.httpRpcClient.sendUserOpToBundler(userOperation) 38 | } catch (error: any) { 39 | // console.error('sendUserOpToBundler failed', error) 40 | throw this.unwrapError(error) 41 | } 42 | // TODO: handle errors - transaction that is "rejected" by bundler is _not likely_ to ever resolve its "wait()" 43 | return transactionResponse 44 | } 45 | 46 | unwrapError(errorIn: any): Error { 47 | if (errorIn.body != null) { 48 | const errorBody = JSON.parse(errorIn.body) 49 | let paymasterInfo: string = '' 50 | let failedOpMessage: string | undefined = errorBody?.error?.message 51 | if (failedOpMessage?.includes('FailedOp') === true) { 52 | // TODO: better error extraction methods will be needed 53 | const matched = failedOpMessage.match(/FailedOp\((.*)\)/) 54 | if (matched != null) { 55 | const split = matched[1].split(',') 56 | paymasterInfo = `(paymaster address: ${split[1]})` 57 | failedOpMessage = split[2] 58 | } 59 | } 60 | const error = new Error( 61 | `The bundler has failed to include UserOperation in a batch: ${failedOpMessage} ${paymasterInfo})` 62 | ) 63 | error.stack = errorIn.stack 64 | return error 65 | } 66 | return errorIn 67 | } 68 | 69 | async verifyAllNecessaryFields(transactionRequest: TransactionRequest): Promise { 70 | if (transactionRequest.to == null) { 71 | throw new Error('Missing call target') 72 | } 73 | if (transactionRequest.data == null && transactionRequest.value == null) { 74 | // TBD: banning no-op UserOps seems to make sense on provider level 75 | throw new Error('Missing call data or value') 76 | } 77 | } 78 | 79 | connect(provider: Provider): Signer { 80 | throw new Error('changing providers is not supported') 81 | } 82 | 83 | async getAddress(): Promise { 84 | return await this.erc4337provider.getSenderWalletAddress() 85 | } 86 | 87 | async signMessage(message: Bytes | string): Promise { 88 | return await this.originalSigner.signMessage(message) 89 | } 90 | 91 | async signTransaction(transaction: Deferrable): Promise { 92 | throw new Error('not implemented') 93 | } 94 | 95 | async signUserOperation(userOperation: UserOperationStruct): Promise { 96 | const message = await this.smartWalletAPI.getRequestId(userOperation) 97 | return await this.originalSigner.signMessage(message) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /packages/sdk/src/HttpRpcClient.ts: -------------------------------------------------------------------------------- 1 | import { JsonRpcProvider } from '@ethersproject/providers' 2 | import { ethers } from 'ethers' 3 | import { hexValue, resolveProperties } from 'ethers/lib/utils' 4 | 5 | import { UserOperationStruct } from '@account-abstraction/contracts' 6 | 7 | export class HttpRpcClient { 8 | private readonly userOpJsonRpcProvider: JsonRpcProvider 9 | 10 | constructor ( 11 | readonly bundlerUrl: string, 12 | readonly entryPointAddress: string, 13 | readonly chainId: number 14 | ) { 15 | this.userOpJsonRpcProvider = new ethers.providers.JsonRpcProvider(this.bundlerUrl, { 16 | name: 'Not actually connected to network, only talking to the Bundler!', 17 | chainId 18 | }) 19 | } 20 | 21 | async sendUserOpToBundler (userOp1: UserOperationStruct): Promise { 22 | const userOp = await resolveProperties(userOp1) 23 | const hexifiedUserOp: any = 24 | Object.keys(userOp) 25 | .map(key => { 26 | let val = (userOp as any)[key] 27 | if (typeof val !== 'string' || !val.startsWith('0x')) { 28 | val = hexValue(val) 29 | } 30 | return [key, val] 31 | }) 32 | .reduce((set, [k, v]) => ({ ...set, [k]: v }), {}) 33 | 34 | const jsonRequestData: [UserOperationStruct, string] = [hexifiedUserOp, this.entryPointAddress] 35 | await this.printUserOperation(jsonRequestData) 36 | return await this.userOpJsonRpcProvider 37 | .send('eth_sendUserOperation', [hexifiedUserOp, this.entryPointAddress]) 38 | } 39 | 40 | private async printUserOperation ([userOp1, entryPointAddress]: [UserOperationStruct, string]): Promise { 41 | const userOp = await resolveProperties(userOp1) 42 | console.log('sending eth_sendUserOperation', { 43 | ...userOp, 44 | initCode: (userOp.initCode ?? '').length, 45 | callData: (userOp.callData ?? '').length 46 | }, entryPointAddress) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/sdk/src/PaymasterAPI.ts: -------------------------------------------------------------------------------- 1 | import { UserOperationStruct } from '@account-abstraction/contracts' 2 | 3 | export class PaymasterAPI { 4 | async getPaymasterAndData (userOp: Partial): Promise { 5 | return '0x' 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/sdk/src/Provider.ts: -------------------------------------------------------------------------------- 1 | import { JsonRpcProvider } from '@ethersproject/providers' 2 | 3 | import { EntryPoint__factory, SimpleWalletDeployer__factory } from '@account-abstraction/contracts' 4 | 5 | import { ClientConfig } from './ClientConfig' 6 | import { SimpleWalletAPI } from './SimpleWalletAPI' 7 | import { ERC4337EthersProvider } from './ERC4337EthersProvider' 8 | import { HttpRpcClient } from './HttpRpcClient' 9 | // import { DeterministicDeployer } from './DeterministicDeployer' 10 | import { Signer } from '@ethersproject/abstract-signer' 11 | 12 | export async function newProvider( 13 | originalProvider: JsonRpcProvider, 14 | config: ClientConfig, 15 | originalSigner: Signer = originalProvider.getSigner(), 16 | factoryAddress?: string 17 | ): Promise { 18 | const entryPoint = new EntryPoint__factory().attach(config.entryPointAddress).connect(originalProvider) 19 | // Initial SimpleWallet instance is not deployed and exists just for the interface 20 | // const simpleWalletDeployer = await DeterministicDeployer.deploy(SimpleWalletDeployer__factory.bytecode) 21 | const smartWalletAPI = new SimpleWalletAPI( 22 | originalProvider, 23 | entryPoint.address, 24 | undefined, 25 | originalSigner, 26 | factoryAddress, 27 | 0 28 | ) 29 | const httpRpcClient = new HttpRpcClient(config.bundlerUrl, config.entryPointAddress, 31337) 30 | return await new ERC4337EthersProvider( 31 | config, 32 | originalSigner, 33 | originalProvider, 34 | httpRpcClient, 35 | entryPoint, 36 | smartWalletAPI 37 | ).init() 38 | } 39 | -------------------------------------------------------------------------------- /packages/sdk/src/SimpleWalletAPI.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber, BigNumberish } from 'ethers' 2 | import { 3 | SimpleWallet, 4 | SimpleWallet__factory, 5 | SimpleWalletDeployer, 6 | SimpleWalletDeployer__factory, 7 | } from '@account-abstraction/contracts' 8 | 9 | import { arrayify, BytesLike, hexConcat, parseEther } from 'ethers/lib/utils' 10 | import { Signer } from '@ethersproject/abstract-signer' 11 | import { BaseWalletAPI } from './BaseWalletAPI' 12 | import { Provider, TransactionRequest } from '@ethersproject/providers' 13 | import { PaymasterAPI } from './PaymasterAPI' 14 | 15 | /** 16 | * An implementation of the BaseWalletAPI using the SimpleWallet contract. 17 | * - contract deployer gets "entrypoint", "owner" addresses and "index" nonce 18 | * - owner signs requests using normal "Ethereum Signed Message" (ether's signer.signMessage()) 19 | * - nonce method is "nonce()" 20 | * - execute method is "execFromEntryPoint()" 21 | */ 22 | export class SimpleWalletAPI extends BaseWalletAPI { 23 | /** 24 | * base constructor. 25 | * subclass SHOULD add parameters that define the owner (signer) of this wallet 26 | * @param provider - read-only provider for view calls 27 | * @param entryPointAddress - the entryPoint to send requests through (used to calculate the request-id, and for gas estimations) 28 | * @param walletAddress optional wallet address, if connecting to an existing contract. 29 | * @param owner the signer object for the wallet owner 30 | * @param factoryAddress address of contract "factory" to deploy new contracts 31 | * @param index nonce value used when creating multiple wallets for the same owner 32 | */ 33 | constructor( 34 | provider: Provider, 35 | entryPointAddress: string, 36 | walletAddress: string | undefined, 37 | readonly owner: Signer, 38 | readonly factoryAddress?: string, 39 | // index is "salt" used to distinguish multiple wallets of the same signer. 40 | readonly index = 0 41 | ) { 42 | super(provider, entryPointAddress, walletAddress) 43 | } 44 | 45 | /** 46 | * our wallet contract. 47 | * should support the "execFromSingleton" and "nonce" methods 48 | */ 49 | walletContract?: SimpleWallet 50 | 51 | factory?: SimpleWalletDeployer 52 | 53 | connectPaymaster = (paymasterAPI: PaymasterAPI): void => { 54 | this.paymasterAPI = paymasterAPI 55 | } 56 | 57 | disconnectPaymaster = (): void => { 58 | this.paymasterAPI = undefined 59 | } 60 | 61 | async _getWalletContract(): Promise { 62 | if (this.walletContract == null) { 63 | this.walletContract = SimpleWallet__factory.connect(await this.getWalletAddress(), this.provider) 64 | } 65 | return this.walletContract 66 | } 67 | 68 | async getBatchExecutionTransaction(txs: TransactionRequest[]): Promise { 69 | const walletContract = await this._getWalletContract() 70 | 71 | const destinations: string[] = txs.map((tx) => tx.to ?? '') 72 | const values: BigNumber[] = txs.map((tx) => BigNumber.from(tx.value ?? 0)) 73 | const callDatas: BytesLike[] = txs.map((tx) => tx.data ?? '0x0') 74 | 75 | const finalCallData = walletContract.interface.encodeFunctionData('execBatch', [destinations, values, callDatas]) 76 | const target = await this.getWalletAddress() 77 | 78 | return { 79 | to: target, 80 | data: finalCallData, 81 | from: target, 82 | } 83 | } 84 | 85 | /** 86 | * return the value to put into the "initCode" field, if the wallet is not yet deployed. 87 | * this value holds the "factory" address, followed by this wallet's information 88 | */ 89 | async getWalletInitCode(): Promise { 90 | if (this.factory == null) { 91 | if (this.factoryAddress != null && this.factoryAddress !== '') { 92 | this.factory = SimpleWalletDeployer__factory.connect(this.factoryAddress, this.provider) 93 | } else { 94 | throw new Error('no factory to get initCode') 95 | } 96 | } 97 | return hexConcat([ 98 | this.factory.address, 99 | this.factory.interface.encodeFunctionData('deployWallet', [ 100 | this.entryPointAddress, 101 | await this.owner.getAddress(), 102 | this.index, 103 | ]), 104 | ]) 105 | } 106 | 107 | async getNonce(): Promise { 108 | if (await this.checkWalletPhantom()) { 109 | return BigNumber.from(0) 110 | } 111 | const walletContract = await this._getWalletContract() 112 | return await walletContract.nonce() 113 | } 114 | 115 | /** 116 | * encode a method call from entryPoint to our contract 117 | * @param target 118 | * @param value 119 | * @param data 120 | */ 121 | async encodeExecute(target: string, value: BigNumberish, data: string): Promise { 122 | const walletContract = await this._getWalletContract() 123 | return walletContract.interface.encodeFunctionData('execFromEntryPoint', [target, value, data]) 124 | } 125 | 126 | async signRequestId(requestId: string): Promise { 127 | return await this.owner.signMessage(arrayify(requestId)) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /packages/sdk/src/TransactionDetailsForUserOp.ts: -------------------------------------------------------------------------------- 1 | import { BigNumberish } from 'ethers' 2 | 3 | export interface TransactionDetailsForUserOp { 4 | target: string 5 | data: string 6 | value?: BigNumberish 7 | gasLimit?: BigNumberish 8 | maxFeePerGas?: BigNumberish 9 | maxPriorityFeePerGas?: BigNumberish 10 | } 11 | -------------------------------------------------------------------------------- /packages/sdk/src/UserOperationEventListener.ts: -------------------------------------------------------------------------------- 1 | import { BigNumberish, Event } from 'ethers' 2 | import { TransactionReceipt } from '@ethersproject/providers' 3 | import { EntryPoint } from '@account-abstraction/contracts' 4 | import { defaultAbiCoder } from 'ethers/lib/utils' 5 | 6 | const DEFAULT_TRANSACTION_TIMEOUT: number = 50000 7 | 8 | /** 9 | * This class encapsulates Ethers.js listener function and necessary UserOperation details to 10 | * discover a TransactionReceipt for the operation. 11 | */ 12 | export class UserOperationEventListener { 13 | resolved: boolean = false 14 | boundLisener: (this: any, ...param: any) => void 15 | 16 | constructor( 17 | readonly resolve: (t: TransactionReceipt) => void, 18 | readonly reject: (reason?: any) => void, 19 | readonly entryPoint: EntryPoint, 20 | readonly sender: string, 21 | readonly requestId: string, 22 | readonly nonce?: BigNumberish, 23 | readonly timeout?: number 24 | ) { 25 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 26 | this.boundLisener = this.listenerCallback.bind(this) 27 | setTimeout(() => { 28 | this.stop() 29 | this.reject(new Error('Timed out')) 30 | }, this.timeout ?? DEFAULT_TRANSACTION_TIMEOUT) 31 | } 32 | 33 | start(): void { 34 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 35 | const filter = this.entryPoint.filters.UserOperationEvent(this.requestId) 36 | // listener takes time... first query directly: 37 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 38 | setTimeout(async () => { 39 | const res = await this.entryPoint.queryFilter(filter, 'latest') 40 | if (res.length > 0) { 41 | void this.listenerCallback(res[0]) 42 | } else { 43 | this.entryPoint.once(filter, this.boundLisener) 44 | } 45 | }, 100) 46 | } 47 | 48 | stop(): void { 49 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 50 | this.entryPoint.off('UserOperationEvent', this.boundLisener) 51 | } 52 | 53 | async listenerCallback(this: any, ...param: any): Promise { 54 | const event = arguments[arguments.length - 1] as Event 55 | if (event.args == null) { 56 | console.error('got event without args', event) 57 | return 58 | } 59 | // TODO: can this happen? we register to event by requestId.. 60 | if (event.args.requestId !== this.requestId) { 61 | console.log( 62 | `== event with wrong requestId: sender/nonce: event.${event.args.sender as string}@${ 63 | event.args.nonce.toString() as string 64 | }!= userOp.${this.sender as string}@${parseInt(this.nonce?.toString())}` 65 | ) 66 | return 67 | } 68 | 69 | const transactionReceipt = await event.getTransactionReceipt() 70 | transactionReceipt.transactionHash = this.requestId 71 | console.log('got event with status=', event.args.success, 'gasUsed=', transactionReceipt.gasUsed) 72 | 73 | // before returning the receipt, update the status from the event. 74 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 75 | if (!event.args.success) { 76 | await this.extractFailureReason(transactionReceipt) 77 | } 78 | this.stop() 79 | this.resolve(transactionReceipt) 80 | this.resolved = true 81 | } 82 | 83 | async extractFailureReason(receipt: TransactionReceipt): Promise { 84 | console.log('mark tx as failed') 85 | receipt.status = 0 86 | const revertReasonEvents = await this.entryPoint.queryFilter( 87 | this.entryPoint.filters.UserOperationRevertReason(this.requestId, this.sender), 88 | receipt.blockHash 89 | ) 90 | if (revertReasonEvents[0] != null) { 91 | let message = revertReasonEvents[0].args.revertReason 92 | if (message.startsWith('0x08c379a0')) { 93 | // Error(string) 94 | message = defaultAbiCoder.decode(['string'], '0x' + message.substring(10)).toString() 95 | } 96 | console.log(`rejecting with reason: ${message}`) 97 | this.reject(new Error(`UserOp failed with reason: ${message}`)) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /packages/sdk/src/index.ts: -------------------------------------------------------------------------------- 1 | export { SimpleWalletAPI } from './SimpleWalletAPI' 2 | export { PaymasterAPI } from './PaymasterAPI' 3 | export { newProvider } from './Provider' 4 | export { ERC4337EthersSigner } from './ERC4337EthersSigner' 5 | export { ERC4337EthersProvider } from './ERC4337EthersProvider' 6 | export { ClientConfig } from './ClientConfig' 7 | -------------------------------------------------------------------------------- /packages/sdk/test/0-deterministicDeployer.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { SampleRecipient__factory } from '@cupcakes-sdk/common/dist/src/types' 3 | import { ethers } from 'hardhat' 4 | import { hexValue } from 'ethers/lib/utils' 5 | import { DeterministicDeployer } from '../src/DeterministicDeployer' 6 | 7 | const deployer = DeterministicDeployer.instance 8 | 9 | describe('#deterministicDeployer', () => { 10 | it('deploy deployer', async () => { 11 | expect(await deployer.isDeployerDeployed()).to.equal(false) 12 | await deployer.deployDeployer() 13 | expect(await deployer.isDeployerDeployed()).to.equal(true) 14 | }) 15 | it('should ignore deploy again of deployer', async () => { 16 | await deployer.deployDeployer() 17 | }) 18 | it('should deploy at given address', async () => { 19 | const ctr = hexValue(new SampleRecipient__factory(ethers.provider.getSigner()).getDeployTransaction().data!) 20 | const addr = await DeterministicDeployer.getAddress(ctr) 21 | expect(await deployer.isContractDeployed(addr)).to.equal(false) 22 | await DeterministicDeployer.deploy(ctr) 23 | expect(await deployer.isContractDeployed(addr)).to.equal(true) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /packages/sdk/test/1-SimpleWalletAPI.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntryPoint, 3 | EntryPoint__factory, 4 | SimpleWalletDeployer__factory, 5 | UserOperationStruct, 6 | } from '@account-abstraction/contracts' 7 | import { Wallet } from 'ethers' 8 | import { parseEther } from 'ethers/lib/utils' 9 | import { expect } from 'chai' 10 | import { anyValue } from '@nomicfoundation/hardhat-chai-matchers/withArgs' 11 | import { ethers } from 'hardhat' 12 | import { SimpleWalletAPI } from '../src' 13 | import { SampleRecipient, SampleRecipient__factory } from '@cupcakes-sdk/common/dist/src/types' 14 | import { DeterministicDeployer } from '../src/DeterministicDeployer' 15 | 16 | const provider = ethers.provider 17 | const signer = provider.getSigner() 18 | describe('SimpleWalletAPI', () => { 19 | let owner: Wallet 20 | let api: SimpleWalletAPI 21 | let entryPoint: EntryPoint 22 | let beneficiary: string 23 | let recipient: SampleRecipient 24 | let walletAddress: string 25 | let walletDeployed = false 26 | before('init', async () => { 27 | entryPoint = await new EntryPoint__factory(signer).deploy(1, 1) 28 | beneficiary = await signer.getAddress() 29 | 30 | recipient = await new SampleRecipient__factory(signer).deploy() 31 | owner = Wallet.createRandom() 32 | const factoryAddress = await DeterministicDeployer.deploy(SimpleWalletDeployer__factory.bytecode) 33 | api = new SimpleWalletAPI(provider, entryPoint.address, undefined, owner, factoryAddress) 34 | }) 35 | 36 | it('#getRequestId should match entryPoint.getRequestId', async function () { 37 | const userOp: UserOperationStruct = { 38 | sender: '0x'.padEnd(42, '1'), 39 | nonce: 2, 40 | initCode: '0x3333', 41 | callData: '0x4444', 42 | callGasLimit: 5, 43 | verificationGasLimit: 6, 44 | preVerificationGas: 7, 45 | maxFeePerGas: 8, 46 | maxPriorityFeePerGas: 9, 47 | paymasterAndData: '0xaaaaaa', 48 | signature: '0xbbbb', 49 | } 50 | const hash = await api.getRequestId(userOp) 51 | const epHash = await entryPoint.getRequestId(userOp) 52 | expect(hash).to.equal(epHash) 53 | }) 54 | it('should deploy to counterfactual address', async () => { 55 | walletAddress = await api.getWalletAddress() 56 | expect(await provider.getCode(walletAddress).then((code) => code.length)).to.equal(2) 57 | 58 | await signer.sendTransaction({ 59 | to: walletAddress, 60 | value: parseEther('0.1'), 61 | }) 62 | const op = await api.createSignedUserOp({ 63 | target: recipient.address, 64 | data: recipient.interface.encodeFunctionData('something', ['hello']), 65 | }) 66 | 67 | await expect(entryPoint.handleOps([op], beneficiary)) 68 | .to.emit(recipient, 'Sender') 69 | .withArgs(anyValue, walletAddress, 'hello') 70 | expect(await provider.getCode(walletAddress).then((code) => code.length)).to.greaterThan(1000) 71 | walletDeployed = true 72 | }) 73 | it('should use wallet API after creation without a factory', async function () { 74 | if (!walletDeployed) { 75 | this.skip() 76 | } 77 | const api1 = new SimpleWalletAPI(provider, entryPoint.address, walletAddress, owner) 78 | const op1 = await api1.createSignedUserOp({ 79 | target: recipient.address, 80 | data: recipient.interface.encodeFunctionData('something', ['world']), 81 | }) 82 | await expect(entryPoint.handleOps([op1], beneficiary)) 83 | .to.emit(recipient, 'Sender') 84 | .withArgs(anyValue, walletAddress, 'world') 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /packages/sdk/test/2-ERC4337EthersProvider.test.ts: -------------------------------------------------------------------------------- 1 | // import { expect } from 'chai' 2 | // import hre from 'hardhat' 3 | // import { time } from '@nomicfoundation/hardhat-network-helpers' 4 | // 5 | // describe('Lock', function () { 6 | // it('Should set the right unlockTime', async function () { 7 | // const lockedAmount = 1_000_000_000 8 | // const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60 9 | // const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS 10 | // 11 | // // deploy a lock contract where funds can be withdrawn 12 | // // one year in the future 13 | // const Lock = await hre.ethers.getContractFactory('Lock') 14 | // const lock = await Lock.deploy(unlockTime, { value: lockedAmount }) 15 | // 16 | // // assert that the value is correct 17 | // expect(await lock.unlockTime()).to.equal(unlockTime) 18 | // }) 19 | // }) 20 | // should throw timeout exception if user operation is not mined after x time 21 | -------------------------------------------------------------------------------- /packages/sdk/test/3-ERC4337EthersSigner.test.ts: -------------------------------------------------------------------------------- 1 | import { SampleRecipient, SampleRecipient__factory } from '@cupcakes-sdk/common/dist/src/types' 2 | import { ethers } from 'hardhat' 3 | import { ClientConfig, ERC4337EthersProvider, newProvider } from '../src' 4 | import { EntryPoint, EntryPoint__factory } from '@account-abstraction/contracts' 5 | import { expect } from 'chai' 6 | import { parseEther } from 'ethers/lib/utils' 7 | import { Wallet } from 'ethers' 8 | import { anyValue } from '@nomicfoundation/hardhat-chai-matchers/withArgs' 9 | 10 | const provider = ethers.provider 11 | const signer = provider.getSigner() 12 | 13 | describe('ERC4337EthersSigner, Provider', function () { 14 | let recipient: SampleRecipient 15 | let aaProvider: ERC4337EthersProvider 16 | let entryPoint: EntryPoint 17 | before('init', async () => { 18 | const deployRecipient = await new SampleRecipient__factory(signer).deploy() 19 | entryPoint = await new EntryPoint__factory(signer).deploy(1, 1) 20 | const config: ClientConfig = { 21 | chainId: await provider.getNetwork().then((net) => net.chainId), 22 | entryPointAddress: entryPoint.address, 23 | bundlerUrl: '', 24 | } 25 | const aasigner = Wallet.createRandom() 26 | aaProvider = await newProvider(provider, config, aasigner) 27 | 28 | const beneficiary = provider.getSigner().getAddress() 29 | // for testing: bypass sending through a bundler, and send directly to our entrypoint.. 30 | aaProvider.httpRpcClient.sendUserOpToBundler = async (userOp) => { 31 | try { 32 | await entryPoint.handleOps([userOp], beneficiary) 33 | } catch (e: any) { 34 | // doesn't report error unless called with callStatic 35 | await entryPoint.callStatic.handleOps([userOp], beneficiary).catch((e: any) => { 36 | // eslint-disable-next-line 37 | const message = e.errorArgs != null ? `${e.errorName}(${e.errorArgs.join(',')})` : e.message 38 | throw new Error(message) 39 | }) 40 | } 41 | } 42 | recipient = deployRecipient.connect(aaProvider.getSigner()) 43 | }) 44 | 45 | it('should fail to send before funding', async () => { 46 | try { 47 | await recipient.something('hello', { gasLimit: 1e6 }) 48 | throw new Error('should revert') 49 | } catch (e: any) { 50 | expect(e.message).to.eq("FailedOp(0,0x0000000000000000000000000000000000000000,wallet didn't pay prefund)") 51 | } 52 | }) 53 | 54 | it('should use ERC-4337 Signer and Provider to send the UserOperation to the bundler', async function () { 55 | const walletAddress = await aaProvider.getSigner().getAddress() 56 | await signer.sendTransaction({ 57 | to: walletAddress, 58 | value: parseEther('0.1'), 59 | }) 60 | const ret = await recipient.something('hello') 61 | await expect(ret).to.emit(recipient, 'Sender').withArgs(anyValue, walletAddress, 'hello') 62 | }) 63 | 64 | it('should revert if on-chain userOp execution reverts', async function () { 65 | // specifying gas, so that estimateGas won't revert.. 66 | const ret = await recipient.reverting({ gasLimit: 10000 }) 67 | 68 | try { 69 | await ret.wait() 70 | throw new Error('expected to revert') 71 | } catch (e: any) { 72 | expect(e.message).to.match(/test revert/) 73 | } 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /packages/sdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "composite": true, 9 | "allowJs": true, 10 | "resolveJsonModule": true, 11 | "moduleResolution": "node", 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "declaration": true, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true, 17 | "sourceMap": true, 18 | "outDir": "dist", 19 | "skipLibCheck": true 20 | }, 21 | "include": ["./**/*.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/sdk/tsconfig.packages.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "outDir": "dist", 8 | "strict": true, 9 | "composite": true, 10 | "allowJs": true, 11 | "resolveJsonModule": true, 12 | "moduleResolution": "node", 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "declaration": true, 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true, 18 | "sourceMap": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "target": "es2017", 5 | "module": "commonjs", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "outDir": "dist", 9 | "strict": true, 10 | "composite": true, 11 | "allowJs": true, 12 | "resolveJsonModule": true, 13 | "moduleResolution": "node", 14 | "noImplicitThis": true, 15 | "alwaysStrict": true, 16 | "declaration": true, 17 | "experimentalDecorators": true, 18 | "emitDecoratorMetadata": true, 19 | "sourceMap": true, 20 | "skipLibCheck": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.packages.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | 4 | --------------------------------------------------------------------------------