@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
@@ -2,78 +2,159 @@ import {
2
2
  BalanceRegistry,
3
3
  ChainGetter,
4
4
  IObservableQueryBalanceImpl,
5
+ QueryError,
6
+ QueryResponse,
5
7
  QuerySharedContext,
6
8
  } from "@keplr-wallet/stores";
7
9
  import { AppCurrency } from "@keplr-wallet/types";
8
10
  import { CoinPretty, Int } from "@keplr-wallet/unit";
9
- import { computed, makeObservable } from "mobx";
11
+ import {
12
+ computed,
13
+ makeObservable,
14
+ observable,
15
+ onBecomeObserved,
16
+ onBecomeUnobserved,
17
+ runInAction,
18
+ } from "mobx";
10
19
  import bigInteger from "big-integer";
11
- import { erc20ContractInterface } from "../constants";
12
20
  import { DenomHelper } from "@keplr-wallet/common";
13
21
  import { EthereumAccountBase } from "../account";
14
- import { ObservableEvmChainJsonRpcQuery } from "./evm-chain-json-rpc";
22
+ import { ObservableQueryEthereumERC20BalancesBatchParent } from "./erc20-balance-batch";
23
+ import { ERC20BalanceBatchParentStore } from "./erc20-batch-parent-store";
15
24
 
16
25
  export class ObservableQueryEthereumERC20BalanceImpl
17
- extends ObservableEvmChainJsonRpcQuery<string>
18
26
  implements IObservableQueryBalanceImpl
