Skip to content
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a577e24
feat(erc20): implement batch querying for ERC20 balances
piatoss3612 Apr 18, 2026
430cfeb
Merge branch 'develop' into rowan/erc20-batch-balance-hybrid
piatoss3612 Apr 20, 2026
c4fd321
fix(stores-eth): address ERC20 hybrid review findings
piatoss3612 Apr 20, 2026
1abe923
fix(stores-eth): synthesize Child.response from batch parent data
piatoss3612 Apr 20, 2026
de98b6a
fix(stores-eth): register batch contract when response is observed
piatoss3612 Apr 20, 2026
237cad7
fix(stores-eth): await batch fallback after Alchemy resolves
piatoss3612 Apr 20, 2026
a2358d2
fix(stores-eth): gate response on batch readiness for fallback tokens
piatoss3612 Apr 20, 2026
43e03b0
fix(stores-eth): scope batch error per contract + fallback on Alchemy…
piatoss3612 Apr 20, 2026
8353b5c
fix(stores,stores-eth): force-register on imperative fetch + surface …
piatoss3612 Apr 20, 2026
7dc9adc
fix(stores-eth): track error observation + drop stale parent error
piatoss3612 Apr 20, 2026
de3ac4b
fix(stores,stores-eth): address parallel codex review findings
piatoss3612 Apr 20, 2026
50b5b6d
fix(stores-eth): remove stray duplicate @computed decorator on error
piatoss3612 Apr 20, 2026
581a3e3
fix(stores-eth): imperative fallback also treats Alchemy error as mis…
piatoss3612 Apr 20, 2026
53b9ef4
revert: drop unrelated swap debug change accidentally included
piatoss3612 Apr 20, 2026
fe82d22
fix(stores-eth): derive thirdparty child state from actual data, not …
piatoss3612 Apr 20, 2026
b030768
fix(stores-eth): trigger batch rebuild on EVM RPC endpoint change
piatoss3612 Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions apps/extension/src/stores/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
Expand Down
178 changes: 178 additions & 0 deletions packages/stores-eth/src/queries/erc20-balance-batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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<string, number> = new Map();

@observable.ref
protected batchQueries: ObservableJsonRpcBatchQuery<string>[] = [];

@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<string, number> = new Map();

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),
Comment thread
piatoss3612 marked this conversation as resolved.
Outdated
{ 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<unknown> | 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;
}

async waitFreshResponse(): Promise<void> {
// 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()));
}

protected rebuildBatchQueries(key: string): void {
if (key === "") {
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<string, number>();
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<string>(
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<T>(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;
}
Loading
Loading