diff --git a/apps/extension/src/stores/root.tsx b/apps/extension/src/stores/root.tsx index 662b26adbf..96a161bc33 100644 --- a/apps/extension/src/stores/root.tsx +++ b/apps/extension/src/stores/root.tsx @@ -394,22 +394,6 @@ export class RootStore { EthereumQueries.use({ coingeckoAPIBaseURL: CoinGeckoAPIEndPoint, coingeckoAPIURI: CoinGeckoCoinDataByTokenAddress, - forceNativeERC20Query: ( - chainId, - _chainGetter, - _address, - minimalDenom - ) => { - // Base의 axlUSDC만 밸런스를 가지고 올 수 없는 문제가 있어서 우선 하드코딩으로 처리 - if ( - chainId === "eip155:8453" && - minimalDenom === "erc20:0xeb466342c4d449bc9f53a865d5cb90586f405215" - ) { - return true; - } - - 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 new file mode 100644 index 0000000000..9da71456c8 --- /dev/null +++ b/packages/stores-eth/src/queries/erc20-balance-batch.ts @@ -0,0 +1,185 @@ +import { + ChainGetter, + JsonRpcBatchRequest, + ObservableJsonRpcBatchQuery, + QueryError, + QuerySharedContext, +} from "@keplr-wallet/stores"; +import { makeObservable, observable, reaction, runInAction, when } 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[] = []; + + @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, + protected readonly chainGetter: ChainGetter, + protected readonly ethereumHexAddress: string + ) { + makeObservable(this); + + reaction( + () => { + 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 } + ); + } + + 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); + } + + // Per-contract error: surface only the error of the chunk that owns this + // 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 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; + const perReq = q.perRequestErrors[key]; + if (perReq) { + return { + status: 0, + statusText: "batch-request-error", + message: perReq.message, + data: perReq as unknown, + }; + } + 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 + rpc) snapshot. + await when(() => this.currentKey() === this.lastBuiltKey); + await Promise.all(this.batchQueries.map((q) => q.waitFreshResponse())); + } + + protected rebuildBatchQueries(key: string): void { + if (this.refcount.size === 0) { + runInAction(() => { + this.batchQueries = []; + this.lastBuiltKey = key; + this.chunkIndex = new Map(); + }); + return; + } + + const rpcUrl = this.getRpcUrl(); + if (!rpcUrl) { + runInAction(() => { + this.batchQueries = []; + this.lastBuiltKey = key; + this.chunkIndex = new Map(); + }); + 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, + ]), + }); + + 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) => ({ + method: "eth_call", + params: [calldata(c), "latest"], + id: c, + })); + return new ObservableJsonRpcBatchQuery( + this.sharedContext, + rpcUrl, + "", + requests + ); + }); + this.lastBuiltKey = key; + this.chunkIndex = nextChunkIndex; + }); + } + + 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..1c10d0d8a1 100644 --- a/packages/stores-eth/src/queries/erc20-balance.ts +++ b/packages/stores-eth/src/queries/erc20-balance.ts @@ -2,78 +2,148 @@ 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 _observedProps = 0; + 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); + + // 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" | "error") => { + onBecomeObserved(this, prop, () => { + runInAction(() => { + this._observedProps += 1; + }); + if (this._observedProps === 1) { + this.parent.addContract(contractAddress); + } + }); + onBecomeUnobserved(this, prop, () => { + runInAction(() => { + this._observedProps -= 1; + }); + if (this._observedProps === 0) { + this.parent.removeContract(contractAddress); + } + }); + }; + attach("balance"); + attach("response"); + attach("error"); } @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._observedProps > 0; + } + get isStarted(): boolean { + return this._observedProps > 0; + } + @computed + get error(): QueryError | undefined { + return this.parent.getError(this.contractAddress); + } + @computed + get response(): Readonly> | 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, + }; + } + + 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.ensureFetched(); + } + + async waitFreshResponse(): Promise< + Readonly> | undefined + > { + await this.ensureFetched(); + return this.response; + } + + 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 +163,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..5a015cf7bd 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,37 @@ 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; + @observable + protected isInBatch = false; + protected observedProps = 0; + constructor( protected readonly parent: ObservableQueryThirdpartyERC20BalancesImplParent, protected readonly chainId: string, @@ -102,26 +133,90 @@ export class ObservableQueryThirdpartyERC20BalancesImpl protected readonly denomHelper: DenomHelper ) { makeObservable(this); + + const contract = denomHelper.contractAddress; + // 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( + () => { + // 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) => { + if (status === "missing" && !this.isInBatch) { + runInAction(() => { + this.isInBatch = true; + }); + parent.batchParent.addContract(contract); + } else if (status !== "missing" && this.isInBatch) { + runInAction(() => { + this.isInBatch = false; + }); + parent.batchParent.removeContract(contract); + } + }, + { fireImmediately: true } + ); + }; + const teardownReaction = () => { + this.batchReactionDisposer?.(); + this.batchReactionDisposer = undefined; + if (this.isInBatch) { + runInAction(() => { + this.isInBatch = false; + }); + parent.batchParent.removeContract(contract); + } + }; + const attach = (prop: "balance" | "response" | "error") => { + onBecomeObserved(this, prop, () => { + if (this.observedProps++ === 0) installReaction(); + }); + onBecomeUnobserved(this, prop, () => { + if (--this.observedProps === 0) teardownReaction(); + }); + }; + attach("balance"); + attach("response"); + attach("error"); } @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 @@ -133,10 +228,34 @@ 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 { - return this.parent.error; + 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; + } + // Still loading — stay quiet until the fallback settles. + return undefined; } get isFetching(): boolean { + if (!this.alchemyCovers()) { + return this.parent.batchParent.isFetching || this.parent.isFetching; + } return this.parent.isFetching; } get isObserved(): boolean { @@ -145,10 +264,28 @@ export class ObservableQueryThirdpartyERC20BalancesImpl get isStarted(): boolean { return this.parent.isStarted; } + @computed get response(): | Readonly> | undefined { - 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 { @@ -175,20 +312,43 @@ export class ObservableQueryThirdpartyERC20BalancesImpl })(); } ); - return this.parent.duplicatedFetchResolver; } + const alchemyFetch = this.parent.duplicatedFetchResolver; + return alchemyFetch.then(() => this.awaitFallbackIfMissing()); + } - return this.parent.duplicatedFetchResolver; + // 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; + // Mirror the reaction: an Alchemy error forces fallback even when a + // stale response still advertises the contract as covered. + const coveredByAlchemy = + !this.parent.error && + !!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 > { - return await this.parent.waitFreshResponse(); + await this.parent.waitFreshResponse(); + await this.awaitFallbackIfMissing(); + return this.response; } async waitResponse(): Promise> | undefined> { - return await this.parent.waitResponse(); + await this.parent.waitResponse(); + await this.awaitFallbackIfMissing(); + return this.response; } } @@ -202,12 +362,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 +379,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( diff --git a/packages/stores/src/common/query/json-rpc-batch.ts b/packages/stores/src/common/query/json-rpc-batch.ts index ba26937c13..d95d215dbd 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,36 @@ 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[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(() => { + this._perRequestErrors = perRequestErrors; + }); + return { headers: response.headers, data,