@keplr-wallet/stores-eth 0.13.32 → 0.13.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/build/queries/erc20-balance-batch.d.ts +26 -0
  2. package/build/queries/erc20-balance-batch.js +262 -0
  3. package/build/queries/erc20-balance-batch.js.map +1 -0
  4. package/build/queries/erc20-balance-batch.spec.d.ts +1 -0
  5. package/build/queries/erc20-balance-batch.spec.js +157 -0
  6. package/build/queries/erc20-balance-batch.spec.js.map +1 -0
  7. package/build/queries/erc20-balance.d.ts +21 -6
  8. package/build/queries/erc20-balance.js +118 -27
  9. package/build/queries/erc20-balance.js.map +1 -1
  10. package/build/queries/erc20-balance.spec.d.ts +1 -0
  11. package/build/queries/erc20-balance.spec.js +90 -0
  12. package/build/queries/erc20-balance.spec.js.map +1 -0
  13. package/build/queries/erc20-balances.d.ts +18 -4
  14. package/build/queries/erc20-balances.js +200 -25
  15. package/build/queries/erc20-balances.js.map +1 -1
  16. package/build/queries/erc20-balances.spec.d.ts +1 -0
  17. package/build/queries/erc20-balances.spec.js +150 -0
  18. package/build/queries/erc20-balances.spec.js.map +1 -0
  19. package/build/queries/erc20-batch-parent-store.d.ts +8 -0
  20. package/build/queries/erc20-batch-parent-store.js +21 -0
  21. package/build/queries/erc20-batch-parent-store.js.map +1 -0
  22. package/build/queries/index.d.ts +1 -3
  23. package/build/queries/index.js +6 -5
  24. package/build/queries/index.js.map +1 -1
  25. package/jest.config.js +8 -1
  26. package/package.json +8 -8
  27. package/src/queries/erc20-balance-batch.spec.ts +192 -0
  28. package/src/queries/erc20-balance-batch.ts +273 -0
  29. package/src/queries/erc20-balance.spec.ts +129 -0
  30. package/src/queries/erc20-balance.ts +122 -36
  31. package/src/queries/erc20-balances.spec.ts +194 -0
  32. package/src/queries/erc20-balances.ts +220 -37
  33. package/src/queries/erc20-batch-parent-store.ts +28 -0
  34. package/src/queries/index.ts +8 -15
@@ -1,5 +1,14 @@
1
1
  import { DenomHelper } from "@keplr-wallet/common";
2
- import { computed, makeObservable } from "mobx";
2
+ import {
3
+ computed,
4
+ IReactionDisposer,
5
+ makeObservable,
6
+ observable,
7
+ onBecomeObserved,
8
+ onBecomeUnobserved,
9
+ reaction,
10
+ runInAction,
11
+ } from "mobx";
3
12
  import { CoinPretty, Int } from "@keplr-wallet/unit";
4
13
  import { AppCurrency } from "@keplr-wallet/types";
5
14
  import {
@@ -11,11 +20,13 @@ import {
11
20
  QueryResponse,
12
21
  QuerySharedContext,
13
22
  } from "@keplr-wallet/stores";
23
+ import bigInteger from "big-integer";
14
24
  import { EthereumAccountBase } from "../account";
25
+ import { ObservableQueryEthereumERC20BalancesBatchParent } from "./erc20-balance-batch";
26
+ import { ERC20BalanceBatchParentStore } from "./erc20-batch-parent-store";
15
27
 
16
28
  const thirdparySupportedChainIdMap: Record<string, string> = {
17
29
  "eip155:1": "eth",
18
- "eip155:10": "opt",
19
30
  "eip155:137": "polygon",
20
31
  "eip155:8453": "base",
21
32
  "eip155:42161": "arb",
@@ -32,7 +43,7 @@ interface ThirdpartyERC20TokenBalance {
32
43
  } | null;
33
44
  }[];
34
45
  // TODO: Support pagination.
35
- pageKey: string;
46
+ pageKey?: string;
36
47
  }
37
48
 
38
49
  export class ObservableQueryThirdpartyERC20BalancesImplParent extends ObservableJsonRPCQuery<ThirdpartyERC20TokenBalance> {
@@ -42,11 +53,15 @@ export class ObservableQueryThirdpartyERC20BalancesImplParent extends Observable
42
53
  // so fetch should not be overridden in this parent class.
43
54
  public duplicatedFetchResolver?: Promise<void>;
44
55
 
56
+ @observable.shallow
57
+ protected alchemyContractSet: Set<string> = new Set();
58
+
45
59
  constructor(
46
60
  sharedContext: QuerySharedContext,
47
61
  protected readonly chainId: string,
48
62
  protected readonly chainGetter: ChainGetter,
49
- protected readonly ethereumHexAddress: string
63
+ protected readonly ethereumHexAddress: string,
64
+ public readonly batchParent: ObservableQueryEthereumERC20BalancesBatchParent
50
65
  ) {
51
66
  const tokenAPIURL = `https://evm-${chainId.replace(
52
67
  "eip155:",
@@ -79,22 +94,49 @@ export class ObservableQueryThirdpartyERC20BalancesImplParent extends Observable
79
94
  super.onReceiveResponse(response);
80
95
 
81
96
  const mcInfo = this.chainGetter.getModularChain(this.chainId);
82
- const erc20Denoms = response.data.tokenBalances
83
- .filter(
84
- (tokenBalance) =>
85
- tokenBalance.tokenBalance != null &&
86
- BigInt(tokenBalance.tokenBalance) > 0
87
- )
88
- .map((tokenBalance) => `erc20:${tokenBalance.contractAddress}`);
89
- if (erc20Denoms) {
97
+ const next = new Set<string>();
98
+ const erc20Denoms: string[] = [];
99
+ for (const tokenBalance of response.data.tokenBalances) {
100
+ if (tokenBalance.tokenBalance != null) {
101
+ next.add(tokenBalance.contractAddress.toLowerCase());
102
+ if (BigInt(tokenBalance.tokenBalance) > 0) {
103
+ erc20Denoms.push(`erc20:${tokenBalance.contractAddress}`);
104
+ }
105
+ }
106
+ }
107
+ runInAction(() => {
108
+ this.alchemyContractSet = next;
109
+ });
110
+ if (erc20Denoms.length > 0) {
90
111
  mcInfo.addUnknownDenoms(...erc20Denoms);
91
112
  }
92
113
  }
114
+
115
+ hasAlchemyBalance(contract: string): boolean {
116
+ return this.alchemyContractSet.has(contract.toLowerCase());
117
+ }
118
+
119
+ getAlchemyTokenBalance(
120
+ contract: string
121
+ ): ThirdpartyERC20TokenBalance["tokenBalances"][number] | undefined {
122
+ return this.response?.data.tokenBalances.find(
123
+ (bal) => bal.contractAddress.toLowerCase() === contract.toLowerCase()
124
+ );
125
+ }
126
+
127
+ resolvesAlchemyBalance(contract: string): boolean {
128
+ return this.getAlchemyTokenBalance(contract)?.tokenBalance != null;
129
+ }
93
130
  }
94
131
 
95
132
  export class ObservableQueryThirdpartyERC20BalancesImpl
96
133
  implements IObservableQueryBalanceImpl
97
134
  {
135
+ protected batchReactionDisposer?: IReactionDisposer;
136
+ @observable
137
+ protected isInBatch = false;
138
+ protected observedProps = 0;
139
+
98
140
  constructor(
99
141
  protected readonly parent: ObservableQueryThirdpartyERC20BalancesImplParent,
100
142
  protected readonly chainId: string,
@@ -102,26 +144,86 @@ export class ObservableQueryThirdpartyERC20BalancesImpl
102
144
  protected readonly denomHelper: DenomHelper
103
145
  ) {
104
146
  makeObservable(this);
147
+
148
+ const contract = denomHelper.contractAddress;
149
+ // Readiness gates in hooks-evm can early-return on `response` before
150
+ // reading `balance`, so track observation across both to register the
151
+ // contract with the batch parent when needed.
152
+ const installReaction = () => {
153
+ // Register to batch only when Alchemy can't cover the contract, to avoid
154
+ // duplicate eth_call against tokens Alchemy already returns.
155
+ this.batchReactionDisposer = reaction(
156
+ () => {
157
+ // Alchemy error forces fallback even when a stale `response` still
158
+ // advertises the contract as covered.
159
+ if (parent.error) return "missing";
160
+ if (!parent.response) return "pending";
161
+ if (parent.resolvesAlchemyBalance(contract)) return "covered";
162
+ return "missing";
163
+ },
164
+ (status) => {
165
+ if (status === "missing" && !this.isInBatch) {
166
+ runInAction(() => {
167
+ this.isInBatch = true;
168
+ });
169
+ parent.batchParent.addContract(contract);
170
+ } else if (status !== "missing" && this.isInBatch) {
171
+ runInAction(() => {
172
+ this.isInBatch = false;
173
+ });
174
+ parent.batchParent.removeContract(contract);
175
+ }
176
+ },
177
+ { fireImmediately: true }
178
+ );
179
+ };
180
+ const teardownReaction = () => {
181
+ this.batchReactionDisposer?.();
182
+ this.batchReactionDisposer = undefined;
183
+ if (this.isInBatch) {
184
+ runInAction(() => {
185
+ this.isInBatch = false;
186
+ });
187
+ parent.batchParent.removeContract(contract);
188
+ }
189
+ };
190
+ const attach = (prop: "balance" | "response" | "error") => {
191
+ onBecomeObserved(this, prop, () => {
192
+ if (this.observedProps++ === 0) installReaction();
193
+ });
194
+ onBecomeUnobserved(this, prop, () => {
195
+ if (--this.observedProps === 0) teardownReaction();
196
+ });
197
+ };
198
+ attach("balance");
199
+ attach("response");
200
+ attach("error");
105
201
  }
106
202
 
107
203
  @computed
108
204
  get balance(): CoinPretty {
109
205
  const currency = this.currency;
206
+ const contract = this.denomHelper.contractAddress;
110
207
 
111
- if (!this.response) {
112
- return new CoinPretty(currency, new Int(0)).ready(false);
208
+ if (this.alchemyResolvesBalance) {
209
+ const tokenBalance = this.parent.getAlchemyTokenBalance(contract);
210
+ if (tokenBalance?.tokenBalance != null) {
211
+ return new CoinPretty(
212
+ currency,
213
+ new Int(BigInt(tokenBalance.tokenBalance))
214
+ );
215
+ }
113
216
  }
114
217
 
115
- const tokenBalance = this.response.data.tokenBalances.find(
116
- (bal) =>
117
- DenomHelper.normalizeDenom(`erc20:${bal.contractAddress}`) ===
118
- DenomHelper.normalizeDenom(this.denomHelper.denom)
119
- );
120
- if (tokenBalance?.tokenBalance == null) {
121
- return new CoinPretty(currency, new Int(0)).ready(false);
218
+ const raw = this.parent.batchParent.getBalance(contract);
219
+ if (raw !== undefined) {
220
+ return new CoinPretty(
221
+ currency,
222
+ new Int(bigInteger(raw.replace("0x", ""), 16).toString())
223
+ );
122
224
  }
123
225
 
124
- return new CoinPretty(currency, new Int(BigInt(tokenBalance.tokenBalance)));
226
+ return new CoinPretty(currency, new Int(0)).ready(false);
125
227
  }
126
228
 
127
229
  @computed
@@ -133,10 +235,39 @@ export class ObservableQueryThirdpartyERC20BalancesImpl
133
235
  .forceFindCurrency(denom);
134
236
  }
135
237
 
238
+ // Whether the contract is currently sourced from a healthy Alchemy response.
239
+ // Derived from actual data/error state so imperative callers (which never
240
+ // flip `isInBatch`) observe the same semantics as the reaction path.
241
+ @computed
242
+ protected get alchemyResolvesBalance(): boolean {
243
+ return (
244
+ !this.parent.error &&
245
+ !!this.parent.response &&
246
+ this.parent.resolvesAlchemyBalance(this.denomHelper.contractAddress)
247
+ );
248
+ }
249
+
250
+ @computed
136
251
  get error(): Readonly<QueryError<unknown>> | undefined {
137
- return this.parent.error;
252
+ if (this.alchemyResolvesBalance) return undefined;
253
+ const contract = this.denomHelper.contractAddress;
254
+ const batchErr = this.parent.batchParent.getError(contract);
255
+ if (batchErr) return batchErr;
256
+ // Batch has valid data — suppress any lingering Alchemy error.
257
+ if (this.parent.batchParent.getBalance(contract) !== undefined) {
258
+ return undefined;
259
+ }
260
+ // Still loading — stay quiet until the fallback settles.
261
+ return undefined;
138
262
  }
139
263
  get isFetching(): boolean {
264
+ if (!this.alchemyResolvesBalance) {
265
+ return (
266
+ this.parent.batchParent.isFetchingContract(
267
+ this.denomHelper.contractAddress
268
+ ) || this.parent.isFetching
269
+ );
270
+ }
140
271
  return this.parent.isFetching;
141
272
  }
142
273
  get isObserved(): boolean {
@@ -145,10 +276,28 @@ export class ObservableQueryThirdpartyERC20BalancesImpl
145
276
  get isStarted(): boolean {
146
277
  return this.parent.isStarted;
147
278
  }
279
+ @computed
148
280
  get response():
149
281
  | Readonly<QueryResponse<ThirdpartyERC20TokenBalance>>
150
282
  | undefined {
151
- return this.parent.response;
283
+ // Readiness must gate on actual data availability, not the
284
+ // observation-driven `isInBatch` flag, so imperative callers also see
285
+ // batch-backed readiness for tokens missing from Alchemy.
286
+ if (this.alchemyResolvesBalance) return this.parent.response;
287
+ const raw = this.parent.batchParent.getBalance(
288
+ this.denomHelper.contractAddress
289
+ );
290
+ if (raw === undefined) return undefined;
291
+ return {
292
+ data: {
293
+ address: "",
294
+ tokenBalances: [],
295
+ pageKey: "",
296
+ },
297
+ staled: false,
298
+ local: false,
299
+ timestamp: 0,
300
+ };
152
301
  }
153
302
 
154
303
  fetch(): Promise<void> {
@@ -175,20 +324,54 @@ export class ObservableQueryThirdpartyERC20BalancesImpl
175
324
  })();
176
325
  }
177
326
  );
178
- return this.parent.duplicatedFetchResolver;
179
327
  }
328
+ const alchemyFetch = this.parent.duplicatedFetchResolver;
329
+ return alchemyFetch.then(() => this.awaitFallbackIfMissing());
330
+ }
180
331
 
181
- return this.parent.duplicatedFetchResolver;
332
+ // After Alchemy resolves, check Alchemy coverage directly (not via the
333
+ // observation-driven reaction) so imperative callers also exercise the
334
+ // batch fallback for tokens missing from Alchemy.
335
+ protected async awaitFallbackIfMissing(): Promise<void> {
336
+ // balanceImplMap doesn't evict on currency removal.
337
+ if (!this.isCurrencyRegistered()) return;
338
+ const contract = this.denomHelper.contractAddress;
339
+ // Mirror the reaction: an Alchemy error forces fallback even when a
340
+ // stale response still advertises the contract as covered.
341
+ const coveredByAlchemy =
342
+ !this.parent.error &&
343
+ !!this.parent.response &&
344
+ this.parent.resolvesAlchemyBalance(contract);
345
+ if (coveredByAlchemy) return;
346
+ this.parent.batchParent.addContract(contract);
347
+ try {
348
+ await this.parent.batchParent.waitFreshResponse();
349
+ } finally {
350
+ this.parent.batchParent.removeContract(contract);
351
+ }
352
+ }
353
+
354
+ protected isCurrencyRegistered(): boolean {
355
+ const target = DenomHelper.normalizeDenom(this.denomHelper.denom);
356
+ return this.chainGetter
357
+ .getModularChain(this.chainId)
358
+ .currencies.some(
359
+ (c) => DenomHelper.normalizeDenom(c.coinMinimalDenom) === target
360
+ );
182
361
  }
183
362
 
184
363
  async waitFreshResponse(): Promise<
185
364
  Readonly<QueryResponse<unknown>> | undefined
186
365
  > {
187
- return await this.parent.waitFreshResponse();
366
+ await this.parent.waitFreshResponse();
367
+ await this.awaitFallbackIfMissing();
368
+ return this.response;
188
369
  }
189
370
 
190
371
  async waitResponse(): Promise<Readonly<QueryResponse<unknown>> | undefined> {
191
- return await this.parent.waitResponse();
372
+ await this.parent.waitResponse();
373
+ await this.awaitFallbackIfMissing();
374
+ return this.response;
192
375
  }
193
376
  }
194
377
 
@@ -202,12 +385,7 @@ export class ObservableQueryThirdpartyERC20BalanceRegistry
202
385
 
203
386
  constructor(
204
387
  protected readonly sharedContext: QuerySharedContext,
205
- protected readonly forceNativeERC20Query: (
206
- chainId: string,
207
- chainGetter: ChainGetter,
208
- address: string,
209
- minimalDenom: string
210
- ) => boolean
388
+ protected readonly batchParentStore: ERC20BalanceBatchParentStore
211
389
  ) {}
212
390
 
213
391
  getBalanceImpl(
@@ -224,21 +402,26 @@ export class ObservableQueryThirdpartyERC20BalanceRegistry
224
402
  !Object.keys(thirdparySupportedChainIdMap).includes(chainId) ||
225
403
  denomHelper.type !== "erc20" ||
226
404
  !isHexAddress ||
227
- (mcInfo.type !== "evm" && mcInfo.type !== "ethermint") ||
228
- this.forceNativeERC20Query(chainId, chainGetter, address, minimalDenom)
405
+ (mcInfo.type !== "evm" && mcInfo.type !== "ethermint")
229
406
  ) {
230
407
  return;
231
408
  }
232
409
  const key = `${chainId}/${address}`;
233
410
 
234
411
  if (!this.parentMap.has(key)) {
412
+ const batchParent = this.batchParentStore.getOrCreate(
413
+ chainId,
414
+ chainGetter,
415
+ address
416
+ );
235
417
  this.parentMap.set(
236
418
  key,
237
419
  new ObservableQueryThirdpartyERC20BalancesImplParent(
238
420
  this.sharedContext,
239
421
  chainId,
240
422
  chainGetter,
241
- address
423
+ address,
424
+ batchParent
242
425
  )
243
426
  );
244
427
  }
@@ -0,0 +1,28 @@
1
+ import { ChainGetter, QuerySharedContext } from "@keplr-wallet/stores";
2
+ import { ObservableQueryEthereumERC20BalancesBatchParent } from "./erc20-balance-batch";
3
+
4
+ export class ERC20BalanceBatchParentStore {
5
+ protected map: Map<string, ObservableQueryEthereumERC20BalancesBatchParent> =
6
+ new Map();
7
+
8
+ constructor(protected readonly sharedContext: QuerySharedContext) {}
9
+
10
+ getOrCreate(
11
+ chainId: string,
12
+ chainGetter: ChainGetter,
13
+ ethereumHexAddress: string
14
+ ): ObservableQueryEthereumERC20BalancesBatchParent {
15
+ const key = `${chainId}/${ethereumHexAddress.toLowerCase()}`;
16
+ let p = this.map.get(key);
17
+ if (!p) {
18
+ p = new ObservableQueryEthereumERC20BalancesBatchParent(
19
+ this.sharedContext,
20
+ chainId,
21
+ chainGetter,
22
+ ethereumHexAddress
23
+ );
24
+ this.map.set(key, p);
25
+ }
26
+ return p;
27
+ }
28
+ }
@@ -13,6 +13,7 @@ import { ObservableQueryEthereumMaxPriorityFee } from "./max-priority-fee";
13
13
  import { ObservableQueryThirdpartyERC20BalanceRegistry } from "./erc20-balances";
14
14
  import { ObservableQueryCoingeckoTokenInfo } from "./coingecko-token-info";
15
15
  import { ObservableQueryEthereumERC20BalanceRegistry } from "./erc20-balance";
16
+ import { ERC20BalanceBatchParentStore } from "./erc20-batch-parent-store";
16
17
  import { ObservableQueryEthereumGasPrice } from "./gas-price";
17
18
  import { ObservableQueryEthereumTxReceipt } from "./tx-receipt";
18
19
 
@@ -24,12 +25,6 @@ export const EthereumQueries = {
24
25
  use(options: {
25
26
  coingeckoAPIBaseURL: string;
26
27
  coingeckoAPIURI: string;
27
- forceNativeERC20Query: (
28
- chainId: string,
29
- chainGetter: ChainGetter,
30
- address: string,
31
- minimalDenom: string
32
- ) => boolean;
33
28
  }): (
34
29
  queriesSetBase: QueriesSetBase,
35
30
  sharedContext: QuerySharedContext,
@@ -46,7 +41,6 @@ export const EthereumQueries = {
46
41
  ethereum: new EthereumQueriesImpl(
47
42
  queriesSetBase,
48
43
  sharedContext,
49
- options.forceNativeERC20Query,
50
44
  chainId,
51
45
  chainGetter,
52
46
  options.coingeckoAPIBaseURL,
@@ -70,24 +64,23 @@ export class EthereumQueriesImpl {
70
64
  constructor(
71
65
  base: QueriesSetBase,
72
66
  sharedContext: QuerySharedContext,
73
- protected readonly forceNativeERC20Query: (
74
- chainId: string,
75
- chainGetter: ChainGetter,
76
- address: string,
77
- minimalDenom: string
78
- ) => boolean,
79
67
  protected chainId: string,
80
68
  protected chainGetter: ChainGetter,
81
69
  protected coingeckoAPIBaseURL: string,
82
70
  protected coingeckoAPIURI: string
83
71
  ) {
72
+ const batchParentStore = new ERC20BalanceBatchParentStore(sharedContext);
73
+
84
74
  base.queryBalances.addBalanceRegistry(
85
- new ObservableQueryEthereumERC20BalanceRegistry(sharedContext)
75
+ new ObservableQueryEthereumERC20BalanceRegistry(
76
+ sharedContext,
77
+ batchParentStore
78
+ )
86
79
  );
87
80
  base.queryBalances.addBalanceRegistry(
88
81
  new ObservableQueryThirdpartyERC20BalanceRegistry(
89
82
  sharedContext,
90
- forceNativeERC20Query
83
+ batchParentStore
91
84
  )
92
85
  );
93
86
  base.queryBalances.addBalanceRegistry(