diff --git a/.gitignore b/.gitignore index b399266..31a37cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .eslintcache .nyc_output +.vscode coverage dist lerna-debug.log diff --git a/package-lock.json b/package-lock.json index 59c0e8e..898258f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,12 @@ "sinon": "17.0.1" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.0.tgz", + "integrity": "sha512-nA9XHtlAkYfJxY7bce8DcN7eKxWWCWkU+1GR9d+U6MbNpfwQp8TI7vqOsBsMcHoT4mBu2kypKoSKnghEzOOq5Q==", + "dev": true + }, "node_modules/@babel/code-frame": { "version": "7.24.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", @@ -618,6 +624,30 @@ "glob": "7.1.7" } }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dev": true, + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "dev": true, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1206,6 +1236,42 @@ "integrity": "sha512-hw437iINopmQuxWPSUEvqE56NCPsiU8N4AYtfHmJFckclktzK9YQJieD3XkDCDH4OjL+C7zgPUh73R/nrcHrqw==", "dev": true }, + "node_modules/@scure/base": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.6.tgz", + "integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==", + "dev": true, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.2.tgz", + "integrity": "sha512-N1ZhksgwD3OBlwTv3R6KFEcPojl/W4ElJOeCZdi+vuI5QmTFwLq3OFf2zd2ROpKvxFdgZ6hUpb0dx9bVNEwYCA==", + "dev": true, + "dependencies": { + "@noble/curves": "~1.2.0", + "@noble/hashes": "~1.3.2", + "@scure/base": "~1.1.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "dev": true, + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sigstore/bundle": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-1.1.0.tgz", @@ -1974,6 +2040,27 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "node_modules/abitype": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.0.tgz", + "integrity": "sha512-NMeMah//6bJ56H5XRj8QCV4AwuW6hB6zqz2LnhhLdcWVQOsXki6/Pn3APeqxCma62nXIcmZWdu1DlHWS74umVQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", @@ -6290,6 +6377,21 @@ "node": ">=0.10.0" } }, + "node_modules/isows": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.4.tgz", + "integrity": "sha512-hEzjY+x9u9hPmBom9IIAqdJCwNLax+xrPb51vEPpERoFlIxgmZcHzsT5jKG06nvInKOBGvReAVz80Umed5CczQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wagmi-dev" + } + ], + "peerDependencies": { + "ws": "*" + } + }, "node_modules/iterator.prototype": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", @@ -11922,6 +12024,36 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/viem": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.13.2.tgz", + "integrity": "sha512-9n64fjWL34Q8MQ0EksI77/okE8tiS9bQlGb11/JMGoaEE+MYJgG8yDAWX2srqp7MsBK7/ahoAWJZdMwgcFD65g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "dependencies": { + "@adraffy/ens-normalize": "1.10.0", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@scure/bip32": "1.3.2", + "@scure/bip39": "1.2.1", + "abitype": "1.0.0", + "isows": "1.0.4", + "ws": "8.13.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -12207,6 +12339,27 @@ "node": ">=6" } }, + "node_modules/ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -12358,7 +12511,7 @@ } }, "packages/eth-rpc-cache": { - "version": "1.0.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "debug": "4.3.4", @@ -12369,7 +12522,8 @@ "@types/debug": "4.1.12", "@types/json-stable-stringify": "1.0.36", "ts-node": "10.9.2", - "typescript": "5.4.5" + "typescript": "5.4.5", + "viem": "2.13.2" }, "engines": { "node": ">=16" diff --git a/packages/eth-rpc-cache/package.json b/packages/eth-rpc-cache/package.json index 28038a0..3ebeac3 100644 --- a/packages/eth-rpc-cache/package.json +++ b/packages/eth-rpc-cache/package.json @@ -1,6 +1,6 @@ { "name": "eth-rpc-cache", - "version": "1.0.0", + "version": "2.0.0", "description": "A simple cache for Ethereum RPC requests extensible with different caching strategies", "keywords": [ "cache", @@ -36,7 +36,8 @@ "@types/debug": "4.1.12", "@types/json-stable-stringify": "1.0.36", "ts-node": "10.9.2", - "typescript": "5.4.5" + "typescript": "5.4.5", + "viem": "2.13.2" }, "type": "module", "types": "dist/index.d.ts" diff --git a/packages/eth-rpc-cache/src/index.ts b/packages/eth-rpc-cache/src/index.ts index 64a482a..11c3551 100644 --- a/packages/eth-rpc-cache/src/index.ts +++ b/packages/eth-rpc-cache/src/index.ts @@ -1,59 +1,3 @@ -import debugConstructor from 'debug' - -import { errors } from './error' -import { perBlockStrategy } from './strategies/per-block' -import { permanentStrategy } from './strategies/permanent' -import { type JsonRpcCallFn, type Strategy } from './types' -import { clone } from './utils/clone' - -const debug = debugConstructor('eth-rpc-cache') - -type Options = { - allowOthers?: boolean - cache?: Map - strategies?: Strategy[] -} - -export const createEthRpcCache = function ( - rpc: JsonRpcCallFn, - options: Options = {} -): JsonRpcCallFn { - debug('Creating EVM RPC cache') - - const { - allowOthers = true, - cache = new Map(), - strategies = [perBlockStrategy, permanentStrategy] - } = options - - const cachedRpcByMethod: Record> = {} - strategies.forEach(function ({ getRpc, methods, name }) { - debug('Using strategy "%s"', name) - methods.forEach(function (method) { - // @ts-expect-error allow for options that can be dynamically forwarded to the strategy - cachedRpcByMethod[method] = getRpc(rpc, cache, options[name]) - }) - }) - - // Return the cached `rpc` function. - // - // If an strategy defined an RPC function for the incoming method, use that. - // Otherwise call the method directly if allowed or return proper errors. - // - // To prevent user code to mutate the cached results, the cached RPC functions - // will always return a clone of the result and not the result object itself. - return function (method, params) { - const cachedRpc = cachedRpcByMethod[method] - - try { - return cachedRpc - ? cachedRpc(method, params).then(clone) - : allowOthers - ? rpc(method, params) - : Promise.reject(errors.methodNotFound()) - } catch (err) { - // @ts-expect-error error is typed as unknown by default - return Promise.reject(errors.internalServerError(err)) - } - } -} +export { createEthRpcCache } from './rpc' +export { perBlockStrategy } from './strategies/per-block' +export { permanentStrategy } from './strategies/permanent' diff --git a/packages/eth-rpc-cache/src/rpc.ts b/packages/eth-rpc-cache/src/rpc.ts new file mode 100644 index 0000000..88098b1 --- /dev/null +++ b/packages/eth-rpc-cache/src/rpc.ts @@ -0,0 +1,79 @@ +import debugConstructor from 'debug' +import pMemoize from 'promise-mem' + +import { errors } from './error' +import { perBlockStrategy } from './strategies/per-block' +import { permanentStrategy } from './strategies/permanent' +import { type JsonRpcCallFn, type Strategy } from './types' +import { getKey } from './utils/cache-key' +import { clone } from './utils/clone' + +const debug = debugConstructor('eth-rpc-cache') + +type Options = { + allowOthers?: boolean + cache?: Map + strategies?: Strategy[] +} + +export const createEthRpcCache = function ( + rpc: JsonRpcCallFn, + options: Options = {} +): JsonRpcCallFn { + debug('Creating EVM RPC cache') + + const { + allowOthers = true, + cache = new Map(), + strategies = [perBlockStrategy, permanentStrategy] + } = options + + // Each strategy resolves to a cache if it has a maxAge defined. + // Index all caches into the object by strategy name + const cachesByStrategy = strategies + .filter(({ maxAge }) => maxAge !== undefined) + .map(({ name, maxAge }) => ({ + [name]: pMemoize(rpc, { + cache, + maxAge, + resolver: getKey, + ...options + }) + })) + .reduce((acc, curr) => ({ ...acc, ...curr }), {}) + + // This object indexed by method holds a function that returns which strategy (and cache) + // should be used. By default, each strategy resolves to use its own cache, but some strategies + // may resolve to other strategies' caches, depending on the method + const strategyResolver = strategies + .flatMap(({ methods, name, resolver = () => name }) => + methods.map(method => ({ + [method]: resolver + })) + ) + .reduce((acc, curr) => ({ ...acc, ...curr }), {}) + + // Return the cached `rpc` function. + // + // If an strategy defined an RPC function for the incoming method, use that. + // Otherwise call the method directly if allowed or return proper errors. + // + // To prevent user code to mutate the cached results, the cached RPC functions + // will always return a clone of the result and not the result object itself. + return function (method, params) { + try { + const strategyName = strategyResolver[method]?.(method, params) + if (strategyName) { + return cachesByStrategy[strategyName](method, params).then(clone) + } + if (allowOthers) { + // not configured to be cached, call the method directly + return rpc(method, params) + } + return Promise.reject(errors.methodNotFound()) + } catch (err) { + // @ts-expect-error error is typed as unknown by default + return Promise.reject(errors.internalServerError(err)) + } + } +} diff --git a/packages/eth-rpc-cache/src/strategies/per-block.ts b/packages/eth-rpc-cache/src/strategies/per-block.ts index 789b669..1e22736 100644 --- a/packages/eth-rpc-cache/src/strategies/per-block.ts +++ b/packages/eth-rpc-cache/src/strategies/per-block.ts @@ -1,9 +1,4 @@ -import pMemoize from 'promise-mem' - -import { type JsonRpcCallFn, type Strategy } from '../types' -import { getKey } from '../utils/cache-key' - -const name = 'perBlock' +import { type Strategy } from '../types' // These methods could be permanently cached if executed i.e. on an old block. // For newer blocks, the results could change in the case of a [deep] reorg. @@ -35,20 +30,8 @@ const perBlockMethods = [ const methods = [...mayBeSafeMethods, ...perBlockMethods] -const getRpc = ( - rpc: JsonRpcCallFn, - cache: Map, - options = {} -) => - pMemoize(rpc, { - cache, - maxAge: 6000, // Half block time: ~6 sec. - resolver: (method: string, params: unknown[]) => getKey(method, params), - ...options - }) - export const perBlockStrategy: Strategy = { - getRpc, + maxAge: 6000, // Half block time: ~6 sec. methods, - name + name: 'block' } diff --git a/packages/eth-rpc-cache/src/strategies/permanent.ts b/packages/eth-rpc-cache/src/strategies/permanent.ts index 160e95d..7e87998 100644 --- a/packages/eth-rpc-cache/src/strategies/permanent.ts +++ b/packages/eth-rpc-cache/src/strategies/permanent.ts @@ -1,9 +1,4 @@ -import pMemoize from 'promise-mem' - -import { type JsonRpcCallFn, type Strategy } from '../types' -import { getKey } from '../utils/cache-key' - -const name = 'permanent' +import { type Strategy } from '../types' // These methods can be safely cached once the result is obtained. const methods = [ @@ -20,19 +15,8 @@ const methods = [ 'web3_sha3' // This one could be calculated here instead of calling the node. ] -const getRpc = ( - rpc: JsonRpcCallFn, - cache: Map, - options = {} -) => - pMemoize(rpc, { - cache, - resolver: (method: string, params: unknown[]) => getKey(method, params), - ...options - }) - export const permanentStrategy: Strategy = { - getRpc, + maxAge: Infinity, methods, - name + name: 'permanent' } diff --git a/packages/eth-rpc-cache/src/types.ts b/packages/eth-rpc-cache/src/types.ts index 6b0a256..3ee3117 100644 --- a/packages/eth-rpc-cache/src/types.ts +++ b/packages/eth-rpc-cache/src/types.ts @@ -10,11 +10,9 @@ export type JsonRpcCallFn = ( ) => Promise export type Strategy = { - getRpc: ( - rpc: JsonRpcCallFn, - cache: Map, - options?: Record - ) => JsonRpcCallFn + maxAge?: number methods: string[] name: string + // For a given rpc call, return the strategy name which will be used to cache the result + resolver?: (method: string, params: unknown[]) => string | undefined } diff --git a/packages/eth-rpc-cache/test/index.spec.js b/packages/eth-rpc-cache/test/index.spec.js index 966f378..57ac7ad 100644 --- a/packages/eth-rpc-cache/test/index.spec.js +++ b/packages/eth-rpc-cache/test/index.spec.js @@ -1,11 +1,14 @@ import * as chai from 'chai' import chaiAsPromised from 'chai-as-promised' import sinon from 'sinon' +import { keccak256 } from 'viem' chai.use(chaiAsPromised).should() import { errors } from '../src/error' import { createEthRpcCache } from '../src/index' +import { perBlockStrategy } from '../src/strategies/per-block' +import { permanentStrategy } from '../src/strategies/permanent' const getJsonResponse = result => Promise.resolve({ @@ -52,8 +55,14 @@ describe('Ethereum RPC Cache', function () { }) it('should call through a strategy', async function () { - const testMethod = 'eth_chainId' - const testParams = [] + const testMethod = 'eth_call' + const testParams = [ + { + data: '0xa25ae55700000000000000000000000000000000000000000000000000000000000016ed', + to: '0x' + }, + 'latest' + ] const testResult = '0x1' const mockRpc = function (method, params) { method.should.equal(testMethod) @@ -61,17 +70,31 @@ describe('Ethereum RPC Cache', function () { return getJsonResponse(testResult) } const spied = sinon.spy(mockRpc) - const testStrategy = { - getRpc(rpc) { - rpc.should.equal(mockRpc) - return spied - }, + + const indexed = [ + { method: 'decimals()', policy: 'permanent' }, + { method: 'getL2Output(uint256)', policy: 'block' } + ] + .map(({ method, policy }) => ({ + [keccak256(method).slice(0, 10)]: policy + })) + .reduce((a, b) => ({ ...a, ...b }), {}) + + const ethCallStrategy = { methods: [testMethod], - name: 'test-strategy' + name: 'eth-call-strategy', + resolver(_, params) { + const signature = params[0].data.slice(0, 10) + return indexed[signature] + } } - const ethRpc = createEthRpcCache(mockRpc, { strategies: [testStrategy] }) + const ethRpc = createEthRpcCache(spied, { + strategies: [perBlockStrategy, permanentStrategy, ethCallStrategy] + }) const response = await ethRpc(testMethod, testParams) + // call again to ensure the cached version was used + await ethRpc(testMethod, testParams) response.should.eql({ id: 1,