diff --git a/.changeset/tricky-oranges-pump.md b/.changeset/tricky-oranges-pump.md new file mode 100644 index 0000000000..4cd982b9c3 --- /dev/null +++ b/.changeset/tricky-oranges-pump.md @@ -0,0 +1,10 @@ +--- +"@latticexyz/common": major +--- + +Add utils for using viem with MUD + +- `createContract` is a wrapper around [viem's `getContract`](https://viem.sh/docs/contract/getContract.html) but with better nonce handling for faster executing of transactions. It has the same arguments and return type as `getContract`. +- `createNonceManager` helps track local nonces, used by `createContract`. + +Also renames `mudTransportObserver` to `transportObserver`. diff --git a/packages/common/package.json b/packages/common/package.json index 244e1f6c36..55a0725414 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -61,6 +61,8 @@ "chalk": "^5.2.0", "debug": "^4.3.4", "execa": "^7.0.0", + "p-queue": "^7.3.4", + "p-retry": "^5.1.2", "prettier": "^2.8.4", "prettier-plugin-solidity": "^1.1.2", "viem": "1.3.1" diff --git a/packages/common/src/createContract.ts b/packages/common/src/createContract.ts new file mode 100644 index 0000000000..336fb75aef --- /dev/null +++ b/packages/common/src/createContract.ts @@ -0,0 +1,125 @@ +import { + Abi, + Account, + Address, + Chain, + GetContractParameters, + GetContractReturnType, + Hex, + PublicClient, + SimulateContractParameters, + Transport, + WalletClient, + WriteContractParameters, + getContract, +} from "viem"; +import pQueue from "p-queue"; +import pRetry from "p-retry"; +import { createNonceManager } from "./createNonceManager"; +import { debug as parentDebug } from "./debug"; + +const debug = parentDebug.extend("createContract"); + +// copied from viem because it isn't exported +// TODO: import from viem? +function getFunctionParameters(values: [args?: readonly unknown[], options?: object]): { + args: readonly unknown[]; + options: object; +} { + const hasArgs = values.length && Array.isArray(values[0]); + const args = hasArgs ? values[0]! : []; + const options = (hasArgs ? values[1] : values[0]) ?? {}; + return { args, options }; +} + +export function createContract< + TTransport extends Transport, + TAddress extends Address, + TAbi extends Abi, + TChain extends Chain, + TAccount extends Account, + TPublicClient extends PublicClient, + TWalletClient extends WalletClient +>({ + abi, + address, + publicClient, + walletClient, +}: Required< + GetContractParameters +>): GetContractReturnType { + const contract = getContract({ + abi, + address, + publicClient, + walletClient, + }) as unknown as GetContractReturnType; + + if (contract.write) { + const nonceManager = createNonceManager({ + publicClient: publicClient as PublicClient, + address: walletClient.account.address, + }); + + // Concurrency of one means transactions will be queued and inserted into the mem pool synchronously and in order. + // Although increasing this will allow for more parallel requests/transactions and nonce errors will get automatically retried, + // we can't guarantee local nonce accurancy due to needing async operations (simulate) before incrementing the nonce. + const queue = new pQueue({ concurrency: 1 }); + + // Replace write calls with our own proxy. Implemented ~the same as viem, but adds better handling of nonces (via queue + retries). + contract.write = new Proxy( + {}, + { + get(_, functionName: string): GetContractReturnType["write"][string] { + return async (...parameters) => { + const { args, options } = getFunctionParameters(parameters as any); + + async function write(): Promise { + if (!nonceManager.hasNonce()) { + await nonceManager.resetNonce(); + } + + debug("simulating write", functionName, args, options); + const { request } = await publicClient.simulateContract({ + account: walletClient.account, + address, + abi, + functionName, + args, + ...options, + } as unknown as SimulateContractParameters); + + const nonce = nonceManager.nextNonce(); + debug("calling write function with nonce", nonce, request); + const result = await walletClient.writeContract({ + nonce, + ...request, + } as unknown as WriteContractParameters); + + return result; + } + + return await queue.add( + () => + pRetry(write, { + retries: 3, + onFailedAttempt: async (error) => { + // On nonce errors, reset the nonce and retry + if (nonceManager.shouldResetNonce(error)) { + debug("got nonce error, retrying", error); + await nonceManager.resetNonce(); + return; + } + throw error; + }, + }), + { throwOnTimeout: true } + ); + }; + }, + } + ); + } + + return contract as unknown as GetContractReturnType; +} diff --git a/packages/common/src/createNonceManager.ts b/packages/common/src/createNonceManager.ts new file mode 100644 index 0000000000..5a38611c0a --- /dev/null +++ b/packages/common/src/createNonceManager.ts @@ -0,0 +1,67 @@ +import { BlockTag, Hex, PublicClient } from "viem"; +import { debug as parentDebug } from "./debug"; + +const debug = parentDebug.extend("createNonceManager"); + +type CreateNonceManagerOptions = { + publicClient: PublicClient; + address: Hex; + blockTag?: BlockTag; +}; + +type CreateNonceManagerResult = { + hasNonce: () => boolean; + nextNonce: () => number; + resetNonce: () => Promise; + shouldResetNonce: (error: unknown) => boolean; +}; + +export function createNonceManager({ + publicClient, + address, + blockTag, +}: CreateNonceManagerOptions): CreateNonceManagerResult { + const nonceRef = { current: -1 }; + const channel = + typeof BroadcastChannel !== "undefined" + ? // TODO: fetch chain ID or require it via types? + new BroadcastChannel(`mud:createNonceManager:${publicClient.chain?.id}:${address}`) + : null; + + if (channel) { + channel.addEventListener("message", (event) => { + const nonce = JSON.parse(event.data); + debug("got nonce from broadcast channel", nonce); + nonceRef.current = nonce; + }); + } + + function hasNonce(): boolean { + return nonceRef.current >= 0; + } + + function nextNonce(): number { + if (!hasNonce()) throw new Error("call resetNonce before using nextNonce"); + const nonce = nonceRef.current++; + channel?.postMessage(JSON.stringify(nonceRef.current)); + return nonce; + } + + async function resetNonce(): Promise { + const nonce = await publicClient.getTransactionCount({ address, blockTag }); + nonceRef.current = nonce; + channel?.postMessage(JSON.stringify(nonceRef.current)); + debug("reset nonce to", nonceRef.current); + } + + function shouldResetNonce(error: unknown): boolean { + return /already known|nonce too low/.test(String(error)); + } + + return { + hasNonce, + nextNonce, + resetNonce, + shouldResetNonce, + }; +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 1c5261f42a..878de7788b 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,4 +1,6 @@ export * from "./createBurnerAccount"; +export * from "./createContract"; +export * from "./createNonceManager"; export * from "./hexToTableId"; -export * from "./mudTransportObserver"; export * from "./tableIdToHex"; +export * from "./transportObserver"; diff --git a/packages/common/src/mudTransportObserver.ts b/packages/common/src/transportObserver.ts similarity index 79% rename from packages/common/src/mudTransportObserver.ts rename to packages/common/src/transportObserver.ts index 74fbf6b188..913bfa11f7 100644 --- a/packages/common/src/mudTransportObserver.ts +++ b/packages/common/src/transportObserver.ts @@ -1,9 +1,9 @@ import { Hex, Transport, keccak256 } from "viem"; import { debug as parentDebug } from "./debug"; -const debug = parentDebug.extend("mudTransportObserver"); +const debug = parentDebug.extend("transportObserver"); -export function mudTransportObserver(transport: TTransport): TTransport { +export function transportObserver(transport: TTransport): TTransport { return ((opts) => { const result = transport(opts); const request: typeof result.request = async (req) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27c213641c..f9dbe168b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,6 +235,12 @@ importers: execa: specifier: ^7.0.0 version: 7.0.0 + p-queue: + specifier: ^7.3.4 + version: 7.3.4 + p-retry: + specifier: ^5.1.2 + version: 5.1.2 prettier: specifier: ^2.8.4 version: 2.8.4 @@ -2288,7 +2294,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) espree: 9.5.1 globals: 13.20.0 ignore: 5.2.4 @@ -2657,7 +2663,7 @@ packages: engines: {node: '>=10.10.0'} dependencies: '@humanwhocodes/object-schema': 1.2.1 - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -3563,6 +3569,10 @@ packages: csstype: 3.1.2 dev: true + /@types/retry@0.12.1: + resolution: {integrity: sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g==} + dev: false + /@types/scheduler@0.16.3: resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} dev: true @@ -3644,7 +3654,7 @@ packages: '@typescript-eslint/scope-manager': 5.46.1 '@typescript-eslint/type-utils': 5.46.1(eslint@8.29.0)(typescript@5.1.6) '@typescript-eslint/utils': 5.46.1(eslint@8.29.0)(typescript@5.1.6) - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) eslint: 8.29.0 ignore: 5.2.4 natural-compare-lite: 1.4.0 @@ -3669,7 +3679,7 @@ packages: '@typescript-eslint/scope-manager': 5.46.1 '@typescript-eslint/types': 5.46.1 '@typescript-eslint/typescript-estree': 5.46.1(typescript@5.1.6) - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) eslint: 8.29.0 typescript: 5.1.6 transitivePeerDependencies: @@ -3696,7 +3706,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.46.1(typescript@5.1.6) '@typescript-eslint/utils': 5.46.1(eslint@8.29.0)(typescript@5.1.6) - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) eslint: 8.29.0 tsutils: 3.21.0(typescript@5.1.6) typescript: 5.1.6 @@ -3720,7 +3730,7 @@ packages: dependencies: '@typescript-eslint/types': 5.46.1 '@typescript-eslint/visitor-keys': 5.46.1 - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.0 @@ -5100,18 +5110,6 @@ packages: time-zone: 1.0.0 dev: true - /debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.1.2 - dev: true - /debug@4.3.4(supports-color@8.1.1): resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -5791,7 +5789,7 @@ packages: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.0 @@ -8053,7 +8051,7 @@ packages: chalk: 3.0.0 commander: 4.1.1 cosmiconfig: 6.0.0 - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) dedent: 0.7.0 execa: 3.4.0 listr: 0.14.3 @@ -8978,6 +8976,27 @@ packages: aggregate-error: 3.1.0 dev: true + /p-queue@7.3.4: + resolution: {integrity: sha512-esox8CWt0j9EZECFvkFl2WNPat8LN4t7WWeXq73D9ha0V96qPRufApZi4ZhPwXAln1uVVal429HVVKPa2X0yQg==} + engines: {node: '>=12'} + dependencies: + eventemitter3: 4.0.7 + p-timeout: 5.1.0 + dev: false + + /p-retry@5.1.2: + resolution: {integrity: sha512-couX95waDu98NfNZV+i/iLt+fdVxmI7CbrrdC2uDWfPdUAApyxT4wmDlyOtR5KtTDmkDO0zDScDjDou9YHhd9g==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + '@types/retry': 0.12.1 + retry: 0.13.1 + dev: false + + /p-timeout@5.1.0: + resolution: {integrity: sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==} + engines: {node: '>=12'} + dev: false + /p-try@1.0.0: resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} engines: {node: '>=4'} @@ -9721,6 +9740,11 @@ packages: signal-exit: 3.0.7 dev: false + /retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + dev: false + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}