From 3010d167866ea29ef883b6ecb10d21121c9568dd Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Tue, 18 Apr 2023 11:02:30 +1000 Subject: [PATCH 1/5] feat: add eth_call multicall aggregation --- .env.example | 3 +- .github/workflows/main.yml | 5 + src/_test/utils.ts | 6 + src/actions/ens/getEnsAddress.test.ts | 3 +- src/actions/public/call.test.ts | 473 +++++++++++++++++- src/actions/public/call.ts | 148 +++++- src/actions/public/readContract.test.ts | 3 +- src/actions/public/simulateContract.ts | 1 + src/clients/createPublicClient.test.ts | 40 ++ src/clients/createPublicClient.ts | 20 +- src/constants/contract.ts | 1 + src/constants/index.ts | 2 + src/errors/chain.test.ts | 14 +- src/errors/chain.ts | 8 + src/errors/index.ts | 1 + src/index.test.ts | 1 + src/index.ts | 1 + src/types/eip1193.ts | 2 +- src/utils/errors/getContractError.ts | 14 +- .../promise/createBatchScheduler.test.ts | 241 +++++++++ src/utils/promise/createBatchScheduler.ts | 82 +++ src/utils/promise/index.test.ts | 1 + src/utils/promise/index.ts | 1 + 23 files changed, 1047 insertions(+), 24 deletions(-) create mode 100644 src/constants/contract.ts create mode 100644 src/utils/promise/createBatchScheduler.test.ts create mode 100644 src/utils/promise/createBatchScheduler.ts diff --git a/.env.example b/.env.example index 86908d79c4..f260bdc9aa 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ ALCHEMY_ID= VITE_ANVIL_FORK_URL= VITE_ANVIL_BLOCK_TIME=1 VITE_ANVIL_BLOCK_NUMBER=16280770 -VITE_NETWORK_TRANSPORT_MODE=http \ No newline at end of file +VITE_NETWORK_TRANSPORT_MODE=http +VITE_BATCH_MULTICALL=true \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8eddd17a47..6f9bbe419f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -78,6 +78,10 @@ jobs: shard: [1, 2, 3] total-shards: [3] transport-mode: ['http', 'webSocket'] + include: + - batch-multicall: 'false' + - batch-multicall: 'true' + transport-mode: 'http' steps: - uses: actions/checkout@v3 - name: Setup @@ -100,6 +104,7 @@ jobs: VITE_ANVIL_BLOCK_TIME: ${{ vars.VITE_ANVIL_BLOCK_TIME }} VITE_ANVIL_FORK_URL: ${{ vars.VITE_ANVIL_FORK_URL }} VITE_NETWORK_TRANSPORT_MODE: ${{ matrix.transport-mode }} + VITE_BATCH_MULTICALL: ${{ matrix.batch-multicall }} - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: diff --git a/src/_test/utils.ts b/src/_test/utils.ts index 33b706d909..0a8639a878 100644 --- a/src/_test/utils.ts +++ b/src/_test/utils.ts @@ -101,12 +101,18 @@ const provider = { } export const httpClient = createPublicClient({ + batch: { + multicall: process.env.VITE_BATCH_MULTICALL === 'true', + }, chain: anvilChain, pollingInterval: 1_000, transport: http(), }) export const webSocketClient = createPublicClient({ + batch: { + multicall: process.env.VITE_BATCH_MULTICALL === 'true', + }, chain: anvilChain, pollingInterval: 1_000, transport: webSocket(localWsUrl), diff --git a/src/actions/ens/getEnsAddress.test.ts b/src/actions/ens/getEnsAddress.test.ts index 5af8ddce3e..63e87ff1c0 100644 --- a/src/actions/ens/getEnsAddress.test.ts +++ b/src/actions/ens/getEnsAddress.test.ts @@ -118,8 +118,7 @@ test('invalid universal resolver address', async () => { universalResolverAddress: '0xecb504d39723b0be0e3a9aa33d646642d1051ee1', }), ).rejects.toThrowErrorMatchingInlineSnapshot(` - "The contract function \\"resolve\\" reverted with the following reason: - execution reverted + "The contract function \\"resolve\\" reverted. Contract Call: address: 0x0000000000000000000000000000000000000000 diff --git a/src/actions/public/call.test.ts b/src/actions/public/call.test.ts index ad46032354..421405d175 100644 --- a/src/actions/public/call.test.ts +++ b/src/actions/public/call.test.ts @@ -1,11 +1,18 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' -import { accounts, publicClient } from '../../_test/index.js' -import { celo } from '../../chains.js' +import { + accounts, + initialBlockNumber, + publicClient, +} from '../../_test/index.js' +import { baycContractConfig, usdcContractConfig } from '../../_test/abis.js' +import { celo, mainnet } from '../../chains.js' import { createPublicClient, http } from '../../clients/index.js' +import { aggregate3Signature } from '../../constants/index.js' import { numberToHex, parseEther, parseGwei } from '../../utils/index.js' import { call } from './call.js' +import { wait } from '../../utils/wait.js' const wagmiContractAddress = '0xFBA3912Ca04dd458c843e2EE08967fC04f3579c2' const name4bytes = '0x06fdde03' @@ -231,3 +238,463 @@ describe('errors', () => { `) }) }) + +describe('batch call', () => { + test('default', async () => { + publicClient.batch = { multicall: true } + + const spy = vi.spyOn(publicClient, 'request') + + const p = [] + p.push( + call(publicClient, { + data: name4bytes, + to: wagmiContractAddress, + }), + ) + p.push( + call(publicClient, { + data: name4bytes, + to: usdcContractConfig.address, + }), + ) + await wait(1) + p.push( + call(publicClient, { + data: name4bytes, + to: baycContractConfig.address, + }), + ) + await wait(1) + p.push( + call(publicClient, { + data: name4bytes, + to: wagmiContractAddress, + }), + ) + await wait(50) + p.push( + call(publicClient, { + data: name4bytes, + to: usdcContractConfig.address, + }), + ) + p.push( + call(publicClient, { + data: name4bytes, + to: baycContractConfig.address, + }), + ) + + const results = await Promise.all(p) + + expect(spy).toBeCalledTimes(2) + expect(results).toMatchInlineSnapshot(` + [ + { + "data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000", + }, + { + "data": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000855534420436f696e000000000000000000000000000000000000000000000000", + }, + { + "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000011426f7265644170655961636874436c7562000000000000000000000000000000", + }, + { + "data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000", + }, + { + "data": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000855534420436f696e000000000000000000000000000000000000000000000000", + }, + { + "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000011426f7265644170655961636874436c7562000000000000000000000000000000", + }, + ] + `) + }) + + test('args: blockNumber', async () => { + publicClient.batch = { multicall: true } + + const spy = vi.spyOn(publicClient, 'request') + + const p = [] + p.push( + call(publicClient, { + data: name4bytes, + to: wagmiContractAddress, + blockNumber: initialBlockNumber, + }), + ) + p.push( + call(publicClient, { + data: name4bytes, + to: wagmiContractAddress, + blockNumber: initialBlockNumber + 1n, + }), + ) + p.push( + call(publicClient, { + data: name4bytes, + to: usdcContractConfig.address, + }), + ) + await wait(1) + p.push( + call(publicClient, { + data: name4bytes, + to: baycContractConfig.address, + blockNumber: initialBlockNumber, + }), + ) + await wait(1) + p.push( + call(publicClient, { + data: name4bytes, + to: wagmiContractAddress, + }), + ) + await wait(50) + p.push( + call(publicClient, { + data: name4bytes, + to: usdcContractConfig.address, + }), + ) + p.push( + call(publicClient, { + data: name4bytes, + to: baycContractConfig.address, + }), + ) + + const results = await Promise.all(p) + + expect(spy).toBeCalledTimes(4) + expect(results).toMatchInlineSnapshot(` + [ + { + "data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000", + }, + { + "data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000", + }, + { + "data": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000855534420436f696e000000000000000000000000000000000000000000000000", + }, + { + "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000011426f7265644170655961636874436c7562000000000000000000000000000000", + }, + { + "data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000", + }, + { + "data": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000855534420436f696e000000000000000000000000000000000000000000000000", + }, + { + "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000011426f7265644170655961636874436c7562000000000000000000000000000000", + }, + ] + `) + }) + + test('args: no address, no data, aggregate3 sig, other properties', async () => { + publicClient.batch = { multicall: true } + + const spy = vi.spyOn(publicClient, 'request') + + const p = [] + p.push( + call(publicClient, { + data: name4bytes, + }), + ) + p.push( + call(publicClient, { + to: wagmiContractAddress, + }), + ) + p.push( + call(publicClient, { + data: aggregate3Signature, + to: wagmiContractAddress, + }), + ) + p.push( + call(publicClient, { + data: name4bytes, + to: wagmiContractAddress, + maxFeePerGas: 1n, + }), + ) + + try { + await Promise.all(p) + } catch {} + + expect(spy).toBeCalledTimes(4) + }) + + test('contract revert', async () => { + const spy = vi.spyOn(publicClient, 'request') + + const p = [] + p.push( + call(publicClient, { + data: name4bytes, + to: wagmiContractAddress, + }), + ) + p.push( + call(publicClient, { + data: `${mintWithParams4bytes}${fourTwenty}`, + to: wagmiContractAddress, + }), + ) + + const results = await Promise.allSettled(p) + + expect(spy).toBeCalledTimes(1) + expect(results).toMatchInlineSnapshot(` + [ + { + "status": "fulfilled", + "value": { + "data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000", + }, + }, + { + "reason": [CallExecutionError: An error occurred. + + Raw Call Arguments: + to: 0xFBA3912Ca04dd458c843e2EE08967fC04f3579c2 + data: 0xa0712d6800000000000000000000000000000000000000000000000000000000000001a4 + + Version: viem@1.0.2], + "status": "rejected", + }, + ] + `) + }) + + test('client config', async () => { + publicClient.batch = { + multicall: { + batchSize: 1024, + wait: 0, + }, + } + + const spy = vi.spyOn(publicClient, 'request') + + const p = [] + p.push( + call(publicClient, { + data: name4bytes, + to: wagmiContractAddress, + }), + ) + p.push( + call(publicClient, { + data: name4bytes, + to: usdcContractConfig.address, + }), + ) + await wait(1) + p.push( + call(publicClient, { + data: name4bytes, + to: baycContractConfig.address, + }), + ) + await wait(1) + p.push( + call(publicClient, { + data: name4bytes, + to: wagmiContractAddress, + }), + ) + await wait(50) + p.push( + call(publicClient, { + data: name4bytes, + to: usdcContractConfig.address, + }), + ) + p.push( + call(publicClient, { + data: name4bytes, + to: baycContractConfig.address, + }), + ) + + const results = await Promise.all(p) + + expect(spy).toBeCalledTimes(4) + expect(results).toMatchInlineSnapshot(` + [ + { + "data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000", + }, + { + "data": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000855534420436f696e000000000000000000000000000000000000000000000000", + }, + { + "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000011426f7265644170655961636874436c7562000000000000000000000000000000", + }, + { + "data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000", + }, + { + "data": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000855534420436f696e000000000000000000000000000000000000000000000000", + }, + { + "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000011426f7265644170655961636874436c7562000000000000000000000000000000", + }, + ] + `) + }) + + test('no chain on client', async () => { + const client = publicClient + + // @ts-expect-error + client.chain = undefined + client.batch = { multicall: true } + + const spy = vi.spyOn(client, 'request') + + const p = [] + p.push( + call(client, { + data: name4bytes, + to: wagmiContractAddress, + }), + ) + p.push( + call(client, { + data: name4bytes, + to: usdcContractConfig.address, + }), + ) + p.push( + call(client, { + data: name4bytes, + to: baycContractConfig.address, + }), + ) + + const results = await Promise.all(p) + + expect(spy).toBeCalledTimes(3) + expect(results).toMatchInlineSnapshot(` + [ + { + "data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000", + }, + { + "data": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000855534420436f696e000000000000000000000000000000000000000000000000", + }, + { + "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000011426f7265644170655961636874436c7562000000000000000000000000000000", + }, + ] + `) + }) + + test('chain not configured with multicall', async () => { + const client = publicClient + + client.batch = { multicall: true } + client.chain = { + ...client.chain, + contracts: { + // @ts-expect-error + multicall3: undefined, + }, + } + + const spy = vi.spyOn(client, 'request') + + const p = [] + p.push( + call(client, { + data: name4bytes, + to: wagmiContractAddress, + }), + ) + p.push( + call(client, { + data: name4bytes, + to: usdcContractConfig.address, + }), + ) + p.push( + call(client, { + data: name4bytes, + to: baycContractConfig.address, + }), + ) + + const results = await Promise.all(p) + + expect(spy).toBeCalledTimes(3) + expect(results).toMatchInlineSnapshot(` + [ + { + "data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000", + }, + { + "data": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000855534420436f696e000000000000000000000000000000000000000000000000", + }, + { + "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000011426f7265644170655961636874436c7562000000000000000000000000000000", + }, + ] + `) + }) + + test( + 'stress', + async () => { + const batchSize = 2048 + const batch1Length = 500 + const batch2Length = 10_000 + + const client = createPublicClient({ + chain: mainnet, + batch: { multicall: true }, + transport: http(), + }) + + const spy = vi.spyOn(client, 'request') + + const p = [] + for (let i = 0; i < batch1Length; i++) { + p.push( + call(client, { + data: name4bytes, + to: wagmiContractAddress, + }), + ) + } + await wait(50) + for (let i = 0; i < batch2Length; i++) { + p.push( + call(client, { + data: name4bytes, + to: wagmiContractAddress, + }), + ) + } + + await Promise.all(p) + + expect(spy).toBeCalledTimes( + Math.ceil((batch1Length * (name4bytes.length - 2)) / batchSize) + + Math.ceil((batch2Length * (name4bytes.length - 2)) / batchSize), + ) + }, + { timeout: 30_000 }, + ) +}) diff --git a/src/actions/public/call.ts b/src/actions/public/call.ts index d664b413ad..fb6f1b3c10 100644 --- a/src/actions/public/call.ts +++ b/src/actions/public/call.ts @@ -1,5 +1,11 @@ import type { PublicClient, Transport } from '../../clients/index.js' +import { aggregate3Signature, multicall3Abi } from '../../constants/index.js' import type { BaseError } from '../../errors/index.js' +import { + ChainDoesNotSupportContract, + ClientChainNotConfiguredError, + RawContractError, +} from '../../errors/index.js' import type { Account, Address, @@ -10,19 +16,23 @@ import type { MergeIntersectionProperties, TransactionRequest, } from '../../types/index.js' +import type { + Formatted, + TransactionRequestFormatter, +} from '../../utils/index.js' import { assertRequest, + decodeFunctionResult, + encodeFunctionData, extract, format, formatTransactionRequest, getCallError, + getChainContractAddress, numberToHex, parseAccount, } from '../../utils/index.js' -import type { - Formatted, - TransactionRequestFormatter, -} from '../../utils/index.js' +import { createBatchScheduler } from '../../utils/promise/createBatchScheduler.js' export type FormattedCall< TFormatter extends Formatter | undefined = Formatter, @@ -35,6 +45,7 @@ export type CallParameters< TChain extends Chain | undefined = Chain | undefined, > = FormattedCall> & { account?: Account | Address + batch?: boolean } & ( | { /** The balance of the account at a block number. */ @@ -84,6 +95,7 @@ export async function call( ): Promise { const { account: account_, + batch = Boolean(client.batch?.multicall), blockNumber, blockTag = 'latest', accessList, @@ -104,7 +116,7 @@ export async function call( const blockNumberHex = blockNumber ? numberToHex(blockNumber) : undefined const formatter = client.chain?.formatters?.transactionRequest - const request_ = format( + const request = format( { from: account?.address, accessList, @@ -122,11 +134,27 @@ export async function call( { formatter: formatter || formatTransactionRequest, }, - ) + ) as TransactionRequest + + if (batch && shouldPerformMulticall({ request })) { + try { + return await scheduleMulticall(client, { + ...request, + blockNumber, + blockTag, + } as unknown as ScheduleMulticallParameters) + } catch (err) { + if ( + !(err instanceof ClientChainNotConfiguredError) && + !(err instanceof ChainDoesNotSupportContract) + ) + throw err + } + } const response = await client.request({ method: 'eth_call', - params: [request_, blockNumberHex || blockTag], + params: [request as any, blockNumberHex || blockTag], }) if (response === '0x') return { data: undefined } return { data: response } @@ -138,3 +166,109 @@ export async function call( }) } } + +// We only want to perform a scheduled multicall if: +// - The request has calldata, +// - The request has a target address, +// - The target address is not already the aggregate3 signature, +// - The request has no other properties (`nonce`, `gas`, etc cannot be sent with a multicall). +function shouldPerformMulticall({ request }: { request: TransactionRequest }) { + const { data, to, ...request_ } = request + if (!data) return false + if (data.startsWith(aggregate3Signature)) return false + if (!to) return false + if ( + Object.values(request_).filter((x) => typeof x !== 'undefined').length > 0 + ) + return false + return true +} + +type ScheduleMulticallParameters = Pick< + CallParameters, + 'blockNumber' | 'blockTag' +> & { + data: Hex + multicallAddress?: Address + to: Address +} + +async function scheduleMulticall( + client: PublicClient, + args: ScheduleMulticallParameters, +) { + const { batchSize = 1024, wait = 16 } = + typeof client.batch?.multicall === 'object' ? client.batch?.multicall : {} + const { + blockNumber, + blockTag = 'latest', + data, + multicallAddress: multicallAddress_, + to, + } = args + + let multicallAddress = multicallAddress_ + if (!multicallAddress) { + if (!client.chain) throw new ClientChainNotConfiguredError() + + multicallAddress = getChainContractAddress({ + blockNumber, + chain: client.chain, + contract: 'multicall3', + }) + } + + const blockNumberHex = blockNumber ? numberToHex(blockNumber) : undefined + const block = blockNumberHex || blockTag + + const { schedule } = createBatchScheduler({ + id: `${client.uid}.${block}`, + wait, + shouldSplitBatch(args) { + const size = args.reduce((size, { data }) => size + (data.length - 2), 0) + return size > batchSize * 2 + }, + fn: async ( + requests: { + data: Hex + to: Address + }[], + ) => { + const calls = requests.map((request) => ({ + allowFailure: true, + callData: request.data, + target: request.to, + })) + + const calldata = encodeFunctionData({ + abi: multicall3Abi, + args: [calls], + functionName: 'aggregate3', + }) + + const data = await client.request({ + method: 'eth_call', + params: [ + { + data: calldata, + to: multicallAddress, + }, + block, + ], + }) + + return decodeFunctionResult({ + abi: multicall3Abi, + args: [calls], + functionName: 'aggregate3', + data: data || '0x', + }) + }, + }) + + const [{ returnData, success }] = await schedule({ data, to }) + + if (!success) throw new RawContractError({ data: returnData }) + if (returnData === '0x') return { data: undefined } + return { data: returnData } +} diff --git a/src/actions/public/readContract.test.ts b/src/actions/public/readContract.test.ts index a2e6c16cdd..0b8ec50423 100644 --- a/src/actions/public/readContract.test.ts +++ b/src/actions/public/readContract.test.ts @@ -252,8 +252,7 @@ describe('contract errors', () => { functionName: 'requireRead', }), ).rejects.toMatchInlineSnapshot(` - [ContractFunctionExecutionError: The contract function "requireRead" reverted with the following reason: - execution reverted + [ContractFunctionExecutionError: The contract function "requireRead" reverted. Contract Call: address: 0x0000000000000000000000000000000000000000 diff --git a/src/actions/public/simulateContract.ts b/src/actions/public/simulateContract.ts index e8ed21956b..8892b60e50 100644 --- a/src/actions/public/simulateContract.ts +++ b/src/actions/public/simulateContract.ts @@ -115,6 +115,7 @@ export async function simulateContract< } as unknown as EncodeFunctionDataParameters) try { const { data } = await call(client, { + batch: false, data: calldata, to: address, ...callRequest, diff --git a/src/clients/createPublicClient.test.ts b/src/clients/createPublicClient.test.ts index 36a88a8a36..58b2f0f5cf 100644 --- a/src/clients/createPublicClient.test.ts +++ b/src/clients/createPublicClient.test.ts @@ -27,6 +27,7 @@ test('creates', () => { expect(uid).toBeDefined() expect(client).toMatchInlineSnapshot(` { + "batch": undefined, "call": [Function], "chain": undefined, "createBlockFilter": [Function], @@ -84,6 +85,42 @@ test('creates', () => { `) }) +test('args: batch', () => { + expect( + createPublicClient({ + batch: { + multicall: true, + }, + chain: localhost, + transport: http(), + }).batch, + ).toMatchInlineSnapshot(` + { + "multicall": true, + } + `) + + expect( + createPublicClient({ + batch: { + multicall: { + batchSize: 2048, + wait: 32, + }, + }, + chain: localhost, + transport: http(), + }).batch, + ).toMatchInlineSnapshot(` + { + "multicall": { + "batchSize": 2048, + "wait": 32, + }, + } + `) +}) + describe('transports', () => { test('http', () => { const { uid, ...client } = createPublicClient({ @@ -94,6 +131,7 @@ describe('transports', () => { expect(uid).toBeDefined() expect(client).toMatchInlineSnapshot(` { + "batch": undefined, "call": [Function], "chain": { "id": 1337, @@ -182,6 +220,7 @@ describe('transports', () => { expect(uid).toBeDefined() expect(client).toMatchInlineSnapshot(` { + "batch": undefined, "call": [Function], "chain": { "id": 1337, @@ -270,6 +309,7 @@ describe('transports', () => { expect(uid).toBeDefined() expect(client).toMatchInlineSnapshot(` { + "batch": undefined, "call": [Function], "chain": undefined, "createBlockFilter": [Function], diff --git a/src/clients/createPublicClient.ts b/src/clients/createPublicClient.ts index a74ff75e2d..f783112396 100644 --- a/src/clients/createPublicClient.ts +++ b/src/clients/createPublicClient.ts @@ -6,13 +6,26 @@ import { publicActions } from './decorators/index.js' import type { PublicActions } from './decorators/index.js' import type { Chain, Prettify } from '../types/index.js' +export type MulticallBatchOptions = { + /** The maximum size (in bytes) for each calldata chunk. @default 1_024 */ + batchSize?: number + /** The maximum number of milliseconds to wait before sending a batch. @default 16 */ + wait?: number +} + export type PublicClientConfig< TTransport extends Transport = Transport, TChain extends Chain | undefined = Chain | undefined, > = Pick< ClientConfig, 'chain' | 'key' | 'name' | 'pollingInterval' | 'transport' -> +> & { + /** Flags for batch settings. */ + batch?: { + /** Toggle to enable `eth_call` multicall aggregation. */ + multicall?: boolean | MulticallBatchOptions + } +} export type PublicClient< TTransport extends Transport = Transport, @@ -21,7 +34,8 @@ export type PublicClient< > = Prettify< Client & (TIncludeActions extends true ? PublicActions : unknown) -> +> & + Pick /** * Creates a Public Client with a given [Transport](https://viem.sh/docs/clients/intro) configured for a [Chain](https://viem.sh/docs/clients/chains). @@ -46,6 +60,7 @@ export function createPublicClient< TTransport extends Transport, TChain extends Chain | undefined = undefined, >({ + batch, chain, key = 'public', name = 'Public Client', @@ -65,6 +80,7 @@ export function createPublicClient< type: 'publicClient', }) as PublicClient return { + batch, ...client, ...publicActions(client), } diff --git a/src/constants/contract.ts b/src/constants/contract.ts new file mode 100644 index 0000000000..bfbd02d23d --- /dev/null +++ b/src/constants/contract.ts @@ -0,0 +1 @@ +export const aggregate3Signature = '0x82ad56cb' diff --git a/src/constants/index.ts b/src/constants/index.ts index 1328d71201..4f8ef00b0e 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,5 +1,7 @@ export { multicall3Abi } from './abis.js' +export { aggregate3Signature } from './contract.js' + export { panicReasons, solidityError, solidityPanic } from './solidity.js' export { etherUnits, gweiUnits, weiUnits } from './unit.js' diff --git a/src/errors/chain.test.ts b/src/errors/chain.test.ts index 18137e55fd..6d0f3dbecc 100644 --- a/src/errors/chain.test.ts +++ b/src/errors/chain.test.ts @@ -1,6 +1,10 @@ import { expect, test } from 'vitest' import { mainnet } from '../chains.js' -import { ChainDoesNotSupportContract, InvalidChainIdError } from './chain.js' +import { + ChainDoesNotSupportContract, + ClientChainNotConfiguredError, + InvalidChainIdError, +} from './chain.js' test('ChainDoesNotSupportContract', () => { expect( @@ -45,6 +49,14 @@ test('ChainDoesNotSupportContract', () => { `) }) +test('ClientChainNotConfiguredError', () => { + expect(new ClientChainNotConfiguredError()).toMatchInlineSnapshot(` + [ClientChainNotConfiguredError: No chain was provided to the Client. + + Version: viem@1.0.2] + `) +}) + test('InvalidChainIdError', () => { expect(new InvalidChainIdError({ chainId: -1 })).toMatchInlineSnapshot(` [InvalidChainIdError: Chain ID "-1" is invalid. diff --git a/src/errors/chain.ts b/src/errors/chain.ts index 0d144bdbe3..01df2adc32 100644 --- a/src/errors/chain.ts +++ b/src/errors/chain.ts @@ -67,6 +67,14 @@ export class ChainNotFoundError extends BaseError { } } +export class ClientChainNotConfiguredError extends BaseError { + override name = 'ClientChainNotConfiguredError' + + constructor() { + super('No chain was provided to the Client.') + } +} + export class InvalidChainIdError extends BaseError { override name = 'InvalidChainIdError' diff --git a/src/errors/index.ts b/src/errors/index.ts index 4697523f0a..7a78032b0e 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -37,6 +37,7 @@ export { ChainDoesNotSupportContract, ChainMismatchError, ChainNotFoundError, + ClientChainNotConfiguredError, InvalidChainIdError, } from './chain.js' diff --git a/src/index.test.ts b/src/index.test.ts index f3de11a023..18078f2ebb 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -25,6 +25,7 @@ test('exports actions', () => { "CallExecutionError": [Function], "ChainDisconnectedError": [Function], "ChainDoesNotSupportContract": [Function], + "ClientChainNotConfiguredError": [Function], "ContractFunctionExecutionError": [Function], "ContractFunctionRevertedError": [Function], "ContractFunctionZeroDataError": [Function], diff --git a/src/index.ts b/src/index.ts index 1b5326948f..da9af27ea8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -182,6 +182,7 @@ export { CallExecutionError, ChainDisconnectedError, ChainDoesNotSupportContract, + ClientChainNotConfiguredError, ContractFunctionExecutionError, ContractFunctionRevertedError, ContractFunctionZeroDataError, diff --git a/src/types/eip1193.ts b/src/types/eip1193.ts index c374e2eb99..3f932fc4a4 100644 --- a/src/types/eip1193.ts +++ b/src/types/eip1193.ts @@ -207,7 +207,7 @@ export type PublicRequests = { */ method: 'eth_call' params: [ - request: TransactionRequest, + request: Partial, block: BlockNumber | BlockTag | BlockIdentifier, ] }): Promise diff --git a/src/utils/errors/getContractError.ts b/src/utils/errors/getContractError.ts index d1a811bd92..ea31fcf7f1 100644 --- a/src/utils/errors/getContractError.ts +++ b/src/utils/errors/getContractError.ts @@ -33,11 +33,12 @@ export function getContractError( sender?: Address }, ) { - const { code, data, message } = ( + const { code, data, message, shortMessage } = ( err instanceof RawContractError ? err - : err instanceof CallExecutionError || - err instanceof EstimateGasExecutionError + : !(err.cause && 'data' in (err.cause as BaseError)) && + (err instanceof CallExecutionError || + err instanceof EstimateGasExecutionError) ? ((err.cause as BaseError)?.cause as BaseError)?.cause || {} : err.cause || {} ) as RawContractError @@ -45,12 +46,15 @@ export function getContractError( let cause = err if (err instanceof AbiDecodingZeroDataError) { cause = new ContractFunctionZeroDataError({ functionName }) - } else if (code === EXECUTION_REVERTED_ERROR_CODE && (data || message)) { + } else if ( + code === EXECUTION_REVERTED_ERROR_CODE && + (data || message || shortMessage) + ) { cause = new ContractFunctionRevertedError({ abi, data, functionName, - message, + message: shortMessage ?? message, }) } diff --git a/src/utils/promise/createBatchScheduler.test.ts b/src/utils/promise/createBatchScheduler.test.ts new file mode 100644 index 0000000000..02dfb810a4 --- /dev/null +++ b/src/utils/promise/createBatchScheduler.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test, vi } from 'vitest' +import { createBatchScheduler } from './createBatchScheduler.js' +import { uid } from '../uid.js' +import { wait } from '../wait.js' + +test('default', async () => { + const fn = vi.fn() + const { schedule } = createBatchScheduler({ + id: uid(), + fn: async (args: number[]) => (fn(), args), + }) + + const p = [] + p.push(schedule(1)) + p.push(schedule(2)) + p.push(schedule(3)) + p.push(schedule(4)) + await wait(1) + p.push(schedule(5)) + p.push(schedule(6)) + await wait(1) + p.push(schedule(7)) + + const [x1, x2, x3, x4, x5, x6, x7] = await Promise.all(p) + + expect(x1).toEqual([1, [1, 2, 3, 4]]) + expect(x2).toEqual([2, [1, 2, 3, 4]]) + expect(x3).toEqual([3, [1, 2, 3, 4]]) + expect(x4).toEqual([4, [1, 2, 3, 4]]) + expect(x5).toEqual([5, [5, 6]]) + expect(x6).toEqual([6, [5, 6]]) + expect(x7).toEqual([7, [7]]) + + expect(fn).toBeCalledTimes(3) +}) + +test('args: id', async () => { + const fn1 = vi.fn() + const { schedule: schedule1 } = createBatchScheduler({ + id: uid(), + fn: async (args: number[]) => (fn1(), args), + }) + + const fn2 = vi.fn() + const { schedule: schedule2 } = createBatchScheduler({ + id: uid(), + fn: async (args: number[]) => (fn2(), args), + }) + + const p = [] + p.push(schedule1(1)) + p.push(schedule2(2)) + p.push(schedule1(3)) + p.push(schedule1(4)) + await wait(1) + p.push(schedule2(5)) + p.push(schedule1(6)) + p.push(schedule2(7)) + p.push(schedule2(8)) + await wait(1) + p.push(schedule1(9)) + + const [x1, x2, x3, x4, x5, x6, x7, x8, x9] = await Promise.all(p) + + expect(x1).toEqual([1, [1, 3, 4]]) + expect(x2).toEqual([2, [2]]) + expect(x3).toEqual([3, [1, 3, 4]]) + expect(x4).toEqual([4, [1, 3, 4]]) + expect(x5).toEqual([5, [5, 7, 8]]) + expect(x6).toEqual([6, [6]]) + expect(x7).toEqual([7, [5, 7, 8]]) + expect(x8).toEqual([8, [5, 7, 8]]) + expect(x9).toEqual([9, [9]]) + + expect(fn1).toBeCalledTimes(3) + expect(fn2).toBeCalledTimes(2) +}) + +test('args: wait', async () => { + const { schedule } = createBatchScheduler({ + id: uid(), + wait: 10, + fn: async (args: number[]) => args, + }) + + const p = [] + p.push(schedule(1)) + p.push(schedule(2)) + p.push(schedule(3)) + p.push(schedule(4)) + await wait(1) + p.push(schedule(5)) + p.push(schedule(6)) + await wait(1) + p.push(schedule(7)) + await wait(10) + p.push(schedule(8)) + p.push(schedule(9)) + p.push(schedule(10)) + + const [x1, x2, x3, x4, x5, x6, x7, x8, x9, x10] = await Promise.all(p) + + expect(x1).toEqual([1, [1, 2, 3, 4, 5, 6, 7]]) + expect(x2).toEqual([2, [1, 2, 3, 4, 5, 6, 7]]) + expect(x3).toEqual([3, [1, 2, 3, 4, 5, 6, 7]]) + expect(x4).toEqual([4, [1, 2, 3, 4, 5, 6, 7]]) + expect(x5).toEqual([5, [1, 2, 3, 4, 5, 6, 7]]) + expect(x6).toEqual([6, [1, 2, 3, 4, 5, 6, 7]]) + expect(x7).toEqual([7, [1, 2, 3, 4, 5, 6, 7]]) + expect(x8).toEqual([8, [8, 9, 10]]) + expect(x9).toEqual([9, [8, 9, 10]]) + expect(x10).toEqual([10, [8, 9, 10]]) +}) + +test('args: shouldSplitBatch', async () => { + const fn = vi.fn() + const { schedule } = createBatchScheduler({ + id: uid(), + fn: async (args: number[]) => (fn(), args), + shouldSplitBatch: (args) => args.length > 3, + }) + + const p = [] + p.push(schedule(1)) + p.push(schedule(2)) + p.push(schedule(3)) + p.push(schedule(4)) + p.push(schedule(5)) + p.push(schedule(6)) + p.push(schedule(7)) + p.push(schedule(8)) + p.push(schedule(9)) + p.push(schedule(10)) + await wait(1) + p.push(schedule(11)) + p.push(schedule(12)) + await wait(1) + p.push(schedule(13)) + + const [x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11, x12, x13] = + await Promise.all(p) + + expect(x1).toEqual([1, [1, 2, 3]]) + expect(x2).toEqual([2, [1, 2, 3]]) + expect(x3).toEqual([3, [1, 2, 3]]) + expect(x4).toEqual([4, [4, 5, 6]]) + expect(x5).toEqual([5, [4, 5, 6]]) + expect(x6).toEqual([6, [4, 5, 6]]) + expect(x7).toEqual([7, [7, 8, 9]]) + expect(x8).toEqual([8, [7, 8, 9]]) + expect(x9).toEqual([9, [7, 8, 9]]) + expect(x10).toEqual([10, [10]]) + expect(x11).toEqual([11, [11, 12]]) + expect(x12).toEqual([12, [11, 12]]) + expect(x13).toEqual([13, [13]]) + + expect(fn).toBeCalledTimes(6) +}) + +describe('behavior', () => { + test('complex args', async () => { + const { schedule } = createBatchScheduler({ + id: uid(), + fn: async (args) => args, + }) + + const p = [] + p.push(schedule({ x: 1 })) + p.push(schedule([1, 2])) + p.push(schedule({ x: 4, y: [1, 2] })) + + const [x1, x2, x3] = await Promise.all(p) + + expect(x1).toEqual([{ x: 1 }, [{ x: 1 }, [1, 2], { x: 4, y: [1, 2] }]]) + expect(x2).toEqual([ + [1, 2], + [{ x: 1 }, [1, 2], { x: 4, y: [1, 2] }], + ]) + expect(x3).toEqual([ + { x: 4, y: [1, 2] }, + [{ x: 1 }, [1, 2], { x: 4, y: [1, 2] }], + ]) + }) + + test('complex split batch', async () => { + const fn = vi.fn() + const { schedule } = createBatchScheduler({ + id: uid(), + wait: 16, + fn: async (args: string[]) => (fn(), args), + shouldSplitBatch: (args) => + args.reduce((acc, x) => acc + x.length, 0) > 20, + }) + + const p = [] + p.push(schedule('hello')) + p.push(schedule('world')) + p.push(schedule('this is me')) + p.push(schedule('life should be')) + p.push(schedule('fun for everyone')) + await wait(1) + p.push(schedule('hello world')) + p.push(schedule('come and see')) + p.push(schedule('come')) + p.push(schedule('and')) + await wait(16) + p.push(schedule('see')) + p.push(schedule('smile')) + p.push(schedule('just be yourself')) + p.push(schedule('be happy')) + + const [x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11] = await Promise.all(p) + + expect(x1).toEqual(['hello', ['hello', 'world', 'this is me']]) + expect(x2).toEqual(['world', ['hello', 'world', 'this is me']]) + expect(x3).toEqual(['this is me', ['hello', 'world', 'this is me']]) + expect(x4).toEqual(['life should be', ['life should be']]) + expect(x5).toEqual(['fun for everyone', ['fun for everyone']]) + expect(x6).toEqual(['hello world', ['hello world']]) + expect(x7).toEqual(['come and see', ['come and see', 'come', 'and']]) + expect(x8).toEqual(['come', ['come and see', 'come', 'and']]) + expect(x9).toEqual(['and', ['come and see', 'come', 'and']]) + expect(x10).toEqual(['see', ['see', 'smile']]) + expect(x11).toEqual(['smile', ['see', 'smile']]) + + expect(fn).toBeCalledTimes(8) + }) + + test('throws error', async () => { + const { schedule } = createBatchScheduler({ + id: uid(), + fn: async (args) => { + throw new Error(JSON.stringify(args)) + }, + }) + + await expect(() => + Promise.all([schedule(1), schedule(2), schedule(3), schedule(4)]), + ).rejects.toThrowErrorMatchingInlineSnapshot('"[1,2,3,4]"') + }) +}) diff --git a/src/utils/promise/createBatchScheduler.ts b/src/utils/promise/createBatchScheduler.ts new file mode 100644 index 0000000000..2d0bb422e3 --- /dev/null +++ b/src/utils/promise/createBatchScheduler.ts @@ -0,0 +1,82 @@ +type Resolved = [ + result: TReturnType[number], + results: TReturnType, +] + +type PendingPromise = { + resolve?: (data: Resolved) => void + reject?: (reason?: unknown) => void +} + +type SchedulerItem = { args: unknown; pendingPromise: PendingPromise } + +const schedulerCache = new Map() + +export function createBatchScheduler< + TParameters, + TReturnType extends readonly unknown[], +>({ + fn, + id, + shouldSplitBatch, + wait = 0, +}: { + fn: (args: TParameters[]) => Promise + id: number | string + shouldSplitBatch?: (args: TParameters[]) => boolean + wait?: number +}) { + const exec = async () => { + const scheduler = getScheduler() + flush() + + const args = scheduler.map(({ args }) => args) + + if (args.length === 0) return + + fn(args as TParameters[]) + .then((data) => { + scheduler.forEach(({ pendingPromise }, i) => + pendingPromise.resolve?.([data[i], data]), + ) + }) + .catch((err) => { + scheduler.forEach(({ pendingPromise }) => pendingPromise.reject?.(err)) + }) + } + + const flush = () => schedulerCache.delete(id) + + const getBatchedArgs = () => + getScheduler().map(({ args }) => args) as TParameters[] + + const getScheduler = () => schedulerCache.get(id) || [] + + const setScheduler = (item: SchedulerItem) => + schedulerCache.set(id, [...getScheduler(), item]) + + return { + flush, + async schedule(args: TParameters) { + const pendingPromise: PendingPromise = {} + const promise = new Promise>((resolve, reject) => { + pendingPromise.resolve = resolve + pendingPromise.reject = reject + }) + + const split = shouldSplitBatch?.([...getBatchedArgs(), args]) + + if (split) exec() + + const hasActiveScheduler = getScheduler().length > 0 + if (hasActiveScheduler) { + setScheduler({ args, pendingPromise }) + return promise + } + + setScheduler({ args, pendingPromise }) + setTimeout(exec, wait) + return promise + }, + } +} diff --git a/src/utils/promise/index.test.ts b/src/utils/promise/index.test.ts index edd491f371..789d820eb8 100644 --- a/src/utils/promise/index.test.ts +++ b/src/utils/promise/index.test.ts @@ -5,6 +5,7 @@ import * as utils from './index.js' test('exports utils', () => { expect(utils).toMatchInlineSnapshot(` { + "createBatchScheduler": [Function], "getCache": [Function], "withCache": [Function], "withRetry": [Function], diff --git a/src/utils/promise/index.ts b/src/utils/promise/index.ts index fd1fc0dd8f..79f2e4e199 100644 --- a/src/utils/promise/index.ts +++ b/src/utils/promise/index.ts @@ -1,3 +1,4 @@ +export { createBatchScheduler } from './createBatchScheduler.js' export { getCache, withCache } from './withCache.js' export { withRetry } from './withRetry.js' export { withTimeout } from './withTimeout.js' From fa558f1513e5513e9156761da5c80c11e5354872 Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Tue, 18 Apr 2023 11:10:44 +1000 Subject: [PATCH 2/5] fix: types --- src/clients/createPublicClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/clients/createPublicClient.ts b/src/clients/createPublicClient.ts index f783112396..e3daf8a710 100644 --- a/src/clients/createPublicClient.ts +++ b/src/clients/createPublicClient.ts @@ -33,9 +33,9 @@ export type PublicClient< TIncludeActions extends boolean = true, > = Prettify< Client & + Pick & (TIncludeActions extends true ? PublicActions : unknown) -> & - Pick +> /** * Creates a Public Client with a given [Transport](https://viem.sh/docs/clients/intro) configured for a [Chain](https://viem.sh/docs/clients/chains). From 06d1a448294db38da92c8610de4db7739ac95afe Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Tue, 18 Apr 2023 12:12:47 +1000 Subject: [PATCH 3/5] docs --- .env.example | 2 +- site/docs/clients/public.md | 59 +++++++++++++++++++ src/actions/ens/getEnsName.test.ts | 3 +- src/actions/ens/getEnsResolver.test.ts | 3 +- src/actions/ens/getEnsText.test.ts | 3 +- src/actions/getContract.test.ts | 6 +- .../public/estimateContractGas.test.ts | 3 +- src/actions/public/simulateContract.test.ts | 6 +- src/constants/index.test.ts | 1 + src/errors/contract.ts | 2 +- 10 files changed, 70 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index f260bdc9aa..5f2ecab2cf 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,4 @@ VITE_ANVIL_FORK_URL= VITE_ANVIL_BLOCK_TIME=1 VITE_ANVIL_BLOCK_NUMBER=16280770 VITE_NETWORK_TRANSPORT_MODE=http -VITE_BATCH_MULTICALL=true \ No newline at end of file +VITE_BATCH_MULTICALL=false \ No newline at end of file diff --git a/site/docs/clients/public.md b/site/docs/clients/public.md index 1e589890b6..624bba2a63 100644 --- a/site/docs/clients/public.md +++ b/site/docs/clients/public.md @@ -72,6 +72,65 @@ const client = createPublicClient({ }) ``` +### batch (optional) + +Flags for batch settings. + +### batch.multicall (optional) + +- **Type:** `boolean | MulticallBatchOptions` +- **Default:** `false` + +Toggle to enable `eth_call` multicall aggregation. + +```ts +const client = createPublicClient({ + batch: { + multicall: true, // [!code focus] + }, + chain: mainnet, + transport: http(), +}) +``` + +### batch.multicall.batchSize (optional) + +- **Type:** `number` +- **Default:** `1_024` + +The maximum size (in bytes) for each multicall (`aggregate3`) calldata chunk. + +```ts +const client = createPublicClient({ + batch: { + multicall: { + batchSize: 512, // [!code focus] + }, + }, + chain: mainnet, + transport: http(), +}) +``` + +### batch.multicall.wait (optional) + +- **Type:** `number` +- **Default:** `16` + +The maximum number of milliseconds to wait before sending a batch. + +```ts +const client = createPublicClient({ + batch: { + multicall: { + wait: 16, // [!code focus] + }, + }, + chain: mainnet, + transport: http(), +}) +``` + ### key (optional) - **Type:** `string` diff --git a/src/actions/ens/getEnsName.test.ts b/src/actions/ens/getEnsName.test.ts index 4fea6a788b..8c3cc4a30a 100644 --- a/src/actions/ens/getEnsName.test.ts +++ b/src/actions/ens/getEnsName.test.ts @@ -102,8 +102,7 @@ test('invalid universal resolver address', async () => { universalResolverAddress: '0xecb504d39723b0be0e3a9aa33d646642d1051ee1', }), ).rejects.toThrowErrorMatchingInlineSnapshot(` - "The contract function \\"reverse\\" reverted with the following reason: - execution reverted + "The contract function \\"reverse\\" reverted. Contract Call: address: 0x0000000000000000000000000000000000000000 diff --git a/src/actions/ens/getEnsResolver.test.ts b/src/actions/ens/getEnsResolver.test.ts index 2456b90cb9..1f013e5683 100644 --- a/src/actions/ens/getEnsResolver.test.ts +++ b/src/actions/ens/getEnsResolver.test.ts @@ -100,8 +100,7 @@ test('invalid universal resolver address', async () => { universalResolverAddress: '0xecb504d39723b0be0e3a9aa33d646642d1051ee1', }), ).rejects.toThrowErrorMatchingInlineSnapshot(` - "The contract function \\"findResolver\\" reverted with the following reason: - execution reverted + "The contract function \\"findResolver\\" reverted. Contract Call: address: 0x0000000000000000000000000000000000000000 diff --git a/src/actions/ens/getEnsText.test.ts b/src/actions/ens/getEnsText.test.ts index bf7170ca59..d538f6cfbd 100644 --- a/src/actions/ens/getEnsText.test.ts +++ b/src/actions/ens/getEnsText.test.ts @@ -118,8 +118,7 @@ test('invalid universal resolver address', async () => { universalResolverAddress: '0xecb504d39723b0be0e3a9aa33d646642d1051ee1', }), ).rejects.toThrowErrorMatchingInlineSnapshot(` - "The contract function \\"resolve\\" reverted with the following reason: - execution reverted + "The contract function \\"resolve\\" reverted. Contract Call: address: 0x0000000000000000000000000000000000000000 diff --git a/src/actions/getContract.test.ts b/src/actions/getContract.test.ts index 41d01b6137..176c83e41f 100644 --- a/src/actions/getContract.test.ts +++ b/src/actions/getContract.test.ts @@ -170,8 +170,7 @@ test('js reserved keywords/prototype methods as abi item names', async () => { await expect( contractNoIndexedEventArgs.read.constructor(), ).rejects.toThrowErrorMatchingInlineSnapshot(` - "The contract function \\"constructor\\" reverted with the following reason: - execution reverted + "The contract function \\"constructor\\" reverted. Contract Call: address: 0x0000000000000000000000000000000000000000 @@ -183,8 +182,7 @@ test('js reserved keywords/prototype methods as abi item names', async () => { await expect( contractNoIndexedEventArgs.read.function(['function']), ).rejects.toThrowErrorMatchingInlineSnapshot(` - "The contract function \\"function\\" reverted with the following reason: - execution reverted + "The contract function \\"function\\" reverted. Contract Call: address: 0x0000000000000000000000000000000000000000 diff --git a/src/actions/public/estimateContractGas.test.ts b/src/actions/public/estimateContractGas.test.ts index d5626f6056..956459038b 100644 --- a/src/actions/public/estimateContractGas.test.ts +++ b/src/actions/public/estimateContractGas.test.ts @@ -326,8 +326,7 @@ describe('contract errors', () => { account: accounts[0].address, }), ).rejects.toMatchInlineSnapshot(` - [ContractFunctionExecutionError: The contract function "requireWrite" reverted with the following reason: - execution reverted + [ContractFunctionExecutionError: The contract function "requireWrite" reverted. Contract Call: address: 0x0000000000000000000000000000000000000000 diff --git a/src/actions/public/simulateContract.test.ts b/src/actions/public/simulateContract.test.ts index 7970cbf740..0024d47f93 100644 --- a/src/actions/public/simulateContract.test.ts +++ b/src/actions/public/simulateContract.test.ts @@ -350,8 +350,7 @@ describe('contract errors', () => { account: accounts[0].address, }), ).rejects.toMatchInlineSnapshot(` - [ContractFunctionExecutionError: The contract function "requireWrite" reverted with the following reason: - execution reverted + [ContractFunctionExecutionError: The contract function "requireWrite" reverted. Contract Call: address: 0x0000000000000000000000000000000000000000 @@ -565,8 +564,7 @@ describe('node errors', () => { value: parseEther('100000'), }), ).rejects.toThrowErrorMatchingInlineSnapshot(` - "The contract function \\"mint\\" reverted with the following reason: - execution reverted + "The contract function \\"mint\\" reverted. Contract Call: address: 0x0000000000000000000000000000000000000000 diff --git a/src/constants/index.test.ts b/src/constants/index.test.ts index 887b4f6a5c..8203c4b81b 100644 --- a/src/constants/index.test.ts +++ b/src/constants/index.test.ts @@ -6,6 +6,7 @@ test('exports index', () => { expect(Object.keys(index)).toMatchInlineSnapshot(` [ "multicall3Abi", + "aggregate3Signature", "panicReasons", "solidityError", "solidityPanic", diff --git a/src/errors/contract.ts b/src/errors/contract.ts index df498c7ec9..a86d569acf 100644 --- a/src/errors/contract.ts +++ b/src/errors/contract.ts @@ -194,7 +194,7 @@ export class ContractFunctionRevertedError extends BaseError { } else if (message) reason = message super( - reason + reason && reason !== 'execution reverted' ? [ `The contract function "${functionName}" reverted with the following reason:`, reason, From 2cbd51665725713f06c1bcda722b05505bfbbced Mon Sep 17 00:00:00 2001 From: jxom Date: Tue, 18 Apr 2023 15:08:00 +1000 Subject: [PATCH 4/5] chore: changeset --- .changeset/old-ravens-occur.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/old-ravens-occur.md diff --git a/.changeset/old-ravens-occur.md b/.changeset/old-ravens-occur.md new file mode 100644 index 0000000000..3c2316ed3a --- /dev/null +++ b/.changeset/old-ravens-occur.md @@ -0,0 +1,5 @@ +--- +"viem": patch +--- + +Added support for `eth_call` batch aggregation via multicall `aggregate3`. From b6a80fb736d6b16cea6d2043428719157fad471c Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Thu, 20 Apr 2023 07:03:48 +1000 Subject: [PATCH 5/5] docs --- site/docs/clients/public.md | 50 +++++++++++++++++++++++++- src/actions/public/call.test.ts | 10 +++--- src/actions/public/call.ts | 2 +- src/actions/public/simulateContract.ts | 2 +- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/site/docs/clients/public.md b/site/docs/clients/public.md index 624bba2a63..058a6d30ef 100644 --- a/site/docs/clients/public.md +++ b/site/docs/clients/public.md @@ -44,6 +44,54 @@ Then you can consume [Public Actions](/docs/actions/public/introduction): const blockNumber = await client.getBlockNumber() // [!code focus:10] ``` +## Optimization + +The Public Client also supports [`eth_call` Aggregation](#multicall) and JSON-RPC Batching (soon) for improved performance. + +### `eth_call` Aggregation (via Multicall) + +The Public Client supports the aggregation of `eth_call` requests into a single multicall (`aggregate3`) request. + +This means for every Action that utilizes an `eth_call` request (ie. `readContract`), the Public Client will batch the requests (over a timed period) and send it to the RPC Provider in a single multicall request. This can dramatically improve network performance, and decrease the amount of [Compute Units (CU)](https://docs.alchemy.com/reference/compute-units) used by RPC Providers like Alchemy, Infura, etc. + +The Public Client schedules the aggregation of `eth_call` requests over a given time period. By default, it executes the batch request at the end of the current [JavaScript message queue](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop#queue) (a [zero delay](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop#zero_delays)), however, consumers can specify a custom `wait` period (in ms). + +You can enable `eth_call` aggregation by setting the `batch.multicall` flag to `true`: + +```ts +const client = createPublicClient({ + batch: { + multicall: true, // [!code focus] + }, + chain: mainnet, + transport: http(), +}) +``` + +> You can also [customize the `multicall` options](http://localhost:5173/docs/clients/public.html#batch-multicall-batchsize-optional). + +Now, when you start to utilize `readContract` Actions, the Public Client will batch and send over those requests at the end of the message queue (or custom time period) in a single `eth_call` multicall request: + +```ts +const contract = getContract({ address, abi }) + +// The below will send a single request to the RPC Provider. +const [name, totalSupply, symbol, tokenUri, balance] = await Promise.all([ + contract.read.name(), + contract.read.totalSupply(), + contract.read.symbol(), + contract.read.tokenURI([420n]), + contract.read.balanceOf([address]), +]) +``` + +> Read more on [Contract Instances](http://localhost:5173/docs/contract/getContract.html). + + +### JSON-RPC Batching + +The Public Client will support [JSON-RPC Batching](https://www.jsonrpc.org/specification#batch). This is coming soon. + ## Parameters ### transport @@ -115,7 +163,7 @@ const client = createPublicClient({ ### batch.multicall.wait (optional) - **Type:** `number` -- **Default:** `16` +- **Default:** `0` ([zero delay](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop#zero_delays)) The maximum number of milliseconds to wait before sending a batch. diff --git a/src/actions/public/call.test.ts b/src/actions/public/call.test.ts index 421405d175..670a8fa416 100644 --- a/src/actions/public/call.test.ts +++ b/src/actions/public/call.test.ts @@ -288,7 +288,7 @@ describe('batch call', () => { const results = await Promise.all(p) - expect(spy).toBeCalledTimes(2) + expect(spy).toBeCalledTimes(4) expect(results).toMatchInlineSnapshot(` [ { @@ -370,7 +370,7 @@ describe('batch call', () => { const results = await Promise.all(p) - expect(spy).toBeCalledTimes(4) + expect(spy).toBeCalledTimes(6) expect(results).toMatchInlineSnapshot(` [ { @@ -481,7 +481,7 @@ describe('batch call', () => { publicClient.batch = { multicall: { batchSize: 1024, - wait: 0, + wait: 16, }, } @@ -530,7 +530,7 @@ describe('batch call', () => { const results = await Promise.all(p) - expect(spy).toBeCalledTimes(4) + expect(spy).toBeCalledTimes(2) expect(results).toMatchInlineSnapshot(` [ { @@ -678,7 +678,7 @@ describe('batch call', () => { }), ) } - await wait(50) + await wait(1) for (let i = 0; i < batch2Length; i++) { p.push( call(client, { diff --git a/src/actions/public/call.ts b/src/actions/public/call.ts index fb6f1b3c10..3e7a5c977e 100644 --- a/src/actions/public/call.ts +++ b/src/actions/public/call.ts @@ -197,7 +197,7 @@ async function scheduleMulticall( client: PublicClient, args: ScheduleMulticallParameters, ) { - const { batchSize = 1024, wait = 16 } = + const { batchSize = 1024, wait = 0 } = typeof client.batch?.multicall === 'object' ? client.batch?.multicall : {} const { blockNumber, diff --git a/src/actions/public/simulateContract.ts b/src/actions/public/simulateContract.ts index 8892b60e50..44ecde5a71 100644 --- a/src/actions/public/simulateContract.ts +++ b/src/actions/public/simulateContract.ts @@ -32,7 +32,7 @@ export type SimulateContractParameters< } & ContractFunctionConfig & Omit< CallParameters, - 'to' | 'data' | 'value' + 'batch' | 'to' | 'data' | 'value' > & GetValue['value']>