From a577e24f1c0e6b198eef583082ab0a7b74367b73 Mon Sep 17 00:00:00 2001 From: rowan Date: Sat, 18 Apr 2026 16:36:03 +0900 Subject: [PATCH 01/15] feat(erc20): implement batch querying for ERC20 balances - Introduced `ObservableQueryEthereumERC20BalancesBatchParent` to handle batch requests for ERC20 token balances. - Updated `ObservableQueryEthereumERC20BalanceImpl` to utilize the new batch querying mechanism. - Refactored `ObservableQueryThirdpartyERC20BalancesImplParent` to integrate with the batch parent for improved balance fetching. - Removed hardcoded balance handling for specific tokens in `RootStore`. - Added `ERC20BalanceBatchParentStore` to manage instances of batch queries efficiently. - Enhanced overall balance querying performance by reducing redundant calls. --- apps/extension/src/stores/root.tsx | 8 -- .../src/queries/erc20-balance-batch.ts | 132 +++++++++++++++++ .../stores-eth/src/queries/erc20-balance.ts | 115 ++++++++++----- .../stores-eth/src/queries/erc20-balances.ts | 134 ++++++++++++++---- .../src/queries/erc20-batch-parent-store.ts | 28 ++++ packages/stores-eth/src/queries/index.ts | 23 ++- 6 files changed, 351 insertions(+), 89 deletions(-) create mode 100644 packages/stores-eth/src/queries/erc20-balance-batch.ts create mode 100644 packages/stores-eth/src/queries/erc20-batch-parent-store.ts diff --git a/apps/extension/src/stores/root.tsx b/apps/extension/src/stores/root.tsx index 17421c77c0..706533b691 100644 --- a/apps/extension/src/stores/root.tsx +++ b/apps/extension/src/stores/root.tsx @@ -400,14 +400,6 @@ export class RootStore { _address, minimalDenom ) => { - // Base의 axlUSDC만 밸런스를 가지고 올 수 없는 문제가 있어서 우선 하드코딩으로 처리 - if ( - chainId === "eip155:8453" && - minimalDenom === "erc20:0xeb466342c4d449bc9f53a865d5cb90586f405215" - ) { - return true; - } - return this.tokensStore.tokenIsRegistered(chainId, minimalDenom); }, }), diff --git a/packages/stores-eth/src/queries/erc20-balance-batch.ts b/packages/stores-eth/src/queries/erc20-balance-batch.ts new file mode 100644 index 0000000000..352cc14fc9 --- /dev/null +++ b/packages/stores-eth/src/queries/erc20-balance-batch.ts @@ -0,0 +1,132 @@ +import { + ChainGetter, + JsonRpcBatchRequest, + ObservableJsonRpcBatchQuery, + QueryError, + QuerySharedContext, +} from "@keplr-wallet/stores"; +import { makeObservable, observable, reaction, runInAction } from "mobx"; +import { erc20ContractInterface } from "../constants"; + +const BATCH_CHUNK_SIZE = 10; +const REBUILD_DEBOUNCE_MS = 200; + +export class ObservableQueryEthereumERC20BalancesBatchParent { + @observable.shallow + protected refcount: Map = new Map(); + + @observable.ref + protected batchQueries: ObservableJsonRpcBatchQuery[] = []; + + constructor( + protected readonly sharedContext: QuerySharedContext, + protected readonly chainId: string, + protected readonly chainGetter: ChainGetter, + protected readonly ethereumHexAddress: string + ) { + makeObservable(this); + + reaction( + () => Array.from(this.refcount.keys()).sort().join(","), + (key) => this.rebuildBatchQueries(key), + { fireImmediately: true, delay: REBUILD_DEBOUNCE_MS } + ); + } + + addContract(contract: string): void { + const key = contract.toLowerCase(); + runInAction(() => { + this.refcount.set(key, (this.refcount.get(key) ?? 0) + 1); + }); + } + + removeContract(contract: string): void { + const key = contract.toLowerCase(); + const n = this.refcount.get(key); + if (n === undefined) return; + runInAction(() => { + if (n <= 1) { + this.refcount.delete(key); + } else { + this.refcount.set(key, n - 1); + } + }); + } + + getBalance(contract: string): string | undefined { + const key = contract.toLowerCase(); + for (const q of this.batchQueries) { + const data = q.response?.data?.[key]; + if (data !== undefined) return data; + } + return undefined; + } + + get isFetching(): boolean { + return this.batchQueries.some((q) => q.isFetching); + } + + get error(): QueryError | undefined { + return undefined; + } + + async waitFreshResponse(): Promise { + await Promise.all(this.batchQueries.map((q) => q.waitFreshResponse())); + } + + protected rebuildBatchQueries(key: string): void { + if (key === "") { + runInAction(() => { + this.batchQueries = []; + }); + return; + } + + const rpcUrl = this.getRpcUrl(); + if (!rpcUrl) { + runInAction(() => { + this.batchQueries = []; + }); + return; + } + + const contracts = Array.from(this.refcount.keys()).sort(); + const chunks = chunkArray(contracts, BATCH_CHUNK_SIZE); + + const calldata = (to: string) => ({ + to, + data: erc20ContractInterface.encodeFunctionData("balanceOf", [ + this.ethereumHexAddress, + ]), + }); + + runInAction(() => { + this.batchQueries = chunks.map((chunk) => { + const requests: JsonRpcBatchRequest[] = chunk.map((c) => ({ + method: "eth_call", + params: [calldata(c), "latest"], + id: c, + })); + return new ObservableJsonRpcBatchQuery( + this.sharedContext, + rpcUrl, + "", + requests + ); + }); + }); + } + + protected getRpcUrl(): string { + const u = this.chainGetter.getModularChain(this.chainId).unwrapped; + return u.type === "evm" || u.type === "ethermint" ? u.evm.rpc : ""; + } +} + +function chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; +} diff --git a/packages/stores-eth/src/queries/erc20-balance.ts b/packages/stores-eth/src/queries/erc20-balance.ts index 858c84f694..076250ce36 100644 --- a/packages/stores-eth/src/queries/erc20-balance.ts +++ b/packages/stores-eth/src/queries/erc20-balance.ts @@ -2,78 +2,114 @@ import { BalanceRegistry, ChainGetter, IObservableQueryBalanceImpl, + QueryError, + QueryResponse, QuerySharedContext, } from "@keplr-wallet/stores"; import { AppCurrency } from "@keplr-wallet/types"; import { CoinPretty, Int } from "@keplr-wallet/unit"; -import { computed, makeObservable } from "mobx"; +import { + computed, + makeObservable, + observable, + onBecomeObserved, + onBecomeUnobserved, + runInAction, +} from "mobx"; import bigInteger from "big-integer"; -import { erc20ContractInterface } from "../constants"; import { DenomHelper } from "@keplr-wallet/common"; import { EthereumAccountBase } from "../account"; -import { ObservableEvmChainJsonRpcQuery } from "./evm-chain-json-rpc"; +import { ObservableQueryEthereumERC20BalancesBatchParent } from "./erc20-balance-batch"; +import { ERC20BalanceBatchParentStore } from "./erc20-batch-parent-store"; export class ObservableQueryEthereumERC20BalanceImpl - extends ObservableEvmChainJsonRpcQuery implements IObservableQueryBalanceImpl { + @observable + protected _isObserved = false; + constructor( - sharedContext: QuerySharedContext, - chainId: string, - chainGetter: ChainGetter, + protected readonly parent: ObservableQueryEthereumERC20BalancesBatchParent, + protected readonly chainId: string, + protected readonly chainGetter: ChainGetter, protected readonly denomHelper: DenomHelper, - protected readonly ethereumHexAddress: string, protected readonly contractAddress: string ) { - super(sharedContext, chainId, chainGetter, "eth_call", [ - { - to: contractAddress, - data: erc20ContractInterface.encodeFunctionData("balanceOf", [ - ethereumHexAddress, - ]), - }, - "latest", - ]); - makeObservable(this); + + onBecomeObserved(this, "balance", () => { + runInAction(() => { + this._isObserved = true; + }); + this.parent.addContract(contractAddress); + }); + onBecomeUnobserved(this, "balance", () => { + runInAction(() => { + this._isObserved = false; + }); + this.parent.removeContract(contractAddress); + }); } @computed get balance(): CoinPretty { - const denom = this.denomHelper.denom; - - const currency = this.chainGetter - .getModularChain(this.chainId) - .findCurrency(denom); - - if (!currency) { - throw new Error(`Unknown currency: ${this.contractAddress}`); - } - - if (!this.response || !this.response.data) { + const currency = this.currency; + const raw = this.parent.getBalance(this.contractAddress); + if (raw === undefined) { return new CoinPretty(currency, new Int(0)).ready(false); } - return new CoinPretty( currency, - new Int(bigInteger(this.response.data.replace("0x", ""), 16).toString()) + new Int(bigInteger(raw.replace("0x", ""), 16).toString()) ); } @computed get currency(): AppCurrency { - const denom = this.denomHelper.denom; - return this.chainGetter .getModularChain(this.chainId) - .forceFindCurrency(denom); + .forceFindCurrency(this.denomHelper.denom); + } + + get isFetching(): boolean { + return this.parent.isFetching; + } + get isObserved(): boolean { + return this._isObserved; + } + get isStarted(): boolean { + return this._isObserved; + } + get error(): QueryError | undefined { + return this.parent.error; + } + get response(): Readonly> | undefined { + return undefined; + } + + fetch(): Promise { + return this.parent.waitFreshResponse(); + } + + async waitFreshResponse(): Promise< + Readonly> | undefined + > { + await this.parent.waitFreshResponse(); + return undefined; + } + + async waitResponse(): Promise> | undefined> { + return await this.waitFreshResponse(); } } export class ObservableQueryEthereumERC20BalanceRegistry implements BalanceRegistry { - constructor(protected readonly sharedContext: QuerySharedContext) {} + constructor( + protected readonly sharedContext: QuerySharedContext, + protected readonly batchParentStore: ERC20BalanceBatchParentStore + ) {} getBalanceImpl( chainId: string, @@ -93,12 +129,17 @@ export class ObservableQueryEthereumERC20BalanceRegistry return; } + const parent = this.batchParentStore.getOrCreate( + chainId, + chainGetter, + address + ); + return new ObservableQueryEthereumERC20BalanceImpl( - this.sharedContext, + parent, chainId, chainGetter, denomHelper, - address, denomHelper.contractAddress ); } diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index fdf1a0b2c9..2918864f7c 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -1,5 +1,14 @@ import { DenomHelper } from "@keplr-wallet/common"; -import { computed, makeObservable } from "mobx"; +import { + computed, + IReactionDisposer, + makeObservable, + observable, + onBecomeObserved, + onBecomeUnobserved, + reaction, + runInAction, +} from "mobx"; import { CoinPretty, Int } from "@keplr-wallet/unit"; import { AppCurrency } from "@keplr-wallet/types"; import { @@ -11,7 +20,10 @@ import { QueryResponse, QuerySharedContext, } from "@keplr-wallet/stores"; +import bigInteger from "big-integer"; import { EthereumAccountBase } from "../account"; +import { ObservableQueryEthereumERC20BalancesBatchParent } from "./erc20-balance-batch"; +import { ERC20BalanceBatchParentStore } from "./erc20-batch-parent-store"; const thirdparySupportedChainIdMap: Record = { "eip155:1": "eth", @@ -42,11 +54,15 @@ export class ObservableQueryThirdpartyERC20BalancesImplParent extends Observable // so fetch should not be overridden in this parent class. public duplicatedFetchResolver?: Promise; + @observable.shallow + protected alchemyContractSet: Set = new Set(); + constructor( sharedContext: QuerySharedContext, protected readonly chainId: string, protected readonly chainGetter: ChainGetter, - protected readonly ethereumHexAddress: string + protected readonly ethereumHexAddress: string, + public readonly batchParent: ObservableQueryEthereumERC20BalancesBatchParent ) { const tokenAPIURL = `https://evm-${chainId.replace( "eip155:", @@ -79,22 +95,35 @@ export class ObservableQueryThirdpartyERC20BalancesImplParent extends Observable super.onReceiveResponse(response); const mcInfo = this.chainGetter.getModularChain(this.chainId); - const erc20Denoms = response.data.tokenBalances - .filter( - (tokenBalance) => - tokenBalance.tokenBalance != null && - BigInt(tokenBalance.tokenBalance) > 0 - ) - .map((tokenBalance) => `erc20:${tokenBalance.contractAddress}`); - if (erc20Denoms) { + const next = new Set(); + const erc20Denoms: string[] = []; + for (const tokenBalance of response.data.tokenBalances) { + if (tokenBalance.tokenBalance != null) { + next.add(tokenBalance.contractAddress.toLowerCase()); + if (BigInt(tokenBalance.tokenBalance) > 0) { + erc20Denoms.push(`erc20:${tokenBalance.contractAddress}`); + } + } + } + runInAction(() => { + this.alchemyContractSet = next; + }); + if (erc20Denoms.length > 0) { mcInfo.addUnknownDenoms(...erc20Denoms); } } + + hasAlchemyBalance(contract: string): boolean { + return this.alchemyContractSet.has(contract.toLowerCase()); + } } export class ObservableQueryThirdpartyERC20BalancesImpl implements IObservableQueryBalanceImpl { + protected batchReactionDisposer?: IReactionDisposer; + protected isInBatch = false; + constructor( protected readonly parent: ObservableQueryThirdpartyERC20BalancesImplParent, protected readonly chainId: string, @@ -102,26 +131,73 @@ export class ObservableQueryThirdpartyERC20BalancesImpl protected readonly denomHelper: DenomHelper ) { makeObservable(this); + + const contract = denomHelper.contractAddress; + onBecomeObserved(this, "balance", () => { + // Register to BatchParent only when Alchemy has responded AND + // the contract is missing from the response. Avoids duplicate + // eth_call for tokens Alchemy already covers. + this.batchReactionDisposer = reaction( + () => { + // Alchemy response not yet received and no error — wait. + if (!parent.response && !parent.error) return "pending"; + // Alchemy covers this contract — use Alchemy value, skip batch. + if (parent.response && parent.hasAlchemyBalance(contract)) { + return "covered"; + } + // Alchemy errored or Alchemy response missing this contract — fallback to batch. + return "missing"; + }, + (status) => { + if (status === "missing" && !this.isInBatch) { + this.isInBatch = true; + parent.batchParent.addContract(contract); + } else if (status !== "missing" && this.isInBatch) { + this.isInBatch = false; + parent.batchParent.removeContract(contract); + } + }, + { fireImmediately: true } + ); + }); + onBecomeUnobserved(this, "balance", () => { + this.batchReactionDisposer?.(); + this.batchReactionDisposer = undefined; + if (this.isInBatch) { + this.isInBatch = false; + parent.batchParent.removeContract(contract); + } + }); } @computed get balance(): CoinPretty { const currency = this.currency; + const contract = this.denomHelper.contractAddress; - if (!this.response) { - return new CoinPretty(currency, new Int(0)).ready(false); + if (this.parent.hasAlchemyBalance(contract)) { + const tokenBalance = this.response?.data.tokenBalances.find( + (bal) => + DenomHelper.normalizeDenom(`erc20:${bal.contractAddress}`) === + DenomHelper.normalizeDenom(this.denomHelper.denom) + ); + if (tokenBalance?.tokenBalance != null) { + return new CoinPretty( + currency, + new Int(BigInt(tokenBalance.tokenBalance)) + ); + } } - const tokenBalance = this.response.data.tokenBalances.find( - (bal) => - DenomHelper.normalizeDenom(`erc20:${bal.contractAddress}`) === - DenomHelper.normalizeDenom(this.denomHelper.denom) - ); - if (tokenBalance?.tokenBalance == null) { - return new CoinPretty(currency, new Int(0)).ready(false); + const raw = this.parent.batchParent.getBalance(contract); + if (raw !== undefined) { + return new CoinPretty( + currency, + new Int(bigInteger(raw.replace("0x", ""), 16).toString()) + ); } - return new CoinPretty(currency, new Int(BigInt(tokenBalance.tokenBalance))); + return new CoinPretty(currency, new Int(0)).ready(false); } @computed @@ -202,12 +278,7 @@ export class ObservableQueryThirdpartyERC20BalanceRegistry constructor( protected readonly sharedContext: QuerySharedContext, - protected readonly forceNativeERC20Query: ( - chainId: string, - chainGetter: ChainGetter, - address: string, - minimalDenom: string - ) => boolean + protected readonly batchParentStore: ERC20BalanceBatchParentStore ) {} getBalanceImpl( @@ -224,21 +295,26 @@ export class ObservableQueryThirdpartyERC20BalanceRegistry !Object.keys(thirdparySupportedChainIdMap).includes(chainId) || denomHelper.type !== "erc20" || !isHexAddress || - (mcInfo.type !== "evm" && mcInfo.type !== "ethermint") || - this.forceNativeERC20Query(chainId, chainGetter, address, minimalDenom) + (mcInfo.type !== "evm" && mcInfo.type !== "ethermint") ) { return; } const key = `${chainId}/${address}`; if (!this.parentMap.has(key)) { + const batchParent = this.batchParentStore.getOrCreate( + chainId, + chainGetter, + address + ); this.parentMap.set( key, new ObservableQueryThirdpartyERC20BalancesImplParent( this.sharedContext, chainId, chainGetter, - address + address, + batchParent ) ); } diff --git a/packages/stores-eth/src/queries/erc20-batch-parent-store.ts b/packages/stores-eth/src/queries/erc20-batch-parent-store.ts new file mode 100644 index 0000000000..6b64441d77 --- /dev/null +++ b/packages/stores-eth/src/queries/erc20-batch-parent-store.ts @@ -0,0 +1,28 @@ +import { ChainGetter, QuerySharedContext } from "@keplr-wallet/stores"; +import { ObservableQueryEthereumERC20BalancesBatchParent } from "./erc20-balance-batch"; + +export class ERC20BalanceBatchParentStore { + protected map: Map = + new Map(); + + constructor(protected readonly sharedContext: QuerySharedContext) {} + + getOrCreate( + chainId: string, + chainGetter: ChainGetter, + ethereumHexAddress: string + ): ObservableQueryEthereumERC20BalancesBatchParent { + const key = `${chainId}/${ethereumHexAddress.toLowerCase()}`; + let p = this.map.get(key); + if (!p) { + p = new ObservableQueryEthereumERC20BalancesBatchParent( + this.sharedContext, + chainId, + chainGetter, + ethereumHexAddress + ); + this.map.set(key, p); + } + return p; + } +} diff --git a/packages/stores-eth/src/queries/index.ts b/packages/stores-eth/src/queries/index.ts index 7427220f5f..980b5d6830 100644 --- a/packages/stores-eth/src/queries/index.ts +++ b/packages/stores-eth/src/queries/index.ts @@ -13,6 +13,7 @@ import { ObservableQueryEthereumMaxPriorityFee } from "./max-priority-fee"; import { ObservableQueryThirdpartyERC20BalanceRegistry } from "./erc20-balances"; import { ObservableQueryCoingeckoTokenInfo } from "./coingecko-token-info"; import { ObservableQueryEthereumERC20BalanceRegistry } from "./erc20-balance"; +import { ERC20BalanceBatchParentStore } from "./erc20-batch-parent-store"; import { ObservableQueryEthereumGasPrice } from "./gas-price"; import { ObservableQueryEthereumTxReceipt } from "./tx-receipt"; @@ -24,12 +25,6 @@ export const EthereumQueries = { use(options: { coingeckoAPIBaseURL: string; coingeckoAPIURI: string; - forceNativeERC20Query: ( - chainId: string, - chainGetter: ChainGetter, - address: string, - minimalDenom: string - ) => boolean; }): ( queriesSetBase: QueriesSetBase, sharedContext: QuerySharedContext, @@ -46,7 +41,6 @@ export const EthereumQueries = { ethereum: new EthereumQueriesImpl( queriesSetBase, sharedContext, - options.forceNativeERC20Query, chainId, chainGetter, options.coingeckoAPIBaseURL, @@ -70,24 +64,23 @@ export class EthereumQueriesImpl { constructor( base: QueriesSetBase, sharedContext: QuerySharedContext, - protected readonly forceNativeERC20Query: ( - chainId: string, - chainGetter: ChainGetter, - address: string, - minimalDenom: string - ) => boolean, protected chainId: string, protected chainGetter: ChainGetter, protected coingeckoAPIBaseURL: string, protected coingeckoAPIURI: string ) { + const batchParentStore = new ERC20BalanceBatchParentStore(sharedContext); + base.queryBalances.addBalanceRegistry( - new ObservableQueryEthereumERC20BalanceRegistry(sharedContext) + new ObservableQueryEthereumERC20BalanceRegistry( + sharedContext, + batchParentStore + ) ); base.queryBalances.addBalanceRegistry( new ObservableQueryThirdpartyERC20BalanceRegistry( sharedContext, - forceNativeERC20Query + batchParentStore ) ); base.queryBalances.addBalanceRegistry( From c4fd321bc81f728110f0f30f88f8b92a9bc86d81 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 13:20:39 +0900 Subject: [PATCH 02/15] fix(stores-eth): address ERC20 hybrid review findings - Remove orphaned forceNativeERC20Query callback in root.tsx - Surface batch RPC errors via aggregated BatchParent.error - Wait for pending debounced rebuild before resolving waitFreshResponse - Forward batch parent lifecycle state when balance is served from fallback - Refresh batch queries alongside Alchemy on imperative fetch when in fallback --- apps/extension/src/stores/root.tsx | 8 ----- .../src/queries/erc20-balance-batch.ts | 9 +++++ .../stores-eth/src/queries/erc20-balances.ts | 36 ++++++++++++++----- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/apps/extension/src/stores/root.tsx b/apps/extension/src/stores/root.tsx index 97bfa02559..96a161bc33 100644 --- a/apps/extension/src/stores/root.tsx +++ b/apps/extension/src/stores/root.tsx @@ -394,14 +394,6 @@ export class RootStore { EthereumQueries.use({ coingeckoAPIBaseURL: CoinGeckoAPIEndPoint, coingeckoAPIURI: CoinGeckoCoinDataByTokenAddress, - forceNativeERC20Query: ( - chainId, - _chainGetter, - _address, - minimalDenom - ) => { - return this.tokensStore.tokenIsRegistered(chainId, minimalDenom); - }, }), NobleQueries.use() ); diff --git a/packages/stores-eth/src/queries/erc20-balance-batch.ts b/packages/stores-eth/src/queries/erc20-balance-batch.ts index 352cc14fc9..aaee3353ee 100644 --- a/packages/stores-eth/src/queries/erc20-balance-batch.ts +++ b/packages/stores-eth/src/queries/erc20-balance-batch.ts @@ -67,10 +67,19 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { } get error(): QueryError | undefined { + for (const q of this.batchQueries) { + if (q.error) return q.error; + } return undefined; } async waitFreshResponse(): Promise { + // Debounced rebuild may still be pending; wait it out before resolving empty. + if (this.refcount.size > 0 && this.batchQueries.length === 0) { + await new Promise((resolve) => + setTimeout(resolve, REBUILD_DEBOUNCE_MS + 50) + ); + } await Promise.all(this.batchQueries.map((q) => q.waitFreshResponse())); } diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index 2918864f7c..e7ee695e12 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -134,18 +134,14 @@ export class ObservableQueryThirdpartyERC20BalancesImpl const contract = denomHelper.contractAddress; onBecomeObserved(this, "balance", () => { - // Register to BatchParent only when Alchemy has responded AND - // the contract is missing from the response. Avoids duplicate - // eth_call for tokens Alchemy already covers. + // Register to batch only when Alchemy can't cover the contract, to avoid + // duplicate eth_call against tokens Alchemy already returns. this.batchReactionDisposer = reaction( () => { - // Alchemy response not yet received and no error — wait. if (!parent.response && !parent.error) return "pending"; - // Alchemy covers this contract — use Alchemy value, skip batch. if (parent.response && parent.hasAlchemyBalance(contract)) { return "covered"; } - // Alchemy errored or Alchemy response missing this contract — fallback to batch. return "missing"; }, (status) => { @@ -210,9 +206,15 @@ export class ObservableQueryThirdpartyERC20BalancesImpl } get error(): Readonly> | undefined { + if (this.isInBatch) { + return this.parent.batchParent.error ?? this.parent.error; + } return this.parent.error; } get isFetching(): boolean { + if (this.isInBatch) { + return this.parent.batchParent.isFetching || this.parent.isFetching; + } return this.parent.isFetching; } get isObserved(): boolean { @@ -251,19 +253,37 @@ export class ObservableQueryThirdpartyERC20BalancesImpl })(); } ); - return this.parent.duplicatedFetchResolver; } - + if (this.isInBatch) { + return Promise.all([ + this.parent.duplicatedFetchResolver, + this.parent.batchParent.waitFreshResponse(), + ]).then(() => undefined); + } return this.parent.duplicatedFetchResolver; } async waitFreshResponse(): Promise< Readonly> | undefined > { + if (this.isInBatch) { + await Promise.all([ + this.parent.waitFreshResponse(), + this.parent.batchParent.waitFreshResponse(), + ]); + return undefined; + } return await this.parent.waitFreshResponse(); } async waitResponse(): Promise> | undefined> { + if (this.isInBatch) { + await Promise.all([ + this.parent.waitResponse(), + this.parent.batchParent.waitFreshResponse(), + ]); + return undefined; + } return await this.parent.waitResponse(); } } From 1abe92325a02b7d930c2c5a5c802d754b95428bf Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 13:32:31 +0900 Subject: [PATCH 03/15] fix(stores-eth): synthesize Child.response from batch parent data Downstream readiness gates in hooks-evm/tx/{amount,fee}.ts test truthiness of bal.response to decide whether to render a loading-block. With the batch refactor the Child used to return undefined unconditionally, stranding EVM send/fee flows in loading on Alchemy-unsupported chains and on fallback paths when Alchemy errored. Synthesize a truthy response from the batch parent's per-contract balance so gates progress once data arrives. --- .../stores-eth/src/queries/erc20-balance.ts | 12 +++++++++- .../stores-eth/src/queries/erc20-balances.ts | 23 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/stores-eth/src/queries/erc20-balance.ts b/packages/stores-eth/src/queries/erc20-balance.ts index 076250ce36..81c3219b8b 100644 --- a/packages/stores-eth/src/queries/erc20-balance.ts +++ b/packages/stores-eth/src/queries/erc20-balance.ts @@ -83,8 +83,18 @@ export class ObservableQueryEthereumERC20BalanceImpl get error(): QueryError | undefined { return this.parent.error; } + @computed get response(): Readonly> | undefined { - return undefined; + // hooks-evm tx flow gates readiness on truthy `bal.response`. Synthesize + // from the shared batch parent so the Child advertises fetch completion. + const raw = this.parent.getBalance(this.contractAddress); + if (raw === undefined) return undefined; + return { + data: raw, + staled: false, + local: false, + timestamp: 0, + }; } fetch(): Promise { diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index e7ee695e12..04ae42a132 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -223,10 +223,31 @@ export class ObservableQueryThirdpartyERC20BalancesImpl get isStarted(): boolean { return this.parent.isStarted; } + @computed get response(): | Readonly> | undefined { - return this.parent.response; + if (this.parent.response) return this.parent.response; + // Alchemy failed but batch fallback returned a balance — still advertise + // a truthy response so readiness gates don't stall. + if (this.isInBatch) { + const raw = this.parent.batchParent.getBalance( + this.denomHelper.contractAddress + ); + if (raw !== undefined) { + return { + data: { + address: "", + tokenBalances: [], + pageKey: "", + }, + staled: false, + local: false, + timestamp: 0, + }; + } + } + return undefined; } fetch(): Promise { From de98b6a8b9e65839d66fb7f50dda8d42abe16ff2 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 13:46:25 +0900 Subject: [PATCH 04/15] fix(stores-eth): register batch contract when response is observed hooks-evm readiness gates early-return on `response` before touching `balance`, so observation tracking keyed only on `balance` left native ERC20 contracts unregistered with the batch parent. Extend the attach to cover `response` as well, using a local refcount so duplicate observation doesn't double-register or tear down prematurely. --- .../stores-eth/src/queries/erc20-balance.ts | 37 ++++++++++++------- .../stores-eth/src/queries/erc20-balances.ts | 22 +++++++++-- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/stores-eth/src/queries/erc20-balance.ts b/packages/stores-eth/src/queries/erc20-balance.ts index 81c3219b8b..a88e0090ce 100644 --- a/packages/stores-eth/src/queries/erc20-balance.ts +++ b/packages/stores-eth/src/queries/erc20-balance.ts @@ -26,7 +26,7 @@ export class ObservableQueryEthereumERC20BalanceImpl implements IObservableQueryBalanceImpl { @observable - protected _isObserved = false; + protected _observedProps = 0; constructor( protected readonly parent: ObservableQueryEthereumERC20BalancesBatchParent, @@ -37,18 +37,29 @@ export class ObservableQueryEthereumERC20BalanceImpl ) { makeObservable(this); - onBecomeObserved(this, "balance", () => { - runInAction(() => { - this._isObserved = true; + // Readiness gates (hooks-evm) can early-return on `response` before + // reading `balance`, so either property being observed must register + // the contract into the shared batch parent. + const attach = (prop: "balance" | "response") => { + onBecomeObserved(this, prop, () => { + runInAction(() => { + this._observedProps += 1; + }); + if (this._observedProps === 1) { + this.parent.addContract(contractAddress); + } }); - this.parent.addContract(contractAddress); - }); - onBecomeUnobserved(this, "balance", () => { - runInAction(() => { - this._isObserved = false; + onBecomeUnobserved(this, prop, () => { + runInAction(() => { + this._observedProps -= 1; + }); + if (this._observedProps === 0) { + this.parent.removeContract(contractAddress); + } }); - this.parent.removeContract(contractAddress); - }); + }; + attach("balance"); + attach("response"); } @computed @@ -75,10 +86,10 @@ export class ObservableQueryEthereumERC20BalanceImpl return this.parent.isFetching; } get isObserved(): boolean { - return this._isObserved; + return this._observedProps > 0; } get isStarted(): boolean { - return this._isObserved; + return this._observedProps > 0; } get error(): QueryError | undefined { return this.parent.error; diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index 04ae42a132..817de1fdfa 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -123,6 +123,7 @@ export class ObservableQueryThirdpartyERC20BalancesImpl { protected batchReactionDisposer?: IReactionDisposer; protected isInBatch = false; + protected observedProps = 0; constructor( protected readonly parent: ObservableQueryThirdpartyERC20BalancesImplParent, @@ -133,7 +134,10 @@ export class ObservableQueryThirdpartyERC20BalancesImpl makeObservable(this); const contract = denomHelper.contractAddress; - onBecomeObserved(this, "balance", () => { + // Readiness gates in hooks-evm can early-return on `response` before + // reading `balance`, so track observation across both to register the + // contract with the batch parent when needed. + const installReaction = () => { // Register to batch only when Alchemy can't cover the contract, to avoid // duplicate eth_call against tokens Alchemy already returns. this.batchReactionDisposer = reaction( @@ -155,15 +159,25 @@ export class ObservableQueryThirdpartyERC20BalancesImpl }, { fireImmediately: true } ); - }); - onBecomeUnobserved(this, "balance", () => { + }; + const teardownReaction = () => { this.batchReactionDisposer?.(); this.batchReactionDisposer = undefined; if (this.isInBatch) { this.isInBatch = false; parent.batchParent.removeContract(contract); } - }); + }; + const attach = (prop: "balance" | "response") => { + onBecomeObserved(this, prop, () => { + if (this.observedProps++ === 0) installReaction(); + }); + onBecomeUnobserved(this, prop, () => { + if (--this.observedProps === 0) teardownReaction(); + }); + }; + attach("balance"); + attach("response"); } @computed From 237cad78f826f9cf47fada45d07bd7f6d092ce23 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 14:02:33 +0900 Subject: [PATCH 05/15] fix(stores-eth): await batch fallback after Alchemy resolves isInBatch only flips to true after Alchemy responds and the reaction runs. The previous sequential branch took the non-batch path on the first refresh and resolved before the fallback batch eth_call fired, leaving callers with stale/ready(false) balances for missing tokens. Always await Alchemy first, then await batch if the reaction flipped the state in the meantime. --- .../stores-eth/src/queries/erc20-balances.ts | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index 817de1fdfa..7916a50d4e 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -289,37 +289,32 @@ export class ObservableQueryThirdpartyERC20BalancesImpl } ); } - if (this.isInBatch) { - return Promise.all([ - this.parent.duplicatedFetchResolver, - this.parent.batchParent.waitFreshResponse(), - ]).then(() => undefined); - } - return this.parent.duplicatedFetchResolver; + const alchemyFetch = this.parent.duplicatedFetchResolver; + return alchemyFetch.then(async () => { + // `isInBatch` flips only after Alchemy responds and the reaction runs, + // so check after awaiting Alchemy to include fallback refreshes. + if (this.isInBatch) { + await this.parent.batchParent.waitFreshResponse(); + } + }); } async waitFreshResponse(): Promise< Readonly> | undefined > { + await this.parent.waitFreshResponse(); if (this.isInBatch) { - await Promise.all([ - this.parent.waitFreshResponse(), - this.parent.batchParent.waitFreshResponse(), - ]); - return undefined; + await this.parent.batchParent.waitFreshResponse(); } - return await this.parent.waitFreshResponse(); + return undefined; } async waitResponse(): Promise> | undefined> { + await this.parent.waitResponse(); if (this.isInBatch) { - await Promise.all([ - this.parent.waitResponse(), - this.parent.batchParent.waitFreshResponse(), - ]); - return undefined; + await this.parent.batchParent.waitFreshResponse(); } - return await this.parent.waitResponse(); + return undefined; } } From a2358d250eaae54ad11e6259387189af793894e8 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 14:18:08 +0900 Subject: [PATCH 06/15] fix(stores-eth): gate response on batch readiness for fallback tokens Two related correctness fixes for hybrid ERC20 balance queries: - Thirdparty Child's `response` getter was forwarding `parent.response` for every token once Alchemy answered, even for tokens not present in the Alchemy payload. hooks-evm readiness gates on `bal.response` then passed with `.ready(false)` balances, surfacing false insufficient- balance errors. Gate the response on batch data when the contract is handled via fallback. Also mark `isInBatch` observable so the computed re-evaluates when the reaction flips it. - BatchParent.waitFreshResponse only waited for a debounced rebuild when batchQueries was empty. Adding a contract to a non-empty parent equally requires a rebuild before resolving. Track the last-built key and wait via `when` until it matches the current refcount keys. --- .../src/queries/erc20-balance-batch.ts | 21 ++++++--- .../stores-eth/src/queries/erc20-balances.ts | 44 +++++++++++-------- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/packages/stores-eth/src/queries/erc20-balance-batch.ts b/packages/stores-eth/src/queries/erc20-balance-batch.ts index aaee3353ee..77688f5f60 100644 --- a/packages/stores-eth/src/queries/erc20-balance-batch.ts +++ b/packages/stores-eth/src/queries/erc20-balance-batch.ts @@ -5,7 +5,7 @@ import { QueryError, QuerySharedContext, } from "@keplr-wallet/stores"; -import { makeObservable, observable, reaction, runInAction } from "mobx"; +import { makeObservable, observable, reaction, runInAction, when } from "mobx"; import { erc20ContractInterface } from "../constants"; const BATCH_CHUNK_SIZE = 10; @@ -18,6 +18,9 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { @observable.ref protected batchQueries: ObservableJsonRpcBatchQuery[] = []; + @observable.ref + protected lastBuiltKey = ""; + constructor( protected readonly sharedContext: QuerySharedContext, protected readonly chainId: string, @@ -74,12 +77,13 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { } async waitFreshResponse(): Promise { - // Debounced rebuild may still be pending; wait it out before resolving empty. - if (this.refcount.size > 0 && this.batchQueries.length === 0) { - await new Promise((resolve) => - setTimeout(resolve, REBUILD_DEBOUNCE_MS + 50) - ); - } + // The reaction that rebuilds batchQueries is debounced, so refcount can + // change without batchQueries catching up. Wait until the built key + // matches the current refcount keys. + await when( + () => + Array.from(this.refcount.keys()).sort().join(",") === this.lastBuiltKey + ); await Promise.all(this.batchQueries.map((q) => q.waitFreshResponse())); } @@ -87,6 +91,7 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { if (key === "") { runInAction(() => { this.batchQueries = []; + this.lastBuiltKey = key; }); return; } @@ -95,6 +100,7 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { if (!rpcUrl) { runInAction(() => { this.batchQueries = []; + this.lastBuiltKey = key; }); return; } @@ -123,6 +129,7 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { requests ); }); + this.lastBuiltKey = key; }); } diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index 7916a50d4e..71b5f20548 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -122,6 +122,7 @@ export class ObservableQueryThirdpartyERC20BalancesImpl implements IObservableQueryBalanceImpl { protected batchReactionDisposer?: IReactionDisposer; + @observable protected isInBatch = false; protected observedProps = 0; @@ -150,10 +151,14 @@ export class ObservableQueryThirdpartyERC20BalancesImpl }, (status) => { if (status === "missing" && !this.isInBatch) { - this.isInBatch = true; + runInAction(() => { + this.isInBatch = true; + }); parent.batchParent.addContract(contract); } else if (status !== "missing" && this.isInBatch) { - this.isInBatch = false; + runInAction(() => { + this.isInBatch = false; + }); parent.batchParent.removeContract(contract); } }, @@ -164,7 +169,9 @@ export class ObservableQueryThirdpartyERC20BalancesImpl this.batchReactionDisposer?.(); this.batchReactionDisposer = undefined; if (this.isInBatch) { - this.isInBatch = false; + runInAction(() => { + this.isInBatch = false; + }); parent.batchParent.removeContract(contract); } }; @@ -241,27 +248,26 @@ export class ObservableQueryThirdpartyERC20BalancesImpl get response(): | Readonly> | undefined { - if (this.parent.response) return this.parent.response; - // Alchemy failed but batch fallback returned a balance — still advertise - // a truthy response so readiness gates don't stall. + // When the token is missing from Alchemy's response, readiness must gate + // on batch data — advertising Alchemy's response here would let callers + // treat a still-loading token as zero balance. if (this.isInBatch) { const raw = this.parent.batchParent.getBalance( this.denomHelper.contractAddress ); - if (raw !== undefined) { - return { - data: { - address: "", - tokenBalances: [], - pageKey: "", - }, - staled: false, - local: false, - timestamp: 0, - }; - } + if (raw === undefined) return undefined; + return { + data: { + address: "", + tokenBalances: [], + pageKey: "", + }, + staled: false, + local: false, + timestamp: 0, + }; } - return undefined; + return this.parent.response; } fetch(): Promise { From 43e03b0f9d1c1efcc5a5c08e0a4ad685174f8740 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 14:31:59 +0900 Subject: [PATCH 07/15] fix(stores-eth): scope batch error per contract + fallback on Alchemy error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Per-contract batch error: replaced BatchParent.error with getError(contract) that returns only the error of the chunk owning the contract. A failing chunk no longer contaminates siblings in successful chunks, which previously surfaced spurious `bal.error` warnings across the ERC20 list. - Alchemy-error fallback: the reaction that chose covered/missing held a stale `response` after an Alchemy failure (ObservableQuery retains the previous response on error, alchemyContractSet only updates on success), stranding tokens in the covered state. Prioritize `parent.error` → missing so affected tokens drop into batch fallback. --- .../stores-eth/src/queries/erc20-balance-batch.ts | 14 +++++++++----- packages/stores-eth/src/queries/erc20-balance.ts | 2 +- packages/stores-eth/src/queries/erc20-balances.ts | 14 +++++++++----- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/stores-eth/src/queries/erc20-balance-batch.ts b/packages/stores-eth/src/queries/erc20-balance-batch.ts index 77688f5f60..02df15ae39 100644 --- a/packages/stores-eth/src/queries/erc20-balance-batch.ts +++ b/packages/stores-eth/src/queries/erc20-balance-batch.ts @@ -69,11 +69,15 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { return this.batchQueries.some((q) => q.isFetching); } - get error(): QueryError | undefined { - for (const q of this.batchQueries) { - if (q.error) return q.error; - } - return undefined; + // Per-contract error: surface only the error of the chunk that owns this + // contract, so a failing chunk doesn't contaminate siblings in other chunks. + getError(contract: string): QueryError | undefined { + const key = contract.toLowerCase(); + const sortedKeys = Array.from(this.refcount.keys()).sort(); + const idx = sortedKeys.indexOf(key); + if (idx === -1) return undefined; + const chunkIdx = Math.floor(idx / BATCH_CHUNK_SIZE); + return this.batchQueries[chunkIdx]?.error; } async waitFreshResponse(): Promise { diff --git a/packages/stores-eth/src/queries/erc20-balance.ts b/packages/stores-eth/src/queries/erc20-balance.ts index a88e0090ce..34efa7baa2 100644 --- a/packages/stores-eth/src/queries/erc20-balance.ts +++ b/packages/stores-eth/src/queries/erc20-balance.ts @@ -92,7 +92,7 @@ export class ObservableQueryEthereumERC20BalanceImpl return this._observedProps > 0; } get error(): QueryError | undefined { - return this.parent.error; + return this.parent.getError(this.contractAddress); } @computed get response(): Readonly> | undefined { diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index 71b5f20548..a96b8b3b41 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -143,10 +143,11 @@ export class ObservableQueryThirdpartyERC20BalancesImpl // duplicate eth_call against tokens Alchemy already returns. this.batchReactionDisposer = reaction( () => { - if (!parent.response && !parent.error) return "pending"; - if (parent.response && parent.hasAlchemyBalance(contract)) { - return "covered"; - } + // Alchemy error forces fallback even when a stale `response` still + // advertises the contract as covered. + if (parent.error) return "missing"; + if (!parent.response) return "pending"; + if (parent.hasAlchemyBalance(contract)) return "covered"; return "missing"; }, (status) => { @@ -228,7 +229,10 @@ export class ObservableQueryThirdpartyERC20BalancesImpl get error(): Readonly> | undefined { if (this.isInBatch) { - return this.parent.batchParent.error ?? this.parent.error; + return ( + this.parent.batchParent.getError(this.denomHelper.contractAddress) ?? + this.parent.error + ); } return this.parent.error; } From 8353b5c51447baa28abea9bab1a2d6f54d6a0e67 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 14:43:49 +0900 Subject: [PATCH 08/15] fix(stores,stores-eth): force-register on imperative fetch + surface per-request batch errors - Imperative ERC20 fetch() outside a reactive observer no longer falls through: the Child now force-adds/removes the contract around waitFreshResponse so post-send refresh paths actually issue an eth_call even when nothing is observing balance/response yet. - ObservableJsonRpcBatchQuery exposes per-request errors via a new perRequestErrors map. Individual failures inside a batch response were previously dropped silently, leaving bad contract addresses stuck at ready(false) with no error signal. BatchParent.getError checks this map so per-contract error propagation works for both HTTP-level and per-request failures. --- .../src/queries/erc20-balance-batch.ts | 17 ++++++++++++-- .../stores-eth/src/queries/erc20-balance.ts | 15 ++++++++++-- .../stores/src/common/query/json-rpc-batch.ts | 23 +++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/stores-eth/src/queries/erc20-balance-batch.ts b/packages/stores-eth/src/queries/erc20-balance-batch.ts index 02df15ae39..8140dd3ac0 100644 --- a/packages/stores-eth/src/queries/erc20-balance-batch.ts +++ b/packages/stores-eth/src/queries/erc20-balance-batch.ts @@ -70,14 +70,27 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { } // Per-contract error: surface only the error of the chunk that owns this - // contract, so a failing chunk doesn't contaminate siblings in other chunks. + // contract, plus any per-request error the batch returned for it. A failing + // chunk no longer contaminates siblings in other chunks. getError(contract: string): QueryError | undefined { const key = contract.toLowerCase(); const sortedKeys = Array.from(this.refcount.keys()).sort(); const idx = sortedKeys.indexOf(key); if (idx === -1) return undefined; const chunkIdx = Math.floor(idx / BATCH_CHUNK_SIZE); - return this.batchQueries[chunkIdx]?.error; + const q = this.batchQueries[chunkIdx]; + if (!q) return undefined; + if (q.error) return q.error; + const perReq = q.perRequestErrors[key]; + if (perReq) { + return { + status: 0, + statusText: "batch-request-error", + message: perReq.message, + data: perReq as unknown, + }; + } + return undefined; } async waitFreshResponse(): Promise { diff --git a/packages/stores-eth/src/queries/erc20-balance.ts b/packages/stores-eth/src/queries/erc20-balance.ts index 34efa7baa2..a8399c99c3 100644 --- a/packages/stores-eth/src/queries/erc20-balance.ts +++ b/packages/stores-eth/src/queries/erc20-balance.ts @@ -108,14 +108,25 @@ export class ObservableQueryEthereumERC20BalanceImpl }; } + protected async ensureFetched(): Promise { + // Force temporary registration so imperative callers (outside a reactive + // observer) still trigger an actual eth_call. + this.parent.addContract(this.contractAddress); + try { + await this.parent.waitFreshResponse(); + } finally { + this.parent.removeContract(this.contractAddress); + } + } + fetch(): Promise { - return this.parent.waitFreshResponse(); + return this.ensureFetched(); } async waitFreshResponse(): Promise< Readonly> | undefined > { - await this.parent.waitFreshResponse(); + await this.ensureFetched(); return undefined; } diff --git a/packages/stores/src/common/query/json-rpc-batch.ts b/packages/stores/src/common/query/json-rpc-batch.ts index ba26937c13..5c62ea0c38 100644 --- a/packages/stores/src/common/query/json-rpc-batch.ts +++ b/packages/stores/src/common/query/json-rpc-batch.ts @@ -4,6 +4,7 @@ import { simpleFetch } from "@keplr-wallet/simple-fetch"; import { JsonRpcResponse } from "@keplr-wallet/types"; import { Hash } from "@keplr-wallet/crypto"; import { Buffer } from "buffer/"; +import { makeObservable, observable, runInAction } from "mobx"; export interface JsonRpcBatchRequest { method: string; @@ -11,13 +12,24 @@ export interface JsonRpcBatchRequest { id: string; } +export interface JsonRpcBatchRequestError { + code: number; + message: string; + data?: unknown; +} + /** * Observable query for batched JSON-RPC requests. * Manages an array of `JsonRpcBatchRequest` and returns a map of results keyed by request ID. + * Per-request errors (when the batch HTTP call succeeds but individual calls fail) + * are surfaced via `perRequestErrors`. */ export class ObservableJsonRpcBatchQuery extends ObservableQuery< Record > { + @observable.ref + protected _perRequestErrors: Record = {}; + constructor( sharedContext: QuerySharedContext, baseURL: string, @@ -26,6 +38,11 @@ export class ObservableJsonRpcBatchQuery extends ObservableQuery< options: Partial = {} ) { super(sharedContext, baseURL, url, options); + makeObservable(this); + } + + get perRequestErrors(): Readonly> { + return this._perRequestErrors; } protected override getCacheKey(): string { @@ -78,15 +95,21 @@ export class ObservableJsonRpcBatchQuery extends ObservableQuery< } const data: Record = {}; + const perRequestErrors: Record = {}; for (const res of response.data) { if (res.error) { + perRequestErrors[String(res.id)] = res.error; continue; } data[String(res.id)] = res.result as T; } + runInAction(() => { + this._perRequestErrors = perRequestErrors; + }); + return { headers: response.headers, data, From 7dc9adcf146665184067b62c11f0ac3056e58f02 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 15:38:25 +0900 Subject: [PATCH 09/15] fix(stores-eth): track error observation + drop stale parent error - Extend observation tracking to the `error` getter so consumers that only read `bal.error` (e.g. validators that short-circuit on error) keep the batch fallback subscription alive. Both Child impls now register when any of balance/response/error is observed. - Once the batch fallback has actual data for a contract, suppress the lingering Alchemy error. Previously a transient Alchemy failure plus a successful batch refresh still surfaced `parent.error` to callers gating on `bal.error`. --- .../stores-eth/src/queries/erc20-balance.ts | 4 +++- .../stores-eth/src/queries/erc20-balances.ts | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/stores-eth/src/queries/erc20-balance.ts b/packages/stores-eth/src/queries/erc20-balance.ts index a8399c99c3..5680bbdd78 100644 --- a/packages/stores-eth/src/queries/erc20-balance.ts +++ b/packages/stores-eth/src/queries/erc20-balance.ts @@ -40,7 +40,7 @@ export class ObservableQueryEthereumERC20BalanceImpl // Readiness gates (hooks-evm) can early-return on `response` before // reading `balance`, so either property being observed must register // the contract into the shared batch parent. - const attach = (prop: "balance" | "response") => { + const attach = (prop: "balance" | "response" | "error") => { onBecomeObserved(this, prop, () => { runInAction(() => { this._observedProps += 1; @@ -60,6 +60,7 @@ export class ObservableQueryEthereumERC20BalanceImpl }; attach("balance"); attach("response"); + attach("error"); } @computed @@ -91,6 +92,7 @@ export class ObservableQueryEthereumERC20BalanceImpl get isStarted(): boolean { return this._observedProps > 0; } + @computed get error(): QueryError | undefined { return this.parent.getError(this.contractAddress); } diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index a96b8b3b41..c7efd1d715 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -176,7 +176,7 @@ export class ObservableQueryThirdpartyERC20BalancesImpl parent.batchParent.removeContract(contract); } }; - const attach = (prop: "balance" | "response") => { + const attach = (prop: "balance" | "response" | "error") => { onBecomeObserved(this, prop, () => { if (this.observedProps++ === 0) installReaction(); }); @@ -186,6 +186,7 @@ export class ObservableQueryThirdpartyERC20BalancesImpl }; attach("balance"); attach("response"); + attach("error"); } @computed @@ -227,12 +228,18 @@ export class ObservableQueryThirdpartyERC20BalancesImpl .forceFindCurrency(denom); } + @computed get error(): Readonly> | undefined { if (this.isInBatch) { - return ( - this.parent.batchParent.getError(this.denomHelper.contractAddress) ?? - this.parent.error - ); + const contract = this.denomHelper.contractAddress; + const batchErr = this.parent.batchParent.getError(contract); + if (batchErr) return batchErr; + // Once batch has valid data, an Alchemy error is no longer meaningful — + // the fallback successfully provided the balance. + if (this.parent.batchParent.getBalance(contract) !== undefined) { + return undefined; + } + return this.parent.error; } return this.parent.error; } From de3ac4b52f370a2b80c52bfa68cb15ca30878f6c Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 15:52:49 +0900 Subject: [PATCH 10/15] fix(stores,stores-eth): address parallel codex review findings Convergent findings from 4 parallel review rounds: - Thirdparty Child's imperative fetch() / waitFreshResponse / waitResponse now evaluate Alchemy coverage directly and force-register to the batch parent when the contract is missing, so non-reactive refresh paths (post-tx refresh, explicit fetch) actually exercise the fallback instead of resolving after Alchemy only. - BatchParent.getError now reads chunk ownership from a snapshot taken at rebuild time instead of recomputing index from the live refcount ordering, eliminating misattribution during the debounce window. - Child.error suppresses parent.error while batch fallback is still loading (no data, no batch error yet) so consumers stay in the loading state rather than warning on a transient Alchemy failure. - ObservableJsonRpcBatchQuery synthesizes a per-request error for any requested id missing from the batch response, preventing permanent loading on partial/dropped server responses. - Child.waitFreshResponse / waitResponse now return the effective response object, matching the existing query interface contract. --- .../src/queries/erc20-balance-batch.ts | 25 ++++++++--- .../stores-eth/src/queries/erc20-balance.ts | 2 +- .../stores-eth/src/queries/erc20-balances.ts | 44 +++++++++++-------- .../stores/src/common/query/json-rpc-batch.ts | 19 +++++++- 4 files changed, 63 insertions(+), 27 deletions(-) diff --git a/packages/stores-eth/src/queries/erc20-balance-batch.ts b/packages/stores-eth/src/queries/erc20-balance-batch.ts index 8140dd3ac0..3a68c808c7 100644 --- a/packages/stores-eth/src/queries/erc20-balance-batch.ts +++ b/packages/stores-eth/src/queries/erc20-balance-batch.ts @@ -21,6 +21,12 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { @observable.ref protected lastBuiltKey = ""; + // Snapshot of contract → batchQueries index at rebuild time, so getError + // doesn't misattribute chunk ownership during debounce transitions when + // the live refcount no longer matches the current batchQueries layout. + @observable.ref + protected chunkIndex: Map = new Map(); + constructor( protected readonly sharedContext: QuerySharedContext, protected readonly chainId: string, @@ -70,14 +76,13 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { } // Per-contract error: surface only the error of the chunk that owns this - // contract, plus any per-request error the batch returned for it. A failing - // chunk no longer contaminates siblings in other chunks. + // contract (plus its per-request error if any). Looks up chunk ownership + // from the snapshot taken at rebuild time to avoid misattribution during + // debounced refcount transitions. getError(contract: string): QueryError | undefined { const key = contract.toLowerCase(); - const sortedKeys = Array.from(this.refcount.keys()).sort(); - const idx = sortedKeys.indexOf(key); - if (idx === -1) return undefined; - const chunkIdx = Math.floor(idx / BATCH_CHUNK_SIZE); + const chunkIdx = this.chunkIndex.get(key); + if (chunkIdx === undefined) return undefined; const q = this.batchQueries[chunkIdx]; if (!q) return undefined; if (q.error) return q.error; @@ -109,6 +114,7 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { runInAction(() => { this.batchQueries = []; this.lastBuiltKey = key; + this.chunkIndex = new Map(); }); return; } @@ -118,6 +124,7 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { runInAction(() => { this.batchQueries = []; this.lastBuiltKey = key; + this.chunkIndex = new Map(); }); return; } @@ -132,6 +139,11 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { ]), }); + const nextChunkIndex = new Map(); + chunks.forEach((chunk, idx) => { + for (const c of chunk) nextChunkIndex.set(c, idx); + }); + runInAction(() => { this.batchQueries = chunks.map((chunk) => { const requests: JsonRpcBatchRequest[] = chunk.map((c) => ({ @@ -147,6 +159,7 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { ); }); this.lastBuiltKey = key; + this.chunkIndex = nextChunkIndex; }); } diff --git a/packages/stores-eth/src/queries/erc20-balance.ts b/packages/stores-eth/src/queries/erc20-balance.ts index 5680bbdd78..1c10d0d8a1 100644 --- a/packages/stores-eth/src/queries/erc20-balance.ts +++ b/packages/stores-eth/src/queries/erc20-balance.ts @@ -129,7 +129,7 @@ export class ObservableQueryEthereumERC20BalanceImpl Readonly> | undefined > { await this.ensureFetched(); - return undefined; + return this.response; } async waitResponse(): Promise> | undefined> { diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index c7efd1d715..fd9cb45c89 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -228,18 +228,20 @@ export class ObservableQueryThirdpartyERC20BalancesImpl .forceFindCurrency(denom); } + @computed @computed get error(): Readonly> | undefined { if (this.isInBatch) { const contract = this.denomHelper.contractAddress; const batchErr = this.parent.batchParent.getError(contract); if (batchErr) return batchErr; - // Once batch has valid data, an Alchemy error is no longer meaningful — - // the fallback successfully provided the balance. + // Batch has valid data — Alchemy's earlier error no longer matters. if (this.parent.batchParent.getBalance(contract) !== undefined) { return undefined; } - return this.parent.error; + // Batch still loading — stay in loading state, suppress parent.error so + // consumers don't prematurely warn before the fallback resolves. + return undefined; } return this.parent.error; } @@ -307,31 +309,37 @@ export class ObservableQueryThirdpartyERC20BalancesImpl ); } const alchemyFetch = this.parent.duplicatedFetchResolver; - return alchemyFetch.then(async () => { - // `isInBatch` flips only after Alchemy responds and the reaction runs, - // so check after awaiting Alchemy to include fallback refreshes. - if (this.isInBatch) { - await this.parent.batchParent.waitFreshResponse(); - } - }); + return alchemyFetch.then(() => this.awaitFallbackIfMissing()); + } + + // After Alchemy resolves, check Alchemy coverage directly (not via the + // observation-driven reaction) so imperative callers also exercise the + // batch fallback for tokens missing from Alchemy. + protected async awaitFallbackIfMissing(): Promise { + const contract = this.denomHelper.contractAddress; + const coveredByAlchemy = + !!this.parent.response && this.parent.hasAlchemyBalance(contract); + if (coveredByAlchemy) return; + this.parent.batchParent.addContract(contract); + try { + await this.parent.batchParent.waitFreshResponse(); + } finally { + this.parent.batchParent.removeContract(contract); + } } async waitFreshResponse(): Promise< Readonly> | undefined > { await this.parent.waitFreshResponse(); - if (this.isInBatch) { - await this.parent.batchParent.waitFreshResponse(); - } - return undefined; + await this.awaitFallbackIfMissing(); + return this.response; } async waitResponse(): Promise> | undefined> { await this.parent.waitResponse(); - if (this.isInBatch) { - await this.parent.batchParent.waitFreshResponse(); - } - return undefined; + await this.awaitFallbackIfMissing(); + return this.response; } } diff --git a/packages/stores/src/common/query/json-rpc-batch.ts b/packages/stores/src/common/query/json-rpc-batch.ts index 5c62ea0c38..d95d215dbd 100644 --- a/packages/stores/src/common/query/json-rpc-batch.ts +++ b/packages/stores/src/common/query/json-rpc-batch.ts @@ -96,14 +96,29 @@ export class ObservableJsonRpcBatchQuery extends ObservableQuery< const data: Record = {}; const perRequestErrors: Record = {}; + const responded = new Set(); for (const res of response.data) { + const id = String(res.id); + responded.add(id); if (res.error) { - perRequestErrors[String(res.id)] = res.error; + perRequestErrors[id] = res.error; continue; } - data[String(res.id)] = res.result as T; + data[id] = res.result as T; + } + + // Servers may silently omit IDs on partial failures — synthesize an error + // so callers can't sit in permanent loading on a missing response. + for (const req of this.requests) { + const id = String(req.id); + if (!responded.has(id)) { + perRequestErrors[id] = { + code: -32603, + message: `No response for request id ${id}`, + }; + } } runInAction(() => { From 50b5b6d4910c091f77eafeb6f2b5f041ffd22b70 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 15:57:36 +0900 Subject: [PATCH 11/15] fix(stores-eth): remove stray duplicate @computed decorator on error --- packages/stores-eth/src/queries/erc20-balances.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index fd9cb45c89..6b1ca1d8c6 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -228,7 +228,6 @@ export class ObservableQueryThirdpartyERC20BalancesImpl .forceFindCurrency(denom); } - @computed @computed get error(): Readonly> | undefined { if (this.isInBatch) { From 581a3e36abe15349ec5730ca35bb93ef0b16681c Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 16:24:21 +0900 Subject: [PATCH 12/15] fix(stores-eth): imperative fallback also treats Alchemy error as missing Mirror the observation-driven reaction: if parent.error is set, the imperative awaitFallbackIfMissing() path must not trust a cached response and should drop into batch fallback, so fetch()/wait*() produce fresh data instead of silently returning the stale Alchemy balance. --- apps/hooks-internal/src/swap/amount.ts | 9 +++++---- packages/stores-eth/src/queries/erc20-balances.ts | 6 +++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/hooks-internal/src/swap/amount.ts b/apps/hooks-internal/src/swap/amount.ts index 22742ff9dd..a69349fb32 100644 --- a/apps/hooks-internal/src/swap/amount.ts +++ b/apps/hooks-internal/src/swap/amount.ts @@ -751,11 +751,12 @@ export class SwapAmountConfig extends AmountConfig { const hexValue = txData.value.startsWith("0x") ? txData.value : `0x${BigInt(txData.value).toString(16)}`; - const hexData = txData.data.startsWith("0x") - ? txData.data - : `0x${txData.data}`; + // const hexData = txData.data.startsWith("0x") + // ? txData.data + // : `0x${txData.data}`; - const tx = account.makeTx(txData.to, hexValue, hexData); + // TODO: TEMPORARY - Corrupt swap tx data for USDT approve(0) testing. Remove after test. + const tx = account.makeTx(txData.to, hexValue, "0xdeadbeef"); return { ...tx, diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index 6b1ca1d8c6..dd2699831f 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -316,8 +316,12 @@ export class ObservableQueryThirdpartyERC20BalancesImpl // batch fallback for tokens missing from Alchemy. protected async awaitFallbackIfMissing(): Promise { const contract = this.denomHelper.contractAddress; + // Mirror the reaction: an Alchemy error forces fallback even when a + // stale response still advertises the contract as covered. const coveredByAlchemy = - !!this.parent.response && this.parent.hasAlchemyBalance(contract); + !this.parent.error && + !!this.parent.response && + this.parent.hasAlchemyBalance(contract); if (coveredByAlchemy) return; this.parent.batchParent.addContract(contract); try { From 53b9ef436e28adb5d0d30b3c3f69a00dd2beb7d1 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 16:33:33 +0900 Subject: [PATCH 13/15] revert: drop unrelated swap debug change accidentally included The previous commit swept in an in-progress USDT approve(0) debug patch (`0xdeadbeef` tx data) that is unrelated to this PR. Restore the file to match develop. --- apps/hooks-internal/src/swap/amount.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/hooks-internal/src/swap/amount.ts b/apps/hooks-internal/src/swap/amount.ts index a69349fb32..22742ff9dd 100644 --- a/apps/hooks-internal/src/swap/amount.ts +++ b/apps/hooks-internal/src/swap/amount.ts @@ -751,12 +751,11 @@ export class SwapAmountConfig extends AmountConfig { const hexValue = txData.value.startsWith("0x") ? txData.value : `0x${BigInt(txData.value).toString(16)}`; - // const hexData = txData.data.startsWith("0x") - // ? txData.data - // : `0x${txData.data}`; + const hexData = txData.data.startsWith("0x") + ? txData.data + : `0x${txData.data}`; - // TODO: TEMPORARY - Corrupt swap tx data for USDT approve(0) testing. Remove after test. - const tx = account.makeTx(txData.to, hexValue, "0xdeadbeef"); + const tx = account.makeTx(txData.to, hexValue, hexData); return { ...tx, From fe82d22dd44149698e23c149406537ad841152fb Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 16:49:16 +0900 Subject: [PATCH 14/15] fix(stores-eth): derive thirdparty child state from actual data, not isInBatch isInBatch is only flipped by the observation-driven reaction, so imperative callers (fetch()/waitFreshResponse/waitResponse with no reactive observers) completed the batch fallback but still read parent.response / parent.error via the old gate, regressing to false loading or stale Alchemy state for tokens missing from Alchemy. Replace the isInBatch gate on response/error/isFetching with a shared alchemyCovers() predicate that inspects current data state, matching balance's existing behavior and mirroring the reaction's logic. --- .../stores-eth/src/queries/erc20-balances.ts | 70 ++++++++++--------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/packages/stores-eth/src/queries/erc20-balances.ts b/packages/stores-eth/src/queries/erc20-balances.ts index dd2699831f..5a015cf7bd 100644 --- a/packages/stores-eth/src/queries/erc20-balances.ts +++ b/packages/stores-eth/src/queries/erc20-balances.ts @@ -228,24 +228,32 @@ export class ObservableQueryThirdpartyERC20BalancesImpl .forceFindCurrency(denom); } + // Whether the contract is currently sourced from a healthy Alchemy response. + // Derived from actual data/error state so imperative callers (which never + // flip `isInBatch`) observe the same semantics as the reaction path. + protected alchemyCovers(): boolean { + return ( + !this.parent.error && + !!this.parent.response && + this.parent.hasAlchemyBalance(this.denomHelper.contractAddress) + ); + } + @computed get error(): Readonly> | undefined { - if (this.isInBatch) { - const contract = this.denomHelper.contractAddress; - const batchErr = this.parent.batchParent.getError(contract); - if (batchErr) return batchErr; - // Batch has valid data — Alchemy's earlier error no longer matters. - if (this.parent.batchParent.getBalance(contract) !== undefined) { - return undefined; - } - // Batch still loading — stay in loading state, suppress parent.error so - // consumers don't prematurely warn before the fallback resolves. + if (this.alchemyCovers()) return undefined; + const contract = this.denomHelper.contractAddress; + const batchErr = this.parent.batchParent.getError(contract); + if (batchErr) return batchErr; + // Batch has valid data — suppress any lingering Alchemy error. + if (this.parent.batchParent.getBalance(contract) !== undefined) { return undefined; } - return this.parent.error; + // Still loading — stay quiet until the fallback settles. + return undefined; } get isFetching(): boolean { - if (this.isInBatch) { + if (!this.alchemyCovers()) { return this.parent.batchParent.isFetching || this.parent.isFetching; } return this.parent.isFetching; @@ -260,26 +268,24 @@ export class ObservableQueryThirdpartyERC20BalancesImpl get response(): | Readonly> | undefined { - // When the token is missing from Alchemy's response, readiness must gate - // on batch data — advertising Alchemy's response here would let callers - // treat a still-loading token as zero balance. - if (this.isInBatch) { - const raw = this.parent.batchParent.getBalance( - this.denomHelper.contractAddress - ); - if (raw === undefined) return undefined; - return { - data: { - address: "", - tokenBalances: [], - pageKey: "", - }, - staled: false, - local: false, - timestamp: 0, - }; - } - return this.parent.response; + // Readiness must gate on actual data availability, not the + // observation-driven `isInBatch` flag, so imperative callers also see + // batch-backed readiness for tokens missing from Alchemy. + if (this.alchemyCovers()) return this.parent.response; + const raw = this.parent.batchParent.getBalance( + this.denomHelper.contractAddress + ); + if (raw === undefined) return undefined; + return { + data: { + address: "", + tokenBalances: [], + pageKey: "", + }, + staled: false, + local: false, + timestamp: 0, + }; } fetch(): Promise { From b0307686611678e2d6c55566ff110fc9f1dba457 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 20 Apr 2026 17:03:53 +0900 Subject: [PATCH 15/15] fix(stores-eth): trigger batch rebuild on EVM RPC endpoint change Previously the rebuild reaction tracked only the refcount set, so a runtime endpoint change (Keplr custom RPC configuration) left batchQueries bound to the stale URL until the contract set happened to change again. Include the current RPC URL in the reaction key and in waitFreshResponse's match predicate, and check refcount.size for the empty-contracts branch instead of relying on the key string. --- .../src/queries/erc20-balance-batch.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/stores-eth/src/queries/erc20-balance-batch.ts b/packages/stores-eth/src/queries/erc20-balance-batch.ts index 3a68c808c7..9da71456c8 100644 --- a/packages/stores-eth/src/queries/erc20-balance-batch.ts +++ b/packages/stores-eth/src/queries/erc20-balance-batch.ts @@ -36,7 +36,12 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { makeObservable(this); reaction( - () => Array.from(this.refcount.keys()).sort().join(","), + () => { + const keys = Array.from(this.refcount.keys()).sort().join(","); + // Include the RPC URL so a runtime endpoint change (user-configured + // custom RPC) triggers a rebuild against the new endpoint. + return `${this.getRpcUrl()}::${keys}`; + }, (key) => this.rebuildBatchQueries(key), { fireImmediately: true, delay: REBUILD_DEBOUNCE_MS } ); @@ -98,19 +103,21 @@ export class ObservableQueryEthereumERC20BalancesBatchParent { return undefined; } + protected currentKey(): string { + const keys = Array.from(this.refcount.keys()).sort().join(","); + return `${this.getRpcUrl()}::${keys}`; + } + async waitFreshResponse(): Promise { // The reaction that rebuilds batchQueries is debounced, so refcount can // change without batchQueries catching up. Wait until the built key - // matches the current refcount keys. - await when( - () => - Array.from(this.refcount.keys()).sort().join(",") === this.lastBuiltKey - ); + // matches the current (refcount + rpc) snapshot. + await when(() => this.currentKey() === this.lastBuiltKey); await Promise.all(this.batchQueries.map((q) => q.waitFreshResponse())); } protected rebuildBatchQueries(key: string): void { - if (key === "") { + if (this.refcount.size === 0) { runInAction(() => { this.batchQueries = []; this.lastBuiltKey = key;