diff --git a/demos/inpage/.eslintrc.cjs b/demos/inpage/.eslintrc.cjs index 6c263bcf..8adf9685 100644 --- a/demos/inpage/.eslintrc.cjs +++ b/demos/inpage/.eslintrc.cjs @@ -118,5 +118,6 @@ module.exports = { 'no-await-in-loop': 'off', 'no-continue': 'off', 'no-constant-condition': 'off', + 'no-underscore-dangle': 'off', }, }; diff --git a/demos/inpage/README.md b/demos/inpage/README.md index 7a23dfef..9bddbbf7 100644 --- a/demos/inpage/README.md +++ b/demos/inpage/README.md @@ -20,6 +20,44 @@ The dev server will run a hardhat-based testnet for you by default. If you'd like to use an external network, configure your `rpcUrl` in `demo/config.ts` and the dev server will skip running a local node. +### Use the Demo with an External Bundler + +In `demo/config.ts`, set `bundlerRpcUrl` to your bundler's rpc url. + +If you don't have access to an existing external bundler, one option for running +your own is +[`eth-infinitism/bundler`](https://github.com/eth-infinitism/bundler). + +1. Set `bundlerRpcUrl` above to `http://127.0.0.1:3000` +2. Clone [`eth-infinitism/bundler`](https://github.com/eth-infinitism/bundler) +3. Run `yarn && yarn preprocess` +4. Set `packages/bundler/localconfig/mnemonic.txt` to: + +``` +test test test test test test test test test test test absent +``` + +5. Set `beneficiary` in `packages/bundler/localconfig/bundler.config.json` to: + +``` +0xe8250207B79D7396631bb3aE38a7b457261ae0B6 +``` + +Send some ETH to this address. + +6. Get the entry point address using these commands in the dev console of the + demo: + +```ts +let contracts = await waxInPage.getContracts(); +await contracts.entryPoint.getAddress(); +``` + +7. Set `entryPoint` in `packages/bundler/localconfig/bundler.config.json` to + that address. + +8. Run `yarn run bundler --unsafe`. + ## Use the Library (as an Npm Module) ```sh @@ -37,25 +75,29 @@ yet.) Then: ```ts -import WaxInPage from '@getwax/inpage'; +import WaxInPage from "@getwax/inpage"; const wax = new WaxInPage({ - rpcUrl: '', // eg for hardhat use http://127.0.0.1:8545 + rpcUrl: "", // eg for hardhat use http://127.0.0.1:8545 }); -console.log(await wax.ethereum.request({ - method: 'eth_requestAccounts', -})); +console.log( + await wax.ethereum.request({ + method: "eth_requestAccounts", + }), +); // Alternatively... WaxInPage.global({ - rpcUrl: '', // eg for hardhat use http://127.0.0.1:8545 + rpcUrl: "", // eg for hardhat use http://127.0.0.1:8545 }); -console.log(await ethereum.request({ - method: 'eth_requestAccounts', -})); +console.log( + await ethereum.request({ + method: "eth_requestAccounts", + }), +); ``` ## Use the Libary (as a Script) @@ -89,10 +131,10 @@ Now you can use `window.ethereum` and `window.waxInPage`, eg: ```ts (async () => { - - console.log(await ethereum.request({ - method: 'eth_requestAccounts', - })); - + console.log( + await ethereum.request({ + method: "eth_requestAccounts", + }), + ); })(); ``` diff --git a/demos/inpage/demo/ConfigType.ts b/demos/inpage/demo/ConfigType.ts index 6963dda1..61022b69 100644 --- a/demos/inpage/demo/ConfigType.ts +++ b/demos/inpage/demo/ConfigType.ts @@ -1,5 +1,7 @@ type ConfigType = { + logRequests?: boolean; rpcUrl: string; + bundlerRpcUrl?: string; pollingInterval?: number; addFundsEthAmount?: string; }; diff --git a/demos/inpage/demo/config.template.ts b/demos/inpage/demo/config.template.ts index 3b7ef4a0..d4933543 100644 --- a/demos/inpage/demo/config.template.ts +++ b/demos/inpage/demo/config.template.ts @@ -10,7 +10,13 @@ import ConfigType from './ConfigType'; const config: ConfigType = { + logRequests: true, rpcUrl: 'http://127.0.0.1:8545', + + // Uncomment this with the url of a bundler to enable using an external + // bundler (sometimes this is the same as rpcUrl). Otherwise, a bundler will + // be simulated inside the library. + // bundlerRpcUrl: '', }; export default config; diff --git a/demos/inpage/demo/main.tsx b/demos/inpage/demo/main.tsx index 1ca60002..ade69b51 100644 --- a/demos/inpage/demo/main.tsx +++ b/demos/inpage/demo/main.tsx @@ -11,6 +11,7 @@ WaxInPage.addStylesheet(); const waxInPage = new WaxInPage({ rpcUrl: config.rpcUrl, + bundlerRpcUrl: config.bundlerRpcUrl, }); waxInPage.attachGlobals(); @@ -28,6 +29,10 @@ if (config.pollingInterval !== undefined) { }); } +waxInPage.setConfig({ + logRequests: config.logRequests, +}); + const demoContext = new DemoContext(waxInPage); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/demos/inpage/src/EthereumApi.tsx b/demos/inpage/src/EthereumApi.tsx index 8a81be74..77759ccd 100644 --- a/demos/inpage/src/EthereumApi.tsx +++ b/demos/inpage/src/EthereumApi.tsx @@ -2,15 +2,25 @@ import z from 'zod'; import { ethers } from 'ethers'; import JsonRpcError from './JsonRpcError'; -import randomId from './helpers/randomId'; import WaxInPage from '.'; import EthereumRpc from './EthereumRpc'; -import ZodNotUndefined from './helpers/ZodNotUndefined'; import { UserOperationStruct } from '../hardhat/typechain-types/@account-abstraction/contracts/interfaces/IEntryPoint'; import { SimpleAccount__factory } from '../hardhat/typechain-types'; import assert from './helpers/assert'; +import IBundler from './bundlers/IBundler'; +import waxPrivate from './waxPrivate'; +import ethereumRequest from './ethereumRequest'; -const baseVerificationGas = 100_000n; +// We need a UserOperation in order to estimate the gas fields of a +// UserOperation, so we use these values as placeholders. +const temporaryEstimationGas = '0x012345'; +const temporarySignature = [ + '0x', + '123456fe2807660c417ca1a38760342fa70135fcab89a8c7c879a77da8ce7a0b5a3805735e', + '95170906b11c6f30dcc74e463e1e6990c68a3998a7271b728b123456', +].join(''); + +const extraGasForTransferToNewAddress = 20_000n; type StrictUserOperation = { sender: string; @@ -18,8 +28,8 @@ type StrictUserOperation = { initCode: string; callData: string; callGasLimit: bigint; - verificationGasLimit: bigint; - preVerificationGas: bigint; + verificationGasLimit: string; + preVerificationGas: string; maxFeePerGas: bigint; maxPriorityFeePerGas: bigint; paymasterAndData: string; @@ -27,8 +37,9 @@ type StrictUserOperation = { }; export default class EthereumApi { - #waxInPage: WaxInPage; #rpcUrl: string; + #waxInPage: WaxInPage; + #bundler: IBundler; #chainIdPromise: Promise; #userOps = new Map< @@ -45,13 +56,15 @@ export default class EthereumApi { } >(); - constructor(rpcUrl: string, waxInPage: WaxInPage) { + constructor(rpcUrl: string, waxInPage: WaxInPage, bundler: IBundler) { this.#rpcUrl = rpcUrl; this.#waxInPage = waxInPage; + this.#bundler = bundler; - this.#chainIdPromise = this.#networkRequest({ method: 'eth_chainId' }).then( - (res) => z.string().parse(res), - ); + this.#chainIdPromise = ethereumRequest({ + url: this.#rpcUrl, + method: 'eth_chainId', + }).then((res) => z.string().parse(res)); } async request({ @@ -59,12 +72,53 @@ export default class EthereumApi { params, }: { method: M; + } & EthereumRpc.RequestParams): Promise> { + let res; + + try { + res = await this.#requestImpl({ method, params } as { + method: M; + } & EthereumRpc.RequestParams); + } catch (error) { + if (this.#waxInPage.getConfig('logRequests')) { + // eslint-disable-next-line no-console + console.log('ethereum.request', { + method, + params, + error, + }); + } + + throw error; + } + + if (this.#waxInPage.getConfig('logRequests')) { + // eslint-disable-next-line no-console + console.log('ethereum.request', { + method, + params, + response: res, + }); + } + + return res as EthereumRpc.Response; + } + + async #requestImpl({ + method, + params, + }: { + method: M; } & EthereumRpc.RequestParams): Promise> { if (!(method in EthereumRpc.schema)) { - return (await this.#requestImpl({ + return await ethereumRequest({ + url: this.#rpcUrl, method, params, - })) as EthereumRpc.Response; + } as { + url: string; + method: M; + } & EthereumRpc.RequestParams); } const methodSchema = EthereumRpc.schema[method as keyof EthereumRpc.Schema]; @@ -78,33 +132,28 @@ export default class EthereumApi { }); } - const response = await this.#requestImpl({ method, params }); - - const parsedResponse = methodSchema.output.safeParse(response); + let response: EthereumRpc.Response; - if (!parsedResponse.success) { - throw new JsonRpcError({ - code: -32602, - message: parsedResponse.error.toString(), - }); - } - - return parsedResponse.data as EthereumRpc.Response; - } - - async #requestImpl({ - method, - params, - }: { - method: string; - params?: unknown[]; - }): Promise { if (method in this.#customHandlers) { // eslint-disable-next-line - return await (this.#customHandlers as any)[method](...(params ?? [])); + response = await (this.#customHandlers as any)[method](...(params ?? [])); + } else { + response = await ethereumRequest({ + url: this.#rpcUrl, + method, + params, + } as { + url: string; + method: M; + } & EthereumRpc.RequestParams); } - return await this.#networkRequest({ method, params }); + return response; + } + + async #getEntryPointAddress() { + const contracts = await this.#waxInPage.getContracts(); + return await contracts.entryPoint.getAddress(); } #customHandlers: Partial = { @@ -129,7 +178,7 @@ export default class EthereumApi { }); } - const account = await this.#getAccount(); + const account = await this.#waxInPage._getAccount(waxPrivate); connectedAccounts = [account.address]; @@ -142,15 +191,25 @@ export default class EthereumApi { await this.#waxInPage.storage.connectedAccounts.get(), eth_sendTransaction: async (...txs) => { - const sender = txs[0].from; + const account = await this.#waxInPage._getAccount(waxPrivate); + const contracts = await this.#waxInPage.getContracts(); + + const sender = txs[0].from ?? account.address; - if (txs.find((tx) => tx.from !== sender)) { + if (txs.find((tx) => tx.from !== txs[0].from)) { throw new JsonRpcError({ code: -32602, message: 'All txs must have the same sender (aka `.from`)', }); } + if (sender.toLowerCase() !== account.address.toLowerCase()) { + throw new JsonRpcError({ + code: -32000, + message: `unknown account ${sender}`, + }); + } + const question = txs.length === 1 ? 'Send this transaction?' @@ -160,7 +219,19 @@ export default class EthereumApi { const granted = await this.#waxInPage.requestPermission(
-          {question} {JSON.stringify(txData, null, 2)}
+          {question}{' '}
+          {JSON.stringify(
+            txData,
+            (_key, value) => {
+              if (typeof value === 'bigint') {
+                return `0x${value.toString(16)}`;
+              }
+
+              // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+              return value;
+            },
+            2,
+          )}
         
, ); @@ -171,28 +242,68 @@ export default class EthereumApi { }); } - const account = await this.#getAccount(); - const contracts = await this.#waxInPage.getContracts(); - - const actions = txs.map((tx) => { - const parsedTx = z - .object({ - to: z.string(), - gas: z.string(), // TODO: should be optional (calculate gas here) - data: z.optional(z.string()), - value: z.optional(z.string()), - }) - .safeParse(tx); - - if (!parsedTx.success) { - throw new JsonRpcError({ - code: -32602, - message: `Failed to parse tx: ${parsedTx.error.toString()}`, - }); - } - - return parsedTx.data; - }); + const actions = await Promise.all( + txs.map(async (tx) => { + const parsedTx = z + .object({ + // TODO: Shouldn't this be optional? (contract deployment) + to: z.string(), + + gas: z.optional(z.string()), + data: z.optional(z.string()), + value: z.optional(EthereumRpc.BigNumberish), + }) + .safeParse(tx); + + if (!parsedTx.success) { + throw new JsonRpcError({ + code: -32601, + message: `Failed to parse tx: ${parsedTx.error.toString()}`, + }); + } + + let { value } = parsedTx.data; + + if (typeof value === 'number' || typeof value === 'bigint') { + value = `0x${value.toString(16)}`; + } + + // eslint-disable-next-line prefer-destructuring + let gas: string | bigint | undefined = parsedTx.data.gas; + + if (gas === undefined) { + gas = BigInt( + await this.request({ + method: 'eth_estimateGas', + params: [ + { + ...parsedTx.data, + value, + }, + ], + }), + ); + + if (BigInt(value ?? 0) !== 0n) { + const recipientBalance = + await this.#waxInPage.ethersProvider.getBalance( + parsedTx.data.to, + ); + + if (recipientBalance === 0n) { + gas += extraGasForTransferToNewAddress; + } + } + } + + return { + to: parsedTx.data.to, + gas, + data: parsedTx.data.data ?? '0x', + value: parsedTx.data.value ?? 0n, + }; + }), + ); const simpleAccount = SimpleAccount__factory.connect( account.address, @@ -206,7 +317,7 @@ export default class EthereumApi { callData = simpleAccount.interface.encodeFunctionData('execute', [ action.to, - action.value ?? 0n, + action.value, action.data ?? '0x', ]); } else { @@ -237,7 +348,6 @@ export default class EthereumApi { ); let initCode: string; - let verificationGasLimit = baseVerificationGas; if (accountBytecode === '0x') { nonce = 0n; @@ -247,12 +357,6 @@ export default class EthereumApi { initCode += contracts.simpleAccountFactory.interface .encodeFunctionData('createAccount', [account.ownerAddress, 0]) .slice(2); - - verificationGasLimit += - await contracts.simpleAccountFactory.createAccount.estimateGas( - account.ownerAddress, - 0, - ); } else { nonce = await simpleAccount.getNonce(); initCode = '0x'; @@ -270,29 +374,38 @@ export default class EthereumApi { initCode, callData, callGasLimit: actions.map((a) => BigInt(a.gas)).reduce((a, b) => a + b), - verificationGasLimit, - preVerificationGas: 0n, // TODO + verificationGasLimit: temporaryEstimationGas, + preVerificationGas: temporaryEstimationGas, maxFeePerGas: feeData.maxFeePerGas, maxPriorityFeePerGas: feeData.maxPriorityFeePerGas, paymasterAndData: '0x', - signature: '0x', + signature: temporarySignature, } satisfies UserOperationStruct; - const userOpHash = await contracts.entryPoint.getUserOpHash(userOp); + let userOpHash = await contracts.entryPoint.getUserOpHash(userOp); userOp.signature = await ownerWallet.signMessage( ethers.getBytes(userOpHash), ); - const adminAccount = await this.#waxInPage.requestAdminAccount( - 'simulate-bundler', + const { verificationGasLimit, preVerificationGas } = await this.request({ + method: 'eth_estimateUserOperationGas', + params: [userOp, await contracts.entryPoint.getAddress()], + }); + + userOp.verificationGasLimit = verificationGasLimit; + userOp.preVerificationGas = preVerificationGas; + + userOpHash = await contracts.entryPoint.getUserOpHash(userOp); + + userOp.signature = await ownerWallet.signMessage( + ethers.getBytes(userOpHash), ); - // *not* the confirmation, just the response (don't add .wait(), that's - // wrong). - await contracts.entryPoint - .connect(adminAccount) - .handleOps([userOp], adminAccount.getAddress()); + await this.request({ + method: 'eth_sendUserOperation', + params: [userOp, await contracts.entryPoint.getAddress()], + }); this.#userOps.set(userOpHash, { chainId: BigInt(await this.request({ method: 'eth_chainId' })), @@ -312,24 +425,22 @@ export default class EthereumApi { const opInfo = this.#userOps.get(txHash); if (opInfo === undefined) { - return (await this.#networkRequest({ + return await ethereumRequest({ + url: this.#rpcUrl, method: 'eth_getTransactionByHash', params: [txHash], - })) as EthereumRpc.Transaction | null; + }); } - const contracts = await this.#waxInPage.getContracts(); - - const events = await contracts.entryPoint.queryFilter( - contracts.entryPoint.filters.UserOperationEvent(txHash), - ); + const receipt = await this.request({ + method: 'eth_getUserOperationReceipt', + params: [txHash], + }); - if (events.length === 0) { + if (receipt === null) { return null; } - const event = events[0]; - const { userOp } = opInfo; // eth_getTransactionByHash doesn't really have the right shape for batch @@ -339,15 +450,15 @@ export default class EthereumApi { const action = opInfo.actions[0]; return { - blockHash: event.blockHash, - blockNumber: `0x${event.blockNumber.toString(16)}`, - from: userOp.sender, - gas: `0x${event.args.actualGasUsed.toString(16)}`, - hash: txHash, + blockHash: receipt.receipt.blockHash, + blockNumber: receipt.receipt.blockNumber, + from: receipt.sender, + gas: receipt.actualGasUsed, + hash: receipt.userOpHash, input: userOp.callData, - nonce: userOp.nonce, + nonce: receipt.nonce, to: action.to, - transactionIndex: `0x${event.transactionIndex.toString(16)}`, + transactionIndex: receipt.receipt.transactionIndex, value: `0x${action.value.toString(16)}`, accessList: [], @@ -359,70 +470,27 @@ export default class EthereumApi { v: '0x0', chainId: `0x${opInfo.chainId.toString(16)}`, - gasPrice: `0x${event.args.actualGasCost.toString(16)}`, + gasPrice: receipt.actualGasCost, maxFeePerGas: `0x${userOp.maxFeePerGas.toString(16)}`, maxPriorityFeePerGas: `0x${userOp.maxPriorityFeePerGas.toString(16)}`, - } satisfies EthereumRpc.Transaction; + } satisfies EthereumRpc.TransactionReceipt; }, - }; - - async #networkRequest({ - method, - params = [], - }: { - method: string; - params?: unknown[]; - }) { - const res = await fetch(this.#rpcUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - jsonrpc: '2.0', - method, - params, - id: randomId(), - }), - }); - - const json = z - .union([ - z.object({ result: ZodNotUndefined() }), - z.object({ error: ZodNotUndefined() }), - ]) - .parse(await res.json()); - - if ('result' in json) { - return json.result; - } - - assert('error' in json); - throw JsonRpcError.parse(json.error); - } - async #getAccount() { - let account = await this.#waxInPage.storage.account.get(); - - if (account) { - return account; - } - - const contracts = await this.#waxInPage.getContracts(); - - const wallet = ethers.Wallet.createRandom(); + eth_sendUserOperation: async (userOp) => + this.#bundler.eth_sendUserOperation( + userOp, + await this.#getEntryPointAddress(), + ), - account = { - privateKey: wallet.privateKey, - ownerAddress: wallet.address, - address: await contracts.simpleAccountFactory.createAccount.staticCall( - wallet.address, - 0, + eth_estimateUserOperationGas: async (userOp) => + this.#bundler.eth_estimateUserOperationGas( + userOp, + await this.#getEntryPointAddress(), ), - }; - await this.#waxInPage.storage.account.set(account); + eth_getUserOperationReceipt: (userOpHash) => + this.#bundler.eth_getUserOperationReceipt(userOpHash), - return account; - } + eth_supportedEntryPoints: () => this.#bundler.eth_supportedEntryPoints(), + }; } diff --git a/demos/inpage/src/EthereumRpc.ts b/demos/inpage/src/EthereumRpc.ts index e79bbc45..a6fbe0a1 100644 --- a/demos/inpage/src/EthereumRpc.ts +++ b/demos/inpage/src/EthereumRpc.ts @@ -4,29 +4,74 @@ import z from 'zod'; namespace EthereumRpc { export const emptyParams = z.union([z.tuple([]), z.undefined()]); - export const Transaction = z.object({ + export const BigNumberish = z.union([z.string(), z.number(), z.bigint()]); + export type BigNumberish = z.infer; + + export const TransactionReceipt = z.object({ blockHash: z.union([z.string(), z.null()]), blockNumber: z.union([z.string(), z.null()]), from: z.string(), - gas: z.string(), - hash: z.string(), - input: z.string(), - nonce: z.string(), - to: z.string(), + gas: z.optional(z.string()), + hash: z.optional(z.string()), + input: z.optional(z.string()), + nonce: z.optional(z.string()), + to: z.union([z.string(), z.null()]), transactionIndex: z.union([z.string(), z.null()]), - value: z.string(), - v: z.string(), - r: z.string(), - s: z.string(), + value: z.optional(z.string()), + v: z.optional(z.string()), + r: z.optional(z.string()), + s: z.optional(z.string()), type: z.optional(z.string()), - accessList: z.array(z.unknown()), - chainId: z.string(), + accessList: z.optional(z.array(z.unknown())), + chainId: z.optional(z.string()), gasPrice: z.optional(z.string()), maxFeePerGas: z.optional(z.string()), maxPriorityFeePerGas: z.optional(z.string()), }); - export type Transaction = z.infer; + export type TransactionReceipt = z.infer; + + export const UserOperation = z.object({ + sender: z.string(), + nonce: BigNumberish, + initCode: z.string(), + callData: z.string(), + callGasLimit: BigNumberish, + verificationGasLimit: BigNumberish, + preVerificationGas: BigNumberish, + maxFeePerGas: BigNumberish, + maxPriorityFeePerGas: BigNumberish, + paymasterAndData: z.string(), + signature: z.string(), + }); + + export type UserOperation = z.infer; + + export const UserOperationReceipt = z.object({ + userOpHash: z.string(), + entryPoint: z.optional(z.string()), + sender: z.string(), + nonce: z.string(), + paymaster: z.optional(z.string()), + actualGasCost: z.string(), + actualGasUsed: z.string(), + success: z.boolean(), + reason: z.optional(z.string()), + logs: z.array(z.unknown()), + receipt: TransactionReceipt, + }); + + export type UserOperationReceipt = z.infer; + + export const UserOperationGasEstimate = z.object({ + preVerificationGas: z.string(), + verificationGasLimit: z.string(), + callGasLimit: z.string(), + }); + + export type UserOperationGasEstimate = z.infer< + typeof UserOperationGasEstimate + >; export const schema = { eth_requestAccounts: { @@ -45,7 +90,7 @@ namespace EthereumRpc { params: z .array( z.object({ - from: z.string(), + from: z.optional(z.string()), }), ) .min(1), @@ -53,7 +98,40 @@ namespace EthereumRpc { }, eth_getTransactionByHash: { params: z.tuple([z.string()]), - output: z.union([z.null(), Transaction]), + output: z.union([z.null(), TransactionReceipt]), + }, + eth_estimateGas: { + params: z.tuple([ + z.object({ + from: z.optional(z.string()), + to: z.optional(z.string()), + gas: z.optional(BigNumberish), + gasPrice: z.optional(BigNumberish), + value: z.optional(BigNumberish), + data: z.optional(z.string()), + }), + ]), + output: z.string(), + }, + eth_sendUserOperation: { + params: z.tuple([UserOperation, z.string()]), + output: z.string(), + }, + eth_estimateUserOperationGas: { + params: z.tuple([UserOperation, z.string()]), + output: z.object({ + preVerificationGas: z.string(), + verificationGasLimit: z.string(), + callGasLimit: z.string(), + }), + }, + eth_getUserOperationReceipt: { + params: z.tuple([z.string()]), + output: z.union([z.null(), UserOperationReceipt]), + }, + eth_supportedEntryPoints: { + params: emptyParams, + output: z.array(z.string()), }, }; diff --git a/demos/inpage/src/WaxInPage.tsx b/demos/inpage/src/WaxInPage.tsx index 446c38f8..994dbb2f 100644 --- a/demos/inpage/src/WaxInPage.tsx +++ b/demos/inpage/src/WaxInPage.tsx @@ -19,8 +19,13 @@ import SafeSingletonFactory, { } from './SafeSingletonFactory'; import ReusablePopup from './ReusablePopup'; import AdminPopup, { AdminPurpose } from './AdminPopup'; +import waxPrivate from './waxPrivate'; +import SimulatedBundler from './bundlers/SimulatedBundler'; +import NetworkBundler from './bundlers/NetworkBundler'; +import IBundler from './bundlers/IBundler'; type Config = { + logRequests?: boolean; requirePermission: boolean; deployContractsIfNeeded: boolean; ethersPollingInterval?: number; @@ -35,6 +40,7 @@ let ethersDefaultPollingInterval = 4000; type ConstructorOptions = { rpcUrl: string; + bundlerRpcUrl?: string; storage?: WaxStorage; }; @@ -54,8 +60,20 @@ export default class WaxInPage { storage: WaxStorage; ethersProvider: ethers.BrowserProvider; - constructor({ rpcUrl, storage = makeLocalWaxStorage() }: ConstructorOptions) { - this.ethereum = new EthereumApi(rpcUrl, this); + constructor({ + rpcUrl, + bundlerRpcUrl, + storage = makeLocalWaxStorage(), + }: ConstructorOptions) { + let bundler: IBundler; + + if (bundlerRpcUrl === undefined) { + bundler = new SimulatedBundler(this); + } else { + bundler = new NetworkBundler(bundlerRpcUrl); + } + + this.ethereum = new EthereumApi(rpcUrl, this, bundler); this.storage = storage; this.ethersProvider = new ethers.BrowserProvider(this.ethereum); ethersDefaultPollingInterval = this.ethersProvider.pollingInterval; @@ -92,6 +110,10 @@ export default class WaxInPage { } } + getConfig(key: K): Config[K] { + return structuredClone(this.#config[key]); + } + async requestPermission(message: ReactNode) { if (this.#config.requirePermission === false) { return true; @@ -278,4 +300,33 @@ export default class WaxInPage { async disconnect() { await this.storage.connectedAccounts.clear(); } + + async _getAccount(waxPrivateParam: symbol) { + if (waxPrivateParam !== waxPrivate) { + throw new Error('This method is private to the waxInPage library'); + } + + let account = await this.storage.account.get(); + + if (account) { + return account; + } + + const contracts = await this.getContracts(); + + const wallet = ethers.Wallet.createRandom(); + + account = { + privateKey: wallet.privateKey, + ownerAddress: wallet.address, + address: await contracts.simpleAccountFactory.createAccount.staticCall( + wallet.address, + 0, + ), + }; + + await this.storage.account.set(account); + + return account; + } } diff --git a/demos/inpage/src/bundlers/IBundler.ts b/demos/inpage/src/bundlers/IBundler.ts new file mode 100644 index 00000000..9da5f1ba --- /dev/null +++ b/demos/inpage/src/bundlers/IBundler.ts @@ -0,0 +1,11 @@ +import EthereumRpc from '../EthereumRpc'; + +type IBundler = Pick< + EthereumRpc.Handlers, + | 'eth_sendUserOperation' + | 'eth_estimateUserOperationGas' + | 'eth_getUserOperationReceipt' + | 'eth_supportedEntryPoints' +>; + +export default IBundler; diff --git a/demos/inpage/src/bundlers/NetworkBundler.ts b/demos/inpage/src/bundlers/NetworkBundler.ts new file mode 100644 index 00000000..2d8d1f30 --- /dev/null +++ b/demos/inpage/src/bundlers/NetworkBundler.ts @@ -0,0 +1,50 @@ +import EthereumRpc from '../EthereumRpc'; +import ethereumRequest from '../ethereumRequest'; +import IBundler from './IBundler'; + +export default class NetworkBundler implements IBundler { + #rpcUrl: string; + + constructor(rpcUrl: string) { + this.#rpcUrl = rpcUrl; + } + + async eth_sendUserOperation( + userOp: EthereumRpc.UserOperation, + entryPoint: string, + ): Promise { + return await ethereumRequest({ + url: this.#rpcUrl, + method: 'eth_sendUserOperation', + params: [userOp, entryPoint], + }); + } + + async eth_estimateUserOperationGas( + userOp: EthereumRpc.UserOperation, + entryPoint: string, + ): Promise { + return await ethereumRequest({ + url: this.#rpcUrl, + method: 'eth_estimateUserOperationGas', + params: [userOp, entryPoint], + }); + } + + async eth_getUserOperationReceipt( + userOpHash: string, + ): Promise { + return await ethereumRequest({ + url: this.#rpcUrl, + method: 'eth_getUserOperationReceipt', + params: [userOpHash], + }); + } + + async eth_supportedEntryPoints(): Promise { + return await ethereumRequest({ + url: this.#rpcUrl, + method: 'eth_supportedEntryPoints', + }); + } +} diff --git a/demos/inpage/src/bundlers/SimulatedBundler.ts b/demos/inpage/src/bundlers/SimulatedBundler.ts new file mode 100644 index 00000000..c2df292b --- /dev/null +++ b/demos/inpage/src/bundlers/SimulatedBundler.ts @@ -0,0 +1,153 @@ +import WaxInPage from '..'; +import EthereumRpc from '../EthereumRpc'; +import assert from '../helpers/assert'; +import measureCalldataGas from '../measureCalldataGas'; +import waxPrivate from '../waxPrivate'; +import IBundler from './IBundler'; + +// This value is needed to account for the overheads of running the entry point +// that are difficult to attribute directly to each user op. It should be +// calibrated so that the bundler makes a small profit overall. +const basePreVerificationGas = 50_000n; + +// Cost of validating a signature or whatever verification method is in place. +const baseVerificationGas = 100_000n; + +export default class SimulatedBundler implements IBundler { + #waxInPage: WaxInPage; + + constructor(waxInPage: WaxInPage) { + this.#waxInPage = waxInPage; + } + + async eth_sendUserOperation( + userOp: EthereumRpc.UserOperation, + ): Promise { + const adminAccount = await this.#waxInPage.requestAdminAccount( + 'simulate-bundler', + ); + + const contracts = await this.#waxInPage.getContracts(); + + // *not* the confirmation, just the response (don't add .wait(), that's + // wrong). + await contracts.entryPoint + .connect(adminAccount) + .handleOps([userOp], adminAccount.getAddress()); + + return await contracts.entryPoint.getUserOpHash(userOp); + } + + async eth_estimateUserOperationGas( + userOp: EthereumRpc.UserOperation, + ): Promise { + const contracts = await this.#waxInPage.getContracts(); + const account = await this.#waxInPage._getAccount(waxPrivate); + + // We need a beneficiary address to measure the encoded calldata, but + // there's no need for it to be correct. + const fakeBeneficiary = userOp.sender; + + const baselineData = contracts.entryPoint.interface.encodeFunctionData( + 'handleOps', + [[], fakeBeneficiary], + ); + + const data = contracts.entryPoint.interface.encodeFunctionData( + 'handleOps', + [[userOp], fakeBeneficiary], + ); + + const calldataGas = + measureCalldataGas(data) - measureCalldataGas(baselineData); + + let verificationGasLimit = baseVerificationGas; + + assert(userOp.sender.toLowerCase() === account.address.toLowerCase()); + + if (BigInt(userOp.nonce) === 0n) { + verificationGasLimit += + await contracts.simpleAccountFactory.createAccount.estimateGas( + account.ownerAddress, + 0, + ); + } + + const callGasLimit = await this.#waxInPage.ethereum.request({ + method: 'eth_estimateGas', + params: [ + { + from: await contracts.entryPoint.getAddress(), + to: userOp.sender, + data: userOp.callData, + }, + ], + }); + + return { + preVerificationGas: `0x${(basePreVerificationGas + calldataGas).toString( + 16, + )}`, + verificationGasLimit: `0x${verificationGasLimit.toString(16)}`, + callGasLimit, + }; + } + + async eth_getUserOperationReceipt( + userOpHash: string, + ): Promise { + const contracts = await this.#waxInPage.getContracts(); + + const events = await contracts.entryPoint.queryFilter( + contracts.entryPoint.filters.UserOperationEvent(userOpHash), + ); + + if (events.length === 0) { + return null; + } + + const event = events[0]; + + const txReceipt = await this.#waxInPage.ethereum.request({ + method: 'eth_getTransactionByHash', + params: [event.transactionHash], + }); + + if (txReceipt === null) { + return null; + } + + let revertReason = '0x'; + + if (!event.args.success) { + const errorEvents = await contracts.entryPoint.queryFilter( + contracts.entryPoint.filters.UserOperationRevertReason(userOpHash), + ); + + const errorEvent = errorEvents.at(0); + + if (errorEvent !== undefined) { + revertReason = errorEvent.args.revertReason; + } + } + + return { + userOpHash, + entryPoint: await contracts.entryPoint.getAddress(), + sender: event.args.sender, + nonce: `0x${event.args.nonce.toString(16)}`, + paymaster: event.args.paymaster, + actualGasCost: `0x${event.args.actualGasCost.toString(16)}`, + actualGasUsed: `0x${event.args.actualGasUsed.toString(16)}`, + success: event.args.success, + reason: revertReason, + logs: [], // TODO: Logs + receipt: txReceipt, + } satisfies EthereumRpc.UserOperationReceipt; + } + + async eth_supportedEntryPoints(): Promise { + const contracts = await this.#waxInPage.getContracts(); + return [await contracts.entryPoint.getAddress()]; + } +} diff --git a/demos/inpage/src/ethereumRequest.ts b/demos/inpage/src/ethereumRequest.ts new file mode 100644 index 00000000..eb912bf6 --- /dev/null +++ b/demos/inpage/src/ethereumRequest.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; +import JsonRpcError from './JsonRpcError'; +import ZodNotUndefined from './helpers/ZodNotUndefined'; +import assert from './helpers/assert'; +import randomId from './helpers/randomId'; +import EthereumRpc from './EthereumRpc'; + +export default async function ethereumRequest({ + url, + method, + params = [], +}: { + url: string; + method: M; +} & EthereumRpc.RequestParams): Promise> { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify( + { + jsonrpc: '2.0', + method, + params, + id: randomId(), + }, + (_key, value) => { + if (typeof value === 'bigint') { + return `0x${value.toString(16)}`; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value; + }, + ), + }); + + const json = z + .union([ + z.object({ result: ZodNotUndefined() }), + z.object({ error: ZodNotUndefined() }), + ]) + .parse(await res.json()); + + if ('result' in json) { + return parseResponse(method, json.result); + } + + assert('error' in json); + throw JsonRpcError.parse(json.error); +} + +function parseResponse( + method: M, + response: unknown, +): EthereumRpc.Response { + if (!(method in EthereumRpc.schema)) { + return response as EthereumRpc.Response; + } + + const methodSchema = EthereumRpc.schema[method as keyof EthereumRpc.Schema]; + + const parsedResponse = methodSchema.output.safeParse(response); + + if (!parsedResponse.success) { + throw new JsonRpcError({ + code: -32602, + message: parsedResponse.error.toString(), + }); + } + + return parsedResponse.data as EthereumRpc.Response; +} diff --git a/demos/inpage/src/helpers/assert.ts b/demos/inpage/src/helpers/assert.ts index 28ef0f08..388cfee9 100644 --- a/demos/inpage/src/helpers/assert.ts +++ b/demos/inpage/src/helpers/assert.ts @@ -3,6 +3,7 @@ export default function assert( msg = 'Assertion failed', ): asserts condition { if (!condition) { + debugger; throw new AssertionError(msg); } } diff --git a/demos/inpage/src/measureCalldataGas.ts b/demos/inpage/src/measureCalldataGas.ts new file mode 100644 index 00000000..4e6ad5dd --- /dev/null +++ b/demos/inpage/src/measureCalldataGas.ts @@ -0,0 +1,17 @@ +import assert from './helpers/assert'; + +export default function measureCalldataGas(calldata: string) { + assert(/^0x([0-9a-f][0-9a-f])*$/i.test(calldata)); + + let gas = 0n; + + for (let i = 2; i < calldata.length; i += 2) { + if (calldata.slice(i, i + 2) === '00') { + gas += 4n; + } else { + gas += 16n; + } + } + + return gas; +} diff --git a/demos/inpage/src/waxPrivate.ts b/demos/inpage/src/waxPrivate.ts new file mode 100644 index 00000000..5b6eeb88 --- /dev/null +++ b/demos/inpage/src/waxPrivate.ts @@ -0,0 +1,7 @@ +/** + * This symbol is not exported to users of the library so that functions that + * should be internal to wax can test against it. + */ +const waxPrivate = Symbol('waxPrivate'); + +export default waxPrivate;