-
Notifications
You must be signed in to change notification settings - Fork 528
feat(stores-eth): hybrid ERC20 balance query (Alchemy + batch eth_call) #1930
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 13 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 430cfeb
Merge branch 'develop' into rowan/erc20-batch-balance-hybrid
piatoss3612 c4fd321
fix(stores-eth): address ERC20 hybrid review findings
piatoss3612 1abe923
fix(stores-eth): synthesize Child.response from batch parent data
piatoss3612 de98b6a
fix(stores-eth): register batch contract when response is observed
piatoss3612 237cad7
fix(stores-eth): await batch fallback after Alchemy resolves
piatoss3612 a2358d2
fix(stores-eth): gate response on batch readiness for fallback tokens
piatoss3612 43e03b0
fix(stores-eth): scope batch error per contract + fallback on Alchemy…
piatoss3612 8353b5c
fix(stores,stores-eth): force-register on imperative fetch + surface …
piatoss3612 7dc9adc
fix(stores-eth): track error observation + drop stale parent error
piatoss3612 de3ac4b
fix(stores,stores-eth): address parallel codex review findings
piatoss3612 50b5b6d
fix(stores-eth): remove stray duplicate @computed decorator on error
piatoss3612 581a3e3
fix(stores-eth): imperative fallback also treats Alchemy error as mis…
piatoss3612 53b9ef4
revert: drop unrelated swap debug change accidentally included
piatoss3612 fe82d22
fix(stores-eth): derive thirdparty child state from actual data, not …
piatoss3612 b030768
fix(stores-eth): trigger batch rebuild on EVM RPC endpoint change
piatoss3612 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
|
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; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.