Skip to content

Commit

Permalink
feat(batch): batch identical queries
Browse files Browse the repository at this point in the history
use dataloader to batch identical queries made at same blocktag to avoid overpopulating the RPC call

BREAKING CHANGE: removal of _fetchMulticall internal function
  • Loading branch information
Rubilmax committed Dec 24, 2023
1 parent 8bdcd90 commit dfde11d
Show file tree
Hide file tree
Showing 6 changed files with 589 additions and 429 deletions.
23 changes: 10 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,29 +41,26 @@
},
"homepage": "https://github.com/rubilmax/ethers-multicall-provider#readme",
"dependencies": {
"ethers": "^6.0.0",
"lodash": "^4.17.0"
"dataloader": "^2.2.2",
"ethers": "^6.0.0"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@typechain/ethers-v6": "^0.5.0",
"@types/jest": "^29.5.4",
"@types/lodash": "^4.14.197",
"@types/lodash.debounce": "^4.0.7",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@typechain/ethers-v6": "^0.5.1",
"@types/jest": "^29.5.11",
"@types/lodash": "^4.14.202",
"@types/lodash.debounce": "^4.0.9",
"commitizen": "^4.3.0",
"conventional-changelog-conventionalcommits": "^6.1.0",
"cz-conventional-changelog": "^3.3.0",
"dotenv": "^16.3.1",
"husky": "^8.0.3",
"jest": "^29.6.4",
"jest": "^29.7.0",
"lint-staged": "^14.0.1",
"prettier": "^2.8.8",
"ts-jest": "^29.1.1",
"typechain": "^8.3.1",
"typescript": "^5.1.6"
},
"peerDependencies": {
"lodash": "^4.17.0"
"typechain": "^8.3.2",
"typescript": "^5.3.3"
},
"config": {
"commitizen": {
Expand Down
183 changes: 80 additions & 103 deletions src/multicall-provider.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import DataLoader from "dataloader";
import { BlockTag, BytesLike, AbstractProvider, PerformActionRequest, Network } from "ethers";
import { DebouncedFunc } from "lodash";
import _debounce from "lodash/debounce";

import { multicallAddresses } from "./constants";
import { Multicall2, Multicall3 } from "./types";
import { getBlockNumber, getMulticall } from "./utils";

export interface ContractCall<T = any> {
export interface ContractCall {
to: string;
data: BytesLike;
data: string;
blockTag: BlockTag;
}

export interface ContractCallRequest {
call: ContractCall;
multicall: Multicall2 | Multicall3;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
}

export type MulticallProvider<T extends AbstractProvider = AbstractProvider> = T & {
Expand All @@ -21,13 +22,8 @@ export type MulticallProvider<T extends AbstractProvider = AbstractProvider> = T
fetchNetwork(): Promise<Network>;
_networkPromise: Promise<Network>;

_multicallDelay: number;
multicallDelay: number;
maxMulticallDataLength: number;
isMulticallEnabled: boolean;

_performMulticall: () => Promise<void>;
_debouncedPerformMulticall: DebouncedFunc<() => Promise<void>>;
};

export class MulticallWrapper {
Expand All @@ -47,14 +43,12 @@ export class MulticallWrapper {
/**
* Wraps a given ethers provider to enable automatic call batching.
* @param provider The underlying provider to use to batch calls.
* @param delay The delay (in milliseconds) to wait before performing the ongoing batch of calls. Defaults to 16ms.
* @param maxMulticallDataLength The maximum total calldata length allowed in a multicall batch, to avoid having the RPC backend to revert because of too large (or too long) request. Set to 0 to disable this behavior. Defaults to 200k.
* @param maxMulticallDataLength The maximum total calldata length allowed in a multicall batch, to avoid having the RPC backend to revert because of too large (or too long) request. Set to 0 to disable this behavior. Defaults to 0. Typically 480k for Alchemy.
* @returns The multicall provider, which is a proxy to the given provider, automatically batching any call performed with it.
*/
public static wrap<T extends AbstractProvider>(
provider: T,
delay = 16,
maxMulticallDataLength = 200_000
maxMulticallDataLength = 0
): MulticallProvider<T> {
if (MulticallWrapper.isMulticallProvider(provider)) return provider; // Do not overwrap when given provider is already a multicall provider.

Expand Down Expand Up @@ -85,96 +79,86 @@ export class MulticallWrapper {
enumerable: true,
configurable: true,
},
multicallDelay: {
get: function () {
return this._multicallDelay;
},
set: function (delay: number) {
this._debouncedPerformMulticall?.flush();

this._multicallDelay = delay;

this._debouncedPerformMulticall = _debounce(this._performMulticall, delay);
},
enumerable: true,
configurable: false,
},
});

const multicallProvider = provider as MulticallProvider<T>;

// Define execution context

let queuedCalls: ContractCall[] = [];

multicallProvider._performMulticall = async function () {
const _queuedCalls = [...queuedCalls];
const dataLoader = new DataLoader<ContractCallRequest, any, string>(
async function (reqs) {
const blockTagReqs: { [blockTag: string]: (ContractCallRequest & { index: number })[] } =
{};

if (queuedCalls.length === 0) return;
reqs.forEach(({ call, multicall }, index) => {
const blockTag = call.blockTag.toString();

queuedCalls = [];
if (!blockTagReqs[blockTag]) blockTagReqs[blockTag] = [];

const blockTagCalls = _queuedCalls.reduce((acc, queuedCall) => {
const blockTag = queuedCall.blockTag.toString();

return {
...acc,
[blockTag]: [queuedCall].concat(acc[blockTag] ?? []),
};
}, {} as { [blockTag: string]: ContractCall[] });
blockTagReqs[blockTag].push({ call, multicall, index });
});

await Promise.all(
Object.values(blockTagCalls).map(async (blockTagQueuedCalls) => {
const callStructs = blockTagQueuedCalls.map(({ to, data }) => ({
target: to,
callData: data,
}));
const results = new Array(reqs.length);

// Split call in parts of max length to avoid too-long requests
await Promise.all(
Object.values(blockTagReqs).map(async (blockTagReqs) => {
const callStructs = blockTagReqs.map(({ call }) => ({
target: call.to,
callData: call.data,
}));

let currentLength = 0;
const calls: (typeof callStructs)[] = [[]];
// Split call in parts of max length to avoid too-long requests

callStructs.forEach((callStruct) => {
const newLength = currentLength + callStruct.callData.length;
let currentLength = 0;
const calls: (typeof callStructs)[] = [];

if (this.maxMulticallDataLength > 0 && newLength > this.maxMulticallDataLength) {
currentLength = callStruct.callData.length;
if (multicallProvider.maxMulticallDataLength > 0) {
calls.push([]);
} else currentLength = newLength;

calls[calls.length - 1].push(callStruct);
});

const { blockTag, multicall } = blockTagQueuedCalls[0];

try {
const res = (
await Promise.all(
calls.map((call) => multicall.tryAggregate.staticCall(false, call, { blockTag }))
)
).flat();

if (res.length !== callStructs.length)
throw new Error(
`Unexpected multicall response length: received ${res.length}; expected ${callStructs.length}`
);

blockTagQueuedCalls.forEach(({ resolve }, i) => {
resolve(res[i].returnData);
});
} catch (error: any) {
blockTagQueuedCalls.forEach(({ reject }) => {
reject(error);
});
}
})
);
};

// Overload multicall provider delay

multicallProvider.multicallDelay = delay;
callStructs.forEach((callStruct) => {
const newLength = currentLength + callStruct.callData.length;

if (newLength > multicallProvider.maxMulticallDataLength) {
currentLength = callStruct.callData.length;
calls.push([]);
} else currentLength = newLength;

calls[calls.length - 1].push(callStruct);
});
} else calls.push(callStructs);

const {
call: { blockTag },
multicall,
} = blockTagReqs[0];

try {
const res = (
await Promise.all(
calls.map((call) => multicall.tryAggregate.staticCall(false, call, { blockTag }))
)
).flat();

if (res.length !== callStructs.length)
throw new Error(
`Unexpected multicall response length: received ${res.length}; expected ${callStructs.length}`
);

blockTagReqs.forEach(({ index }, i) => {
results[index] = res[i].returnData;
});
} catch (error: any) {
blockTagReqs.forEach(({ index }) => {
results[index] = error;
});
}
})
);

return results;
},
{ cacheKeyFn: ({ call }) => (call.to + call.data + call.blockTag.toString()).toLowerCase() }
);

// Expose `Provider.fetchNetwork` to fetch & update the network cache when needed

Expand All @@ -198,33 +182,26 @@ export class MulticallWrapper {

const _perform = provider._perform.bind(provider);

multicallProvider._perform = async function <R = any>(req: PerformActionRequest): Promise<R> {
multicallProvider._perform = async function (req: PerformActionRequest): Promise<any> {
if (req.method !== "call" || !this.isMulticallEnabled) return _perform(req);

const {
transaction: { to, data },
blockTag,
} = req;

if (!to || !data || multicallAddresses.has(to.toString().toLowerCase())) return _perform(req);

const network = await this._networkPromise;

const blockNumber = getBlockNumber(blockTag);
const multicall = getMulticall(blockNumber, Number(network.chainId), provider);

if (!to || !data || multicall == null || multicallAddresses.has(to.toString().toLowerCase()))
return _perform(req);
if (multicall == null) return _perform(req);

this._debouncedPerformMulticall();

return new Promise<R>((resolve, reject) => {
queuedCalls.push({
to,
data,
blockTag,
multicall,
resolve,
reject,
});
return dataLoader.load({
call: { to, data, blockTag },
multicall,
});
};

Expand Down
Loading

0 comments on commit dfde11d

Please sign in to comment.