Skip to content

Commit a96fa91

Browse files
[SDK] Optimize token balance fetching with Insight API (#6923)
1 parent 4b58eca commit a96fa91

File tree

4 files changed

+240
-99
lines changed

4 files changed

+240
-99
lines changed

packages/thirdweb/src/insight/common.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { getChainServices } from "../chains/utils.js";
44
export async function assertInsightEnabled(chains: Chain[]) {
55
const chainData = await Promise.all(
66
chains.map((chain) =>
7-
getChainServices(chain).then((services) => ({
7+
isInsightEnabled(chain).then((enabled) => ({
88
chain,
9-
enabled: services.some((c) => c.service === "insight" && c.enabled),
9+
enabled,
1010
})),
1111
),
1212
);
@@ -22,3 +22,8 @@ export async function assertInsightEnabled(chains: Chain[]) {
2222
);
2323
}
2424
}
25+
26+
export async function isInsightEnabled(chain: Chain) {
27+
const chainData = await getChainServices(chain);
28+
return chainData.some((c) => c.service === "insight" && c.enabled);
29+
}

packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/BuyScreen.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { trackPayEvent } from "../../../../../../analytics/track/pay.js";
44
import type { Chain } from "../../../../../../chains/types.js";
55
import { getCachedChain } from "../../../../../../chains/utils.js";
66
import type { ThirdwebClient } from "../../../../../../client/client.js";
7-
import { NATIVE_TOKEN_ADDRESS } from "../../../../../../constants/addresses.js";
7+
import {
8+
NATIVE_TOKEN_ADDRESS,
9+
ZERO_ADDRESS,
10+
} from "../../../../../../constants/addresses.js";
811
import type { BuyWithCryptoStatus } from "../../../../../../pay/buyWithCrypto/getStatus.js";
912
import type { BuyWithFiatStatus } from "../../../../../../pay/buyWithFiat/getStatus.js";
1013
import { formatNumber } from "../../../../../../utils/formatNumber.js";
@@ -982,6 +985,9 @@ function createSupportedTokens(
982985

983986
for (const x of data) {
984987
tokens[x.chain.id] = x.tokens.filter((t) => {
988+
if (t.address === ZERO_ADDRESS) {
989+
return false;
990+
}
985991
// for source tokens, data is not provided, so we include all of them
986992
if (
987993
t.buyWithCryptoEnabled === undefined &&

packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/TokenSelectorScreen.tsx

Lines changed: 30 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,8 @@ import {
77
import { useQuery } from "@tanstack/react-query";
88
import { trackPayEvent } from "../../../../../../../analytics/track/pay.js";
99
import type { Chain } from "../../../../../../../chains/types.js";
10-
import { getCachedChain } from "../../../../../../../chains/utils.js";
1110
import type { ThirdwebClient } from "../../../../../../../client/client.js";
12-
import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js";
1311
import type { Wallet } from "../../../../../../../wallets/interfaces/wallet.js";
14-
import {
15-
type GetWalletBalanceResult,
16-
getWalletBalance,
17-
} from "../../../../../../../wallets/utils/getWalletBalance.js";
1812
import type { WalletId } from "../../../../../../../wallets/wallet-types.js";
1913
import { useCustomTheme } from "../../../../../../core/design-system/CustomThemeProvider.js";
2014
import {
@@ -47,12 +41,10 @@ import { formatTokenBalance } from "../../formatTokenBalance.js";
4741
import { type ERC20OrNativeToken, isNativeToken } from "../../nativeToken.js";
4842
import { FiatValue } from "./FiatValue.js";
4943
import { WalletRow } from "./WalletRow.js";
50-
51-
type TokenBalance = {
52-
balance: GetWalletBalanceResult;
53-
chain: Chain;
54-
token: TokenInfo;
55-
};
44+
import {
45+
type TokenBalance,
46+
fetchBalancesForWallet,
47+
} from "./fetchBalancesForWallet.js";
5648

5749
type WalletKey = {
5850
id: WalletId;
@@ -90,93 +82,35 @@ export function TokenSelectorScreen(props: {
9082
activeAccount?.address,
9183
connectedWallets.map((w) => w.getAccount()?.address),
9284
],
85+
enabled: !!props.sourceSupportedTokens && !!chainInfo.data,
9386
queryFn: async () => {
94-
// in parallel, get the balances of all the wallets on each of the sourceSupportedTokens
95-
const walletBalanceMap = new Map<WalletKey, TokenBalance[]>();
96-
97-
const balancePromises = connectedWallets.flatMap((wallet) => {
98-
const account = wallet.getAccount();
99-
if (!account) return [];
100-
const walletKey: WalletKey = {
101-
id: wallet.id,
102-
address: account.address,
103-
};
104-
walletBalanceMap.set(walletKey, []);
105-
106-
// inject the destination token too since it can be used as well to pay/transfer
107-
const toToken = isNativeToken(props.toToken)
108-
? {
109-
address: NATIVE_TOKEN_ADDRESS,
110-
name: chainInfo.data?.nativeCurrency.name || "",
111-
symbol: chainInfo.data?.nativeCurrency.symbol || "",
112-
icon: chainInfo.data?.icon?.url,
113-
}
114-
: props.toToken;
115-
116-
const tokens = {
117-
...props.sourceSupportedTokens,
118-
[props.toChain.id]: [
119-
toToken,
120-
...(props.sourceSupportedTokens?.[props.toChain.id] || []),
121-
],
122-
};
123-
124-
return Object.entries(tokens).flatMap(([chainId, tokens]) => {
125-
return tokens.map(async (token) => {
126-
try {
127-
const chain = getCachedChain(Number(chainId));
128-
const balance = await getWalletBalance({
129-
address: account.address,
130-
chain,
131-
tokenAddress: isNativeToken(token) ? undefined : token.address,
132-
client: props.client,
133-
});
134-
135-
// show the token if:
136-
// - its not the destination token and balance is greater than 0
137-
// - its the destination token and balance is greater than the token amount AND we the account is not the default account in fund_wallet mode
138-
const shouldInclude =
139-
token.address === toToken.address &&
140-
chain.id === props.toChain.id
141-
? props.mode === "fund_wallet" &&
142-
account.address === activeAccount?.address
143-
? false
144-
: Number(balance.displayValue) > Number(props.tokenAmount)
145-
: balance.value > 0n;
146-
147-
if (shouldInclude) {
148-
const existingBalances = walletBalanceMap.get(walletKey) || [];
149-
existingBalances.push({ balance, chain, token });
150-
existingBalances.sort((a, b) => {
151-
if (
152-
a.chain.id === props.toChain.id &&
153-
a.token.address === toToken.address
154-
)
155-
return -1;
156-
if (
157-
b.chain.id === props.toChain.id &&
158-
b.token.address === toToken.address
159-
)
160-
return 1;
161-
if (a.chain.id === props.toChain.id) return -1;
162-
if (b.chain.id === props.toChain.id) return 1;
163-
return a.chain.id > b.chain.id ? 1 : -1;
164-
});
165-
}
166-
} catch (error) {
167-
console.error(
168-
`Failed to fetch balance for wallet ${wallet.id} on chain ${chainId} for token ${token.symbol}:`,
169-
error,
170-
);
171-
}
87+
const entries = await Promise.all(
88+
connectedWallets.map(async (wallet) => {
89+
const balances = await fetchBalancesForWallet({
90+
wallet,
91+
accountAddress: activeAccount?.address,
92+
sourceSupportedTokens: props.sourceSupportedTokens || [],
93+
toChain: props.toChain,
94+
toToken: props.toToken,
95+
tokenAmount: props.tokenAmount,
96+
mode: props.mode,
97+
client: props.client,
17298
});
173-
});
174-
});
175-
176-
await Promise.all(balancePromises);
177-
return walletBalanceMap;
99+
return [
100+
{
101+
id: wallet.id,
102+
address: wallet.getAccount()?.address || "",
103+
} as WalletKey,
104+
balances,
105+
] as const;
106+
}),
107+
);
108+
const map = new Map<WalletKey, TokenBalance[]>();
109+
for (const entry of entries) {
110+
map.set(entry[0], entry[1]);
111+
}
112+
return map;
178113
},
179-
enabled: !!props.sourceSupportedTokens && !!chainInfo.data,
180114
});
181115

182116
if (
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import type { Chain } from "../../../../../../../chains/types.js";
2+
import { getCachedChain } from "../../../../../../../chains/utils.js";
3+
import type { ThirdwebClient } from "../../../../../../../client/client.js";
4+
import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js";
5+
import { isInsightEnabled } from "../../../../../../../insight/common.js";
6+
import { getOwnedTokens } from "../../../../../../../insight/get-tokens.js";
7+
import type { Wallet } from "../../../../../../../wallets/interfaces/wallet.js";
8+
import {
9+
type GetWalletBalanceResult,
10+
getWalletBalance,
11+
} from "../../../../../../../wallets/utils/getWalletBalance.js";
12+
import type { PayUIOptions } from "../../../../../../core/hooks/connection/ConnectButtonProps.js";
13+
import type {
14+
SupportedTokens,
15+
TokenInfo,
16+
} from "../../../../../../core/utils/defaultTokens.js";
17+
import { type ERC20OrNativeToken, isNativeToken } from "../../nativeToken.js";
18+
19+
const CHUNK_SIZE = 5;
20+
21+
function chunkChains<T>(chains: T[]): T[][] {
22+
const chunks: T[][] = [];
23+
for (let i = 0; i < chains.length; i += CHUNK_SIZE) {
24+
chunks.push(chains.slice(i, i + CHUNK_SIZE));
25+
}
26+
return chunks;
27+
}
28+
29+
type FetchBalancesParams = {
30+
wallet: Wallet;
31+
accountAddress: string | undefined;
32+
sourceSupportedTokens: SupportedTokens;
33+
toChain: Chain;
34+
toToken: ERC20OrNativeToken;
35+
tokenAmount: string;
36+
mode: PayUIOptions["mode"];
37+
client: ThirdwebClient;
38+
};
39+
40+
export type TokenBalance = {
41+
balance: GetWalletBalanceResult;
42+
chain: Chain;
43+
token: TokenInfo;
44+
};
45+
46+
export async function fetchBalancesForWallet({
47+
wallet,
48+
accountAddress,
49+
sourceSupportedTokens,
50+
toChain,
51+
toToken,
52+
tokenAmount,
53+
mode,
54+
client,
55+
}: FetchBalancesParams): Promise<TokenBalance[]> {
56+
const account = wallet.getAccount();
57+
if (!account) {
58+
return [];
59+
}
60+
61+
const balances: TokenBalance[] = [];
62+
63+
// 1. Resolve all unique chains in the supported token map
64+
const uniqueChains = Object.keys(sourceSupportedTokens).map((id) =>
65+
getCachedChain(Number(id)),
66+
);
67+
68+
// 2. Check insight availability once per chain
69+
const insightSupport = await Promise.all(
70+
uniqueChains.map(async (c) => ({
71+
chain: c,
72+
enabled: await isInsightEnabled(c),
73+
})),
74+
);
75+
const insightEnabledChains = insightSupport
76+
.filter((c) => c.enabled)
77+
.map((c) => c.chain);
78+
79+
// 3. ERC-20 balances for insight-enabled chains (batched 5 chains / call)
80+
const insightChunks = chunkChains(insightEnabledChains);
81+
await Promise.all(
82+
insightChunks.map(async (chunk) => {
83+
const owned = await getOwnedTokens({
84+
ownerAddress: account.address,
85+
chains: chunk,
86+
client,
87+
});
88+
89+
for (const b of owned) {
90+
const matching = sourceSupportedTokens[b.chainId]?.find(
91+
(t) => t.address.toLowerCase() === b.tokenAddress.toLowerCase(),
92+
);
93+
if (matching) {
94+
balances.push({
95+
balance: b,
96+
chain: getCachedChain(b.chainId),
97+
token: matching,
98+
});
99+
}
100+
}
101+
}),
102+
);
103+
104+
// 4. Build a token map that also includes the destination token so it can be used to pay
105+
const destinationToken = isNativeToken(toToken)
106+
? {
107+
address: NATIVE_TOKEN_ADDRESS,
108+
name: toChain.nativeCurrency?.name || "",
109+
symbol: toChain.nativeCurrency?.symbol || "",
110+
icon: toChain.icon?.url,
111+
}
112+
: toToken;
113+
114+
const tokenMap: Record<number, TokenInfo[]> = {
115+
...sourceSupportedTokens,
116+
[toChain.id]: [
117+
destinationToken,
118+
...(sourceSupportedTokens[toChain.id] || []),
119+
],
120+
};
121+
122+
// 5. Fallback RPC balances (native currency & ERC-20 that we couldn't fetch from insight)
123+
const rpcCalls: Promise<void>[] = [];
124+
125+
for (const [chainIdStr, tokens] of Object.entries(tokenMap)) {
126+
const chainId = Number(chainIdStr);
127+
const chain = getCachedChain(chainId);
128+
129+
for (const token of tokens) {
130+
const isNative = isNativeToken(token);
131+
const isAlreadyFetched = balances.some(
132+
(b) =>
133+
b.chain.id === chainId &&
134+
b.token.address.toLowerCase() === token.address.toLowerCase(),
135+
);
136+
if (!isNative && !isAlreadyFetched) {
137+
// ERC20 on insight-enabled chain already handled by insight call
138+
continue;
139+
}
140+
rpcCalls.push(
141+
(async () => {
142+
try {
143+
const balance = await getWalletBalance({
144+
address: account.address,
145+
chain,
146+
tokenAddress: isNative ? undefined : token.address,
147+
client,
148+
});
149+
150+
const include =
151+
token.address === destinationToken.address &&
152+
chain.id === toChain.id
153+
? mode === "fund_wallet" && account.address === accountAddress
154+
? false
155+
: Number(balance.displayValue) > Number(tokenAmount)
156+
: balance.value > 0n;
157+
158+
if (include) {
159+
balances.push({ balance, chain, token });
160+
}
161+
} catch (err) {
162+
console.warn(
163+
`Failed to fetch balance for ${token.symbol} on chain ${chainId}`,
164+
err,
165+
);
166+
}
167+
})(),
168+
);
169+
}
170+
}
171+
172+
await Promise.all(rpcCalls);
173+
174+
// Remove duplicates (same chainId + token address)
175+
{
176+
const uniq: Record<string, TokenBalance> = {};
177+
for (const b of balances) {
178+
const k = `${b.chain.id}-${b.token.address.toLowerCase()}`;
179+
if (!uniq[k]) {
180+
uniq[k] = b;
181+
}
182+
}
183+
balances.splice(0, balances.length, ...Object.values(uniq));
184+
}
185+
// 6. Sort so that the destination token always appears first, then tokens on the destination chain, then by chain id
186+
balances.sort((a, b) => {
187+
const destAddress = destinationToken.address;
188+
if (a.chain.id === toChain.id && a.token.address === destAddress) return -1;
189+
if (b.chain.id === toChain.id && b.token.address === destAddress) return 1;
190+
if (a.chain.id === toChain.id) return -1;
191+
if (b.chain.id === toChain.id) return 1;
192+
return a.chain.id - b.chain.id;
193+
});
194+
195+
return balances;
196+
}

0 commit comments

Comments
 (0)