19
27
  {
28
+ @observable
29
+ protected _observedProps = 0;
30
+
20
31
  constructor(
21
- sharedContext: QuerySharedContext,
22
- chainId: string,
23
- chainGetter: ChainGetter,
32
+ protected readonly parent: ObservableQueryEthereumERC20BalancesBatchParent,
33
+ protected readonly chainId: string,
34
+ protected readonly chainGetter: ChainGetter,
24
35
  protected readonly denomHelper: DenomHelper,
25
- protected readonly ethereumHexAddress: string,
26
36
  protected readonly contractAddress: string
27
37
  ) {
28
- super(sharedContext, chainId, chainGetter, "eth_call", [
29
- {
30
- to: contractAddress,
31
- data: erc20ContractInterface.encodeFunctionData("balanceOf", [
32
- ethereumHexAddress,
33
- ]),
34
- },
35
- "latest",
36
- ]);
37
-
38
38
  makeObservable(this);
39
+
40
+ // Readiness gates (hooks-evm) can early-return on `response` before
41
+ // reading `balance`, so either property being observed must register
42
+ // the contract into the shared batch parent.
43
+ const attach = (prop: "balance" | "response" | "error") => {
44
+ onBecomeObserved(this, prop, () => {
45
+ runInAction(() => {
46
+ this._observedProps += 1;
47
+ });
48
+ if (this._observedProps === 1) {
49
+ this.parent.addContract(contractAddress);
50
+ }
51
+ });
52
+ onBecomeUnobserved(this, prop, () => {
53
+ runInAction(() => {
54
+ this._observedProps -= 1;
55
+ });
56
+ if (this._observedProps === 0) {
57
+ this.parent.removeContract(contractAddress);
58
+ }
59
+ });
60
+ };
61
+ attach("balance");
62
+ attach("response");
63
+ attach("error");
39
64
  }
40
65
 
41
66
  @computed
42
67
  get balance(): CoinPretty {
43
- const denom = this.denomHelper.denom;
44
-
45
- const currency = this.chainGetter
46
- .getModularChain(this.chainId)
47
- .findCurrency(denom);
48
-
49
- if (!currency) {
50
- throw new Error(`Unknown currency: ${this.contractAddress}`);
51
- }
52
-
53
- if (!this.response || !this.response.data) {
68
+ const currency = this.currency;
69
+ const raw = this.parent.getBalance(this.contractAddress);
70
+ if (raw === undefined) {
54
71
  return new CoinPretty(currency, new Int(0)).ready(false);
55
72
  }
56
-
57
73
  return new CoinPretty(
58
74
  currency,
59
- new Int(bigInteger(this.response.data.replace("0x", ""), 16).toString())
75
+ new Int(bigInteger(raw.replace("0x", ""), 16).toString())
60
76
  );
61
77
  }
62
78
 
63
79
  @computed
64
80
  get currency(): AppCurrency {
65
- const denom = this.denomHelper.denom;
81
+ return this.chainGetter
82
+ .getModularChain(this.chainId)
83
+ .forceFindCurrency(this.denomHelper.denom);
84
+ }
66
85
 
86
+ get isFetching(): boolean {
87
+ return this.parent.isFetching;
88
+ }
89
+ get isObserved(): boolean {
90
+ return this._observedProps > 0;
91
+ }
92
+ get isStarted(): boolean {
93
+ return this._observedProps > 0;
94
+ }
95
+ @computed
96
+ get error(): QueryError<unknown> | undefined {
97
+ return this.parent.getError(this.contractAddress);
98
+ }
99
+ @computed
100
+ get response(): Readonly<QueryResponse<unknown>> | undefined {
101
+ // hooks-evm tx flow gates readiness on truthy `bal.response`. Synthesize
102
+ // from the shared batch parent so the Child advertises fetch completion.
103
+ const raw = this.parent.getBalance(this.contractAddress);
104
+ if (raw === undefined) return undefined;
105
+ return {
106
+ data: raw,
107
+ staled: false,
108
+ local: false,
109
+ timestamp: 0,
110
+ };
111
+ }
112
+
113
+ protected async ensureFetched(): Promise<void> {
114
+ // balanceImplMap doesn't evict on currency removal.
115
+ if (!this.isCurrencyRegistered()) return;
116
+ // Force temporary registration so imperative callers (outside a reactive
117
+ // observer) still trigger an actual eth_call.
118
+ this.parent.addContract(this.contractAddress);
119
+ try {
120
+ await this.parent.waitFreshResponse();
121
+ } finally {
122
+ this.parent.removeContract(this.contractAddress);
123
+ }
124
+ }
125
+
126
+ protected isCurrencyRegistered(): boolean {
127
+ const target = DenomHelper.normalizeDenom(this.denomHelper.denom);
67
128
  return this.chainGetter
68
129
  .getModularChain(this.chainId)
69
- .forceFindCurrency(denom);
130
+ .currencies.some(
131
+ (c) => DenomHelper.normalizeDenom(c.coinMinimalDenom) === target
132
+ );
133
+ }
134
+
135
+ fetch(): Promise<void> {
136
+ return this.ensureFetched();
137
+ }
138
+
139
+ async waitFreshResponse(): Promise<
140
+ Readonly<QueryResponse<unknown>> | undefined
141
+ > {
142
+ await this.ensureFetched();
143
+ return this.response;
144
+ }
145
+
146
+ async waitResponse(): Promise<Readonly<QueryResponse<unknown>> | undefined> {
147
+ return await this.waitFreshResponse();
70
148
  }
71
149
  }
72
150
 
73
151
  export class ObservableQueryEthereumERC20BalanceRegistry
74
152
  implements BalanceRegistry
75
153
  {
76
- constructor(protected readonly sharedContext: QuerySharedContext) {}
154
+ constructor(
155
+ protected readonly sharedContext: QuerySharedContext,
156
+ protected readonly batchParentStore: ERC20BalanceBatchParentStore
157
+ ) {}
77
158
 
78
159
  getBalanceImpl(
79
160
  chainId: string,
@@ -93,12 +174,17 @@ export class ObservableQueryEthereumERC20BalanceRegistry
93
174
  return;
94
175
  }
95
176
 
177
+ const parent = this.batchParentStore.getOrCreate(
178
+ chainId,
179
+ chainGetter,
180
+ address
181
+ );
182
+
96
183
  return new ObservableQueryEthereumERC20BalanceImpl(
97
- this.sharedContext,
184
+ parent,
98
185
  chainId,
99
186
  chainGetter,
100
187
  denomHelper,
101
- address,
102
188
  denomHelper.contractAddress
103
189
  );
104
190
  }
@@ -0,0 +1,194 @@
1
+ import { DenomHelper } from "@keplr-wallet/common";
2
+ import { AppCurrency } from "@keplr-wallet/types";
3
+ import {
4
+ ObservableQueryThirdpartyERC20BalanceRegistry,
5
+ ObservableQueryThirdpartyERC20BalancesImpl,
6
+ } from "./erc20-balances";
7
+
8
+ jest.mock("../account", () => ({
9
+ EthereumAccountBase: {
10
+ isEthereumHexAddressWithChecksum: jest.fn(() => true),
11
+ },
12
+ }));
13
+
14
+ jest.mock("@keplr-wallet/stores", () => {
15
+ const context = jest.requireActual(
16
+ "../../../stores/src/common/query/context"
17
+ );
18
+ const jsonRpc = jest.requireActual(
19
+ "../../../stores/src/common/query/json-rpc"
20
+ );
21
+ const batch = jest.requireActual(
22
+ "../../../stores/src/common/query/json-rpc-batch"
23
+ );
24
+ return {
25
+ QuerySharedContext: context.QuerySharedContext,
26
+ ObservableJsonRPCQuery: jsonRpc.ObservableJsonRPCQuery,
27
+ ObservableJsonRpcBatchQuery: batch.ObservableJsonRpcBatchQuery,
28
+ };
29
+ });
30
+
31
+ const CONTRACT = "0x0000000000000000000000000000000000000002";
32
+ const DENOM = `erc20:${CONTRACT}`;
33
+ const CURRENCY: AppCurrency = {
34
+ coinMinimalDenom: DENOM,
35
+ coinDenom: "TEST",
36
+ coinDecimals: 18,
37
+ };
38
+
39
+ describe("ObservableQueryThirdpartyERC20BalancesImpl", () => {
40
+ it("falls back to batch eth_call for a missing token when the Alchemy response is complete", async () => {
41
+ const { parent, batchParent } = mockParent({
42
+ address: "0x0000000000000000000000000000000000000001",
43
+ tokenBalances: [],
44
+ });
45
+ batchParent.waitFreshResponse.mockImplementation(async () => {
46
+ batchParent.getBalance.mockReturnValue("0x5");
47
+ });
48
+ const balance = createBalance(parent);
49
+
50
+ await balance.fetch();
51
+
52
+ expect(batchParent.addContract).toHaveBeenCalledWith(CONTRACT);
53
+ expect(batchParent.waitFreshResponse).toHaveBeenCalledTimes(1);
54
+ expect(batchParent.removeContract).toHaveBeenCalledWith(CONTRACT);
55
+ expect(balance.response).not.toBe(parent.response);
56
+ expect(balance.balance.isReady).toBe(true);
57
+ expect(balance.balance.toCoin().amount).toBe("5");
58
+ });
59
+
60
+ it("uses the batch parent's cached balance when complete Alchemy response omits the token", async () => {
61
+ const { parent, batchParent } = mockParent({
62
+ address: "0x0000000000000000000000000000000000000001",
63
+ tokenBalances: [],
64
+ });
65
+ batchParent.getBalance.mockReturnValue("0x5");
66
+ const balance = createBalance(parent);
67
+
68
+ await balance.fetch();
69
+
70
+ expect(batchParent.addContract).toHaveBeenCalledWith(CONTRACT);
71
+ expect(balance.balance.toCoin().amount).toBe("5");
72
+ });
73
+
74
+ it("falls back to batch eth_call for a missing token when Alchemy has more pages", async () => {
75
+ const { parent, batchParent } = mockParent({
76
+ address: "0x0000000000000000000000000000000000000001",
77
+ tokenBalances: [],
78
+ pageKey: "next-page",
79
+ });
80
+ const balance = createBalance(parent);
81
+
82
+ await balance.fetch();
83
+
84
+ expect(batchParent.addContract).toHaveBeenCalledWith(CONTRACT);
85
+ expect(batchParent.waitFreshResponse).toHaveBeenCalledTimes(1);
86
+ expect(batchParent.removeContract).toHaveBeenCalledWith(CONTRACT);
87
+ });
88
+ });
89
+
90
+ describe("ObservableQueryThirdpartyERC20BalanceRegistry", () => {
91
+ it("does not use the Keplr Alchemy proxy for Optimism", () => {
92
+ const batchParentStore = {
93
+ getOrCreate: jest.fn(),
94
+ };
95
+ const registry = new ObservableQueryThirdpartyERC20BalanceRegistry(
96
+ {} as any,
97
+ batchParentStore as any
98
+ );
99
+
100
+ const impl = registry.getBalanceImpl(
101
+ "eip155:10",
102
+ mockChainGetter(),
103
+ "0x0000000000000000000000000000000000000001",
104
+ DENOM
105
+ );
106
+
107
+ expect(impl).toBeUndefined();
108
+ expect(batchParentStore.getOrCreate).not.toHaveBeenCalled();
109
+ });
110
+ });
111
+
112
+ function createBalance(
113
+ parent: ReturnType<typeof mockParent>["parent"]
114
+ ): ObservableQueryThirdpartyERC20BalancesImpl {
115
+ return new ObservableQueryThirdpartyERC20BalancesImpl(
116
+ parent as any,
117
+ "eip155:1",
118
+ mockChainGetter(),
119
+ new DenomHelper(DENOM)
120
+ );
121
+ }
122
+
123
+ function mockParent(data: {
124
+ address: string;
125
+ tokenBalances: {
126
+ contractAddress: string;
127
+ tokenBalance: string | null;
128
+ error: { code: number; message: string } | null;
129
+ }[];
130
+ pageKey?: string;
131
+ }) {
132
+ const batchParent = {
133
+ addContract: jest.fn(),
134
+ removeContract: jest.fn(),
135
+ waitFreshResponse: jest.fn().mockResolvedValue(undefined),
136
+ getBalance: jest.fn(),
137
+ getLastKnownBalance: jest.fn(),
138
+ getError: jest.fn(),
139
+ isFetchingContract: jest.fn(() => false),
140
+ isFetching: false,
141
+ };
142
+ const response = {
143
+ data,
144
+ staled: false,
145
+ local: false,
146
+ timestamp: 0,
147
+ };
148
+ const parent = {
149
+ response,
150
+ error: undefined,
151
+ batchParent,
152
+ duplicatedFetchResolver: undefined as Promise<void> | undefined,
153
+ fetch: jest.fn().mockResolvedValue(undefined),
154
+ hasAlchemyBalance(contract: string) {
155
+ return data.tokenBalances.some(
156
+ (bal) =>
157
+ bal.contractAddress.toLowerCase() === contract.toLowerCase() &&
158
+ bal.tokenBalance != null
159
+ );
160
+ },
161
+ getAlchemyTokenBalance(contract: string) {
162
+ return data.tokenBalances.find(
163
+ (bal) => bal.contractAddress.toLowerCase() === contract.toLowerCase()
164
+ );
165
+ },
166
+ resolvesAlchemyBalance(contract: string) {
167
+ return this.getAlchemyTokenBalance(contract)?.tokenBalance != null;
168
+ },
169
+ isFetching: false,
170
+ isObserved: false,
171
+ isStarted: false,
172
+ };
173
+
174
+ return { parent, batchParent };
175
+ }
176
+
177
+ function mockChainGetter() {
178
+ return {
179
+ getModularChain() {
180
+ return {
181
+ currencies: [CURRENCY],
182
+ forceFindCurrency(denom: string) {
183
+ if (denom === DENOM) {
184
+ return CURRENCY;
185
+ }
186
+ throw new Error(`Unknown currency: ${denom}`);
187
+ },
188
+ };
189
+ },
190
+ hasModularChain() {
191
+ return true;
192
+ },
193
+ } as any;
194
+ }