Skip to content

feat(utils): batch balance queries via multicall #1557

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

gsteenkamp89
Copy link
Contributor

motivation

Each new added token/chain increases the number of balance queries exponentially.
This PR adds a class we can use to batch any balance queries (made within a specified interval) via multicall, reducing the number of requests to public RPCs

Signed-off-by: Gerhard Steenkamp <[email protected]>
Signed-off-by: Gerhard Steenkamp <[email protected]>
Copy link

vercel bot commented Apr 16, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
app-frontend-v3 ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 16, 2025 11:56am
sepolia-frontend-v3 ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 16, 2025 11:56am

Comment on lines +21 to +94
export const getBatchBalanceViaMulticall3 = async (
chainId: string | number,
addresses: string[],
tokenAddresses: string[],
blockTag: providers.BlockTag = "latest"
): Promise<MultiCallResult> => {
const chainIdAsInt = Number(chainId);
const provider = getProvider(chainIdAsInt);

const multicall3 = getMulticall3(chainIdAsInt, provider);

if (!multicall3) {
throw new Error("No Multicall3 deployed on this chain");
}

let calls: Parameters<typeof callViaMulticall3>[1] = [];

for (const tokenAddress of tokenAddresses) {
if (tokenAddress === ZERO_ADDRESS) {
// For native currency
calls.push(
...addresses.map((address) => ({
contract: multicall3,
functionName: "getEthBalance",
args: [address],
}))
);
} else {
// For ERC20 tokens
const erc20Contract = ERC20__factory.connect(tokenAddress, provider);
calls.push(
...addresses.map((address) => ({
contract: erc20Contract,
functionName: "balanceOf",
args: [address],
}))
);
}
}

const inputs = calls.map(({ contract, functionName, args }) => ({
target: contract.address,
callData: contract.interface.encodeFunctionData(functionName, args),
}));

const [blockNumber, results] = await multicall3.callStatic.aggregate(inputs, {
blockTag,
});

const decodedResults = results.map((result, i) =>
calls[i].contract.interface.decodeFunctionResult(
calls[i].functionName,
result
)
);

let balances: Record<string, Record<string, string>> = {};

let resultIndex = 0;
for (const tokenAddress of tokenAddresses) {
addresses.forEach((address) => {
if (!balances[address]) {
balances[address] = {};
}
balances[address][tokenAddress] = decodedResults[resultIndex].toString();
resultIndex++;
});
}

return {
blockNumber: blockNumber.toNumber(),
balances,
};
};
Copy link
Contributor Author

@gsteenkamp89 gsteenkamp89 Apr 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copied from here (since we cannot share code between api and frontend easily)

export const getBatchBalanceViaMulticall3 = async (

* @param blockTag Optional blockTag (defaults to "latest").
* @returns An object containing the balance (a string), loading state, and any error.
*/
export function useBalance({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might need to add ability to resolve tokenSymbol => address.

let calls: Parameters<typeof callViaMulticall3>[1] = [];

for (const tokenAddress of tokenAddresses) {
if (tokenAddress === ZERO_ADDRESS) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to add logic somewhere to handle ETH since we use WETH address internally not the zero address

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wdyt of using the symbols instead of addresses in the context of our FE? Based on the chain id we can infer which symbols are native

timer?: NodeJS.Timeout;
}

const DEFAULT_BATCH_INTERVAL = 100;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this interval adds an implicit delay. is this too aggressive? is 10ms better?

Signed-off-by: Gerhard Steenkamp <[email protected]>
*/
export const getBatchBalanceViaMulticall3 = async (
chainId: string | number,
addresses: string[],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gsteenkamp89 Do you think we'll be supplying > 1 address in practice? I'd thought it would typically only be address of the connected wallet, though if we need to make more queries than that's even better.

(i.e. are we maybe also querying relayer balances this way?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that's exactly right, we're repurposing the logic originally written for relayer balances.

Comment on lines +50 to +65
// For ERC20 tokens
const erc20Contract = ERC20__factory.connect(tokenAddress, provider);
calls.push(
...addresses.map((address) => ({
contract: erc20Contract,
functionName: "balanceOf",
args: [address],
}))
);
}
}

const inputs = calls.map(({ contract, functionName, args }) => ({
target: contract.address,
callData: contract.interface.encodeFunctionData(functionName, args),
}));
Copy link
Contributor

@pxrl pxrl Apr 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gsteenkamp89 I think this could be reordered to squash it down. It should be possible to instantiate a single generic ERC20 contract (not connected to any specific address or provider) and then generate all the balanceOf() calldata. Then that same calldata can be reused across all of the ERC20s we want to query.

So I think it could be something like:

const erc20 = ERC20__factory.connect(ZERO_ADDRESS, ...);
const inputs: { target: string, callData: string }[] = [];

for (const address of addresses) {  
  const callData = erc20.interface.encodeFunctionData("balanceOf", address);
  for (const tokenAddress of tokenAddresses) {
    inputs.push({ target: tokenAddress, callData });
  }
}

If it's done that way then that should save instantiating a bunch of ephemeral ERC20 contract instances. It does require some special handling for the snowflake "getEthBalance()" call though.

export const getBatchBalanceViaMulticall3 = async (
chainId: string | number,
addresses: string[],
tokenAddresses: string[],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might make sense to use symbols instead of addresses here?

let calls: Parameters<typeof callViaMulticall3>[1] = [];

for (const tokenAddress of tokenAddresses) {
if (tokenAddress === ZERO_ADDRESS) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wdyt of using the symbols instead of addresses in the context of our FE? Based on the chain id we can infer which symbols are native

blockTag ?? "latest",
] as const,
queryFn: () =>
balanceBatcher.queueBalanceCall(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks interesting. I am just wondering if we could reach the same goal by using optimized tanstack/query options 🤔

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants