@talismn/balances-react 0.3.3 → 0.4.1

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.
@@ -1,16 +1,182 @@
1
- import { Balances, db, balances } from '@talismn/balances';
1
+ import { useContext, createContext, useState, useEffect, useMemo, useRef, useCallback } from 'react';
2
+ import { jsx } from 'react/jsx-runtime';
2
3
  import { ChainConnector } from '@talismn/chain-connector';
3
4
  import { ChainConnectorEvm } from '@talismn/chain-connector-evm';
5
+ import { connectionMetaDb } from '@talismn/connection-meta';
6
+ import { ChaindataProviderExtension } from '@talismn/chaindata-provider-extension';
7
+ import { db as db$1, createSubscriptionId, deleteSubscriptionId, balances, BalanceStatusLive, Balances, deriveStatuses, getValidSubscriptionIds } from '@talismn/balances';
8
+ import { db, fetchTokenRates } from '@talismn/token-rates';
4
9
  import { useLiveQuery } from 'dexie-react-hooks';
5
- import { useState, useEffect, useRef } from 'react';
6
10
  import { useDebounce } from 'react-use';
11
+ import md5 from 'blueimp-md5';
7
12
  import anylogger from 'anylogger';
8
- import { ChaindataProviderExtension } from '@talismn/chaindata-provider-extension';
9
- import { fetchTokenRates } from '@talismn/token-rates';
13
+ import { Subject, Observable, defer, shareReplay } from 'rxjs';
14
+
15
+ const provideContext = useProviderContext => {
16
+ // automatic typing based on our hook's return type
17
+
18
+ const Context = /*#__PURE__*/createContext({
19
+ __provideContextInternalDefaultValue: true
20
+ });
21
+ const Provider = ({
22
+ children,
23
+ ...props
24
+ }) => {
25
+ const ctx = useProviderContext(props);
26
+ return /*#__PURE__*/jsx(Context.Provider, {
27
+ value: ctx,
28
+ children: children
29
+ });
30
+ };
31
+ const useProvidedContext = () => {
32
+ const context = useContext(Context);
33
+ if (typeof context === "object" && context && "__provideContextInternalDefaultValue" in context) throw new Error("This hook requires a provider to be present above it in the tree");
34
+ return context;
35
+ };
36
+ const result = [Provider, useProvidedContext];
37
+ return result;
38
+ };
39
+
40
+ const useAllAddressesProvider = () => useState([]);
41
+ const [AllAddressesProvider, useAllAddresses] = provideContext(useAllAddressesProvider);
42
+
43
+ function useChaindataProvider(options = {}) {
44
+ const [onfinalityApiKey, setOnfinalityApiKey] = useState(options.onfinalityApiKey);
45
+
46
+ // make sure we recreate provider only when the onfinalityApiKey changes
47
+ useEffect(() => {
48
+ if (options.onfinalityApiKey !== onfinalityApiKey) setOnfinalityApiKey(options.onfinalityApiKey);
49
+ }, [options.onfinalityApiKey, onfinalityApiKey]);
50
+ return useMemo(() => new ChaindataProviderExtension({
51
+ onfinalityApiKey
52
+ }), [onfinalityApiKey]);
53
+ }
54
+ const [ChaindataProvider, useChaindata] = provideContext(useChaindataProvider);
55
+
56
+ function useChainConnectorsProvider(options) {
57
+ const [onfinalityApiKey, setOnfinalityApiKey] = useState(options.onfinalityApiKey);
58
+
59
+ // make sure we recreate provider only when the onfinalityApiKey changes
60
+ useEffect(() => {
61
+ if (options.onfinalityApiKey !== onfinalityApiKey) setOnfinalityApiKey(options.onfinalityApiKey);
62
+ }, [options.onfinalityApiKey, onfinalityApiKey]);
63
+
64
+ // chaindata dependency
65
+ const chaindata = useChaindata();
66
+
67
+ // substrate connector
68
+ const substrate = useMemo(() => new ChainConnector(chaindata, connectionMetaDb), [chaindata]);
69
+
70
+ // evm connector
71
+ const evm = useMemo(() => new ChainConnectorEvm(chaindata, {
72
+ onfinalityApiKey
73
+ }), [chaindata, onfinalityApiKey]);
74
+ return useMemo(() => ({
75
+ substrate,
76
+ evm
77
+ }), [substrate, evm]);
78
+ }
79
+ const [ChainConnectorsProvider, useChainConnectors] = provideContext(useChainConnectorsProvider);
80
+
81
+ const useBalanceModulesProvider = ({
82
+ balanceModules
83
+ }) => {
84
+ const chainConnectors = useChainConnectors();
85
+ const chaindataProvider = useChaindata();
86
+ const hydrated = useMemo(() => chainConnectors.substrate && chainConnectors.evm && chaindataProvider ? balanceModules.map(mod => mod({
87
+ chainConnectors,
88
+ chaindataProvider
89
+ })) : [], [balanceModules, chainConnectors, chaindataProvider]);
90
+ return hydrated;
91
+ };
92
+ const [BalanceModulesProvider, useBalanceModules] = provideContext(useBalanceModulesProvider);
93
+
94
+ const filterNoTestnet = ({
95
+ isTestnet
96
+ }) => isTestnet === false;
97
+ const DEFAULT_VALUE = {
98
+ chainsWithTestnets: [],
99
+ chainsWithoutTestnets: [],
100
+ evmNetworksWithTestnets: [],
101
+ evmNetworksWithoutTestnets: [],
102
+ tokensWithTestnets: [],
103
+ tokensWithoutTestnets: [],
104
+ chainsWithTestnetsMap: {},
105
+ chainsWithoutTestnetsMap: {},
106
+ evmNetworksWithTestnetsMap: {},
107
+ evmNetworksWithoutTestnetsMap: {},
108
+ tokensWithTestnetsMap: {},
109
+ tokensWithoutTestnetsMap: {},
110
+ tokenRatesMap: {},
111
+ balances: []
112
+ };
113
+ const consolidateDbCache = (chainsMap, evmNetworksMap, tokensMap, tokenRates, allBalances) => {
114
+ if (!chainsMap || !evmNetworksMap || !tokensMap || !tokenRates || !allBalances) return DEFAULT_VALUE;
115
+ const chainsWithTestnets = Object.values(chainsMap);
116
+ const chainsWithoutTestnets = chainsWithTestnets.filter(filterNoTestnet);
117
+ const chainsWithoutTestnetsMap = Object.fromEntries(chainsWithoutTestnets.map(network => [network.id, network]));
118
+ const evmNetworksWithTestnets = Object.values(evmNetworksMap);
119
+ const evmNetworksWithoutTestnets = evmNetworksWithTestnets.filter(filterNoTestnet);
120
+ const evmNetworksWithoutTestnetsMap = Object.fromEntries(evmNetworksWithoutTestnets.map(network => [network.id, network]));
121
+
122
+ // ensure that we have corresponding network for each token
123
+ const tokensWithTestnets = Object.values(tokensMap).filter(token => token.chain && chainsMap[token.chain.id] || token.evmNetwork && evmNetworksMap[token.evmNetwork.id]);
124
+ const tokensWithoutTestnets = tokensWithTestnets.filter(filterNoTestnet).filter(token => token.chain && chainsWithoutTestnetsMap[token.chain.id] || token.evmNetwork && evmNetworksWithoutTestnetsMap[token.evmNetwork.id]);
125
+ const tokensWithTestnetsMap = Object.fromEntries(tokensWithTestnets.map(token => [token.id, token]));
126
+ const tokensWithoutTestnetsMap = Object.fromEntries(tokensWithoutTestnets.map(token => [token.id, token]));
127
+ const tokenRatesMap = Object.fromEntries(tokenRates.map(({
128
+ tokenId,
129
+ rates
130
+ }) => [tokenId, rates]));
131
+
132
+ // return only balances for which we have a token
133
+ const balances = allBalances.filter(b => tokensWithTestnetsMap[b.tokenId]);
134
+ return {
135
+ chainsWithTestnets,
136
+ chainsWithoutTestnets,
137
+ evmNetworksWithTestnets,
138
+ evmNetworksWithoutTestnets,
139
+ tokensWithTestnets,
140
+ tokensWithoutTestnets,
141
+ chainsWithTestnetsMap: chainsMap,
142
+ chainsWithoutTestnetsMap,
143
+ evmNetworksWithTestnetsMap: evmNetworksMap,
144
+ evmNetworksWithoutTestnetsMap,
145
+ tokensWithTestnetsMap,
146
+ tokensWithoutTestnetsMap,
147
+ tokenRatesMap,
148
+ balances
149
+ };
150
+ };
151
+ const useDbCacheProvider = () => {
152
+ const chaindataProvider = useChaindata();
153
+ const chainList = useLiveQuery(() => chaindataProvider?.chains(), [chaindataProvider]);
154
+ const evmNetworkList = useLiveQuery(() => chaindataProvider?.evmNetworks(), [chaindataProvider]);
155
+ const tokenList = useLiveQuery(() => chaindataProvider?.tokens(), [chaindataProvider]);
156
+ const tokenRates = useLiveQuery(() => db.tokenRates.toArray(), []);
157
+ const rawBalances = useLiveQuery(() => db$1.balances.toArray(), []);
158
+ const [dbData, setDbData] = useState(DEFAULT_VALUE);
159
+
160
+ // debounce every 500ms to prevent hammering UI with updates
161
+ useDebounce(() => {
162
+ setDbData(consolidateDbCache(chainList, evmNetworkList, tokenList, tokenRates, rawBalances));
163
+ }, 500, [chainList, evmNetworkList, tokenList, tokenRates, rawBalances]);
164
+ const refInitialized = useRef(false);
165
+
166
+ // force an update as soon as all datasources are fetched, so UI can display data ASAP
167
+ useEffect(() => {
168
+ if (!refInitialized.current && chainList && evmNetworkList && tokenList && tokenRates && rawBalances) {
169
+ setDbData(consolidateDbCache(chainList, evmNetworkList, tokenList, tokenRates, rawBalances));
170
+ refInitialized.current = true;
171
+ }
172
+ }, [chainList, evmNetworkList, tokenList, tokenRates, rawBalances]);
173
+ return dbData;
174
+ };
175
+ const [DbCacheProvider, useDbCache] = provideContext(useDbCacheProvider);
10
176
 
11
177
  var packageJson = {
12
178
  name: "@talismn/balances-react",
13
- version: "0.3.3",
179
+ version: "0.4.1",
14
180
  author: "Talisman",
15
181
  homepage: "https://talisman.xyz",
16
182
  license: "UNLICENSED",
@@ -28,28 +194,31 @@ var packageJson = {
28
194
  "/dist"
29
195
  ],
30
196
  engines: {
31
- node: ">=14"
197
+ node: ">=18"
32
198
  },
33
199
  scripts: {
34
200
  test: "jest",
35
- lint: "eslint . --max-warnings 0",
201
+ lint: "eslint src --max-warnings 0",
36
202
  clean: "rm -rf dist && rm -rf .turbo rm -rf node_modules"
37
203
  },
38
204
  dependencies: {
39
- "@talismn/balances": "workspace:^",
40
- "@talismn/chain-connector": "workspace:^",
41
- "@talismn/chain-connector-evm": "workspace:^",
42
- "@talismn/chaindata-provider": "workspace:^",
43
- "@talismn/chaindata-provider-extension": "workspace:^",
44
- "@talismn/token-rates": "workspace:^",
205
+ "@talismn/balances": "workspace:*",
206
+ "@talismn/chain-connector": "workspace:*",
207
+ "@talismn/chain-connector-evm": "workspace:*",
208
+ "@talismn/chaindata-provider": "workspace:*",
209
+ "@talismn/chaindata-provider-extension": "workspace:*",
210
+ "@talismn/connection-meta": "workspace:*",
211
+ "@talismn/token-rates": "workspace:*",
45
212
  anylogger: "^1.0.11",
46
- dexie: "^3.2.2",
47
- "dexie-react-hooks": "^1.1.1",
48
- "react-use": "^17.4.0"
213
+ "blueimp-md5": "2.19.0",
214
+ dexie: "^3.2.3",
215
+ "dexie-react-hooks": "^1.1.3",
216
+ "react-use": "^17.4.0",
217
+ rxjs: "^7.8.1"
49
218
  },
50
219
  devDependencies: {
51
- "@talismn/eslint-config": "workspace:^",
52
- "@talismn/tsconfig": "workspace:^",
220
+ "@talismn/eslint-config": "workspace:*",
221
+ "@talismn/tsconfig": "workspace:*",
53
222
  "@types/jest": "^27.5.1",
54
223
  "@types/react": "^18.0.17",
55
224
  eslint: "^8.4.0",
@@ -72,139 +241,394 @@ var packageJson = {
72
241
 
73
242
  var log = anylogger(packageJson.name);
74
243
 
75
- // TODO: Allow user to call useChaindata from multiple places
76
- function useChaindata(options = {}) {
77
- const [chaindataProvider, setChaindataProvider] = useState(null);
244
+ // global data store containing all subscriptions
245
+ const subscriptions = {};
78
246
 
79
- // this number is incremented each time the chaindataProvider has fetched new data
80
- const [generation, setGeneration] = useState(0);
247
+ /**
248
+ * This hook ensures a subscription is created only once, and unsubscribe automatically as soon as there is no consumer to the hook
249
+ * @param key key that is unique to the subscription's parameters
250
+ * @param subscribe // subscribe function that will be shared by all consumers of the key
251
+ */
252
+ const useSharedSubscription = (key, subscribe) => {
253
+ // create the rxJS subject if it doesn't exist
254
+ if (!subscriptions[key]) subscriptions[key] = {
255
+ subject: new Subject()
256
+ };
81
257
  useEffect(() => {
82
- const chaindataProvider = new ChaindataProviderExtension({
83
- onfinalityApiKey: options.onfinalityApiKey
84
- });
85
- let shouldHydrate = true;
86
- const timer = 300_000; // 300_000ms = 300s = 5 minutes
87
- const hydrate = async () => {
88
- if (!shouldHydrate) return;
89
- try {
90
- const updated = await chaindataProvider.hydrate();
91
- if (updated) setGeneration(generation => (generation + 1) % Number.MAX_SAFE_INTEGER);
92
- setTimeout(hydrate, timer);
93
- } catch (error) {
94
- const retryTimeout = 5_000; // 5_000ms = 5 seconds
95
- log.error(`Failed to fetch chaindata, retrying in ${Math.round(retryTimeout / 1000)} seconds`, error);
96
- setTimeout(hydrate, retryTimeout);
258
+ // subscribe to subject.
259
+ // it won't change but we need to count subscribers, to unsubscribe main subscription when no more observers
260
+ const s = subscriptions[key].subject.subscribe();
261
+ return () => {
262
+ // unsubscribe from our local observable updates to prevent memory leaks
263
+ s.unsubscribe();
264
+ const {
265
+ subject,
266
+ unsubscribe
267
+ } = subscriptions[key];
268
+ if (!subject.observed && unsubscribe) {
269
+ log.debug(`[useSharedSubscription] unsubscribing ${key}`);
270
+
271
+ // unsubscribe from backend updates to prevent unnecessary network connections
272
+ unsubscribe();
273
+ delete subscriptions[key].unsubscribe;
97
274
  }
98
275
  };
99
- setChaindataProvider(chaindataProvider);
100
- hydrate();
276
+ }, [key]);
277
+
278
+ // Initialize subscription
279
+ useEffect(() => {
280
+ const {
281
+ unsubscribe
282
+ } = subscriptions[key];
283
+ // launch the subscription if it's a new key
284
+ if (!unsubscribe) {
285
+ const cb = subscribe();
286
+ log.debug(`[useSharedSubscription] subscribing ${key}`);
287
+ if (cb) subscriptions[key].unsubscribe = cb;
288
+ // this error should only happen when developping a new hook, let it bubble up
289
+ else throw new Error(`${key} subscribe did not return an unsubscribe callback`);
290
+ }
291
+ }, [key, subscribe]);
292
+ };
293
+
294
+ /**
295
+ * Creates a subscription function that can be used to subscribe to a multicast observable created from an upstream source.
296
+ *
297
+ * An example of when this is useful is when we want to subscribe to some data from multiple components, but we only want
298
+ * to actively keep that data hydrated when at least one component is subscribed to it.
299
+ *
300
+ * When the first component subscribes, the `upstream` function will be called. It should then set up a subscription and return a teardown function.
301
+ * When subsequent components subscribe, they will be added to the existing subscription.
302
+ * When the last component unsubscribes, the teardown function returned from the `upstream` function will be called.
303
+ *
304
+ * @param upstream A function that takes a "next" callback function as an argument, and returns either an unsubscribe function or void.
305
+ * @returns A subscription function that can be used to subscribe to the multicast observable.
306
+ */
307
+ const useMulticastSubscription = upstream => {
308
+ const subscribe = useMemo(() => createMulticastSubscription(upstream), [upstream]);
309
+ return subscribe;
310
+ };
311
+ const createMulticastSubscription = upstream => {
312
+ // Create an upstream observable using the provided function.
313
+ const upstreamObservable = new Observable(subscriber => {
314
+ const unsubscribe = upstream(val => subscriber.next(val));
101
315
  return () => {
102
- shouldHydrate = false;
316
+ typeof unsubscribe === "function" && unsubscribe();
103
317
  };
104
- }, [options.onfinalityApiKey]);
105
- if (chaindataProvider) chaindataProvider.generation = generation;
106
- return chaindataProvider;
318
+ });
319
+
320
+ // Create a multicast observable from the upstream observable, using the shareReplay operator.
321
+ const multicastObservable = defer(() => upstreamObservable).pipe(shareReplay({
322
+ bufferSize: 1,
323
+ refCount: true
324
+ }));
325
+
326
+ // Create a subscription function that subscribes to the multicast observable and returns an unsubscribe function.
327
+ const subscribe = callback => {
328
+ const subscription = multicastObservable.subscribe(callback);
329
+ const unsubscribe = () => subscription.unsubscribe();
330
+ return unsubscribe;
331
+ };
332
+ return subscribe;
333
+ };
334
+
335
+ const useWithTestnetsProvider = ({
336
+ withTestnets
337
+ }) => {
338
+ return {
339
+ withTestnets
340
+ };
341
+ };
342
+ const [WithTestnetsProvider, useWithTestnets] = provideContext(useWithTestnetsProvider);
343
+
344
+ /**
345
+ * This hook is responsible for fetching the data used for balances and inserting it into the db.
346
+ */
347
+ const useDbCacheSubscription = subscribeTo => {
348
+ const provider = useChaindata();
349
+
350
+ // can't handle balances & tokenRates here as they have other dependencies, it would trigger to many subscriptions
351
+ const subscribe = useCallback(() => {
352
+ switch (subscribeTo) {
353
+ case "chains":
354
+ return subscribeChainDataHydrate(provider, "chains");
355
+ case "evmNetworks":
356
+ return subscribeChainDataHydrate(provider, "evmNetworks");
357
+ case "tokens":
358
+ return subscribeChainDataHydrate(provider, "tokens");
359
+ }
360
+ }, [provider, subscribeTo]);
361
+ useSharedSubscription(subscribeTo, subscribe);
362
+ };
363
+
364
+ /**
365
+ * This hook is responsible for fetching the data used for token rates and inserting it into the db.
366
+ */
367
+ function useDbCacheTokenRatesSubscription() {
368
+ const {
369
+ withTestnets
370
+ } = useWithTestnets();
371
+ const tokens = useTokens$1(withTestnets);
372
+ const subscriptionKey = useMemo(
373
+ // not super sexy but we need key to change based on this stuff
374
+ () => {
375
+ const key = Object.values(tokens ?? {}).map(({
376
+ id
377
+ }) => id).sort().join();
378
+ return `tokenRates-${md5(key)}`;
379
+ }, [tokens]);
380
+ const subscription = useCallback(() => {
381
+ if (!Object.values(tokens ?? {}).length) return () => {};
382
+ return subscribeTokenRates(tokens);
383
+ }, [tokens]);
384
+ useSharedSubscription(subscriptionKey, subscription);
107
385
  }
108
- function useChains(chaindata) {
109
- const [chains, setChains] = useState();
110
- useEffect(() => {
111
- if (!chaindata) return;
112
- const thisGeneration = chaindata.generation;
113
- chaindata.chains().then(chains => {
114
- if (thisGeneration !== chaindata.generation) return;
115
- setChains(chains);
386
+
387
+ /**
388
+ * This hook is responsible for fetching the data used for balances and inserting it into the db.
389
+ */
390
+ function useDbCacheBalancesSubscription() {
391
+ const {
392
+ withTestnets
393
+ } = useWithTestnets();
394
+ const balanceModules = useBalanceModules();
395
+ const chaindataProvider = useChaindata();
396
+ const chainConnectors = useChainConnectors();
397
+ const [allAddresses] = useAllAddresses();
398
+ const tokens = useTokens$1(withTestnets);
399
+ const subscriptionKey = useMemo(
400
+ // not super sexy but we need key to change based on this stuff
401
+ () => {
402
+ const key = allAddresses.sort().join().concat(...Object.values(tokens ?? {}).map(({
403
+ id
404
+ }) => id).sort()).concat(`evm:${!!chainConnectors.evm}`, `sub:${!!chainConnectors.substrate}`, ...balanceModules.map(m => m.type).sort(), `cd:${!!chaindataProvider}`);
405
+ return `balances-${md5(key)}`;
406
+ }, [allAddresses, balanceModules, chainConnectors, chaindataProvider, tokens]);
407
+ const subscription = useCallback(() => {
408
+ if (!Object.values(tokens ?? {}).length || !allAddresses.length) return () => {};
409
+ return subscribeBalances(tokens ?? {}, allAddresses, balanceModules);
410
+ }, [allAddresses, balanceModules, tokens]);
411
+ useSharedSubscription(subscriptionKey, subscription);
412
+ }
413
+
414
+ // subscriptionless version of useTokens, prevents circular dependency
415
+ const useTokens$1 = withTestnets => {
416
+ const {
417
+ tokensWithTestnetsMap,
418
+ tokensWithoutTestnetsMap
419
+ } = useDbCache();
420
+ return withTestnets ? tokensWithTestnetsMap : tokensWithoutTestnetsMap;
421
+ };
422
+ const subscribeChainDataHydrate = (provider, type) => {
423
+ const chaindata = provider;
424
+ const delay = 300_000; // 300_000ms = 300s = 5 minutes
425
+
426
+ let timeout = null;
427
+ const hydrate = async () => {
428
+ try {
429
+ if (type === "chains") await chaindata.hydrateChains();
430
+ if (type === "evmNetworks") await chaindata.hydrateEvmNetworks();
431
+ if (type === "tokens") await chaindata.hydrateTokens();
432
+ timeout = setTimeout(hydrate, delay);
433
+ } catch (error) {
434
+ const retryTimeout = 5_000; // 5_000ms = 5 seconds
435
+ log.error(`Failed to fetch chaindata, retrying in ${Math.round(retryTimeout / 1000)} seconds`, error);
436
+ timeout = setTimeout(hydrate, retryTimeout);
437
+ }
438
+ };
439
+
440
+ // launch the loop
441
+ hydrate();
442
+ return () => {
443
+ if (timeout) clearTimeout(timeout);
444
+ };
445
+ };
446
+ const subscribeTokenRates = tokens => {
447
+ const REFRESH_INTERVAL = 300_000; // 6 minutes
448
+ const RETRY_INTERVAL = 5_000; // 5 sec
449
+
450
+ let timeout = null;
451
+ const refreshTokenRates = async () => {
452
+ try {
453
+ if (timeout) clearTimeout(timeout);
454
+ const tokenRates = await fetchTokenRates(tokens);
455
+ const putTokenRates = Object.entries(tokenRates).map(([tokenId, rates]) => ({
456
+ tokenId,
457
+ rates
458
+ }));
459
+ db.transaction("rw", db.tokenRates, async () => await db.tokenRates.bulkPut(putTokenRates));
460
+ timeout = setTimeout(() => {
461
+ refreshTokenRates();
462
+ }, REFRESH_INTERVAL);
463
+ } catch (error) {
464
+ log.error(`Failed to fetch tokenRates, retrying in ${Math.round(RETRY_INTERVAL / 1000)} seconds`, error);
465
+ setTimeout(async () => {
466
+ refreshTokenRates();
467
+ }, RETRY_INTERVAL);
468
+ }
469
+ };
470
+
471
+ // launch the loop
472
+ refreshTokenRates();
473
+ return () => {
474
+ if (timeout) clearTimeout(timeout);
475
+ };
476
+ };
477
+ const subscribeBalances = (tokens, addresses, balanceModules) => {
478
+ const tokenIds = Object.values(tokens).map(({
479
+ id
480
+ }) => id);
481
+ const addressesByToken = Object.fromEntries(tokenIds.map(tokenId => [tokenId, addresses]));
482
+ const subscriptionId = createSubscriptionId();
483
+
484
+ // TODO: Create subscriptions in a service worker, where we can detect page closes
485
+ // and therefore reliably delete the subscriptionId when the user closes our dapp
486
+ //
487
+ // For more information, check out https://developer.chrome.com/blog/page-lifecycle-api/#faqs
488
+ // and scroll down to:
489
+ // - `What is the back/forward cache?`, and
490
+ // - `If I can't run asynchronous APIs in the frozen or terminated states, how can I save data to IndexedDB?
491
+ //
492
+ // For now, we'll just last-ditch remove the subscriptionId (it works surprisingly well!) in the beforeunload event
493
+ window.onbeforeunload = () => {
494
+ deleteSubscriptionId();
495
+ };
496
+ const updateDb = balances => {
497
+ const putBalances = Object.entries(balances.toJSON()).map(([id, balance]) => ({
498
+ id,
499
+ ...balance,
500
+ status: BalanceStatusLive(subscriptionId)
501
+ }));
502
+ db$1.transaction("rw", db$1.balances, async () => await db$1.balances.bulkPut(putBalances));
503
+ };
504
+ let unsubscribed = false;
505
+
506
+ // eslint-disable-next-line no-console
507
+ log.log("subscribing to balance changes for %d tokens and %d addresses", tokenIds.length, addresses.length);
508
+ const unsubs = balanceModules.map(async balanceModule => {
509
+ // filter out tokens to only include those which this module knows how to fetch balances for
510
+ const moduleTokenIds = Object.values(tokens ?? {}).filter(({
511
+ type
512
+ }) => type === balanceModule.type).map(({
513
+ id
514
+ }) => id);
515
+ const addressesByModuleToken = Object.fromEntries(Object.entries(addressesByToken).filter(([tokenId]) => moduleTokenIds.includes(tokenId)));
516
+ const unsub = balances(balanceModule, addressesByModuleToken, (error, balances) => {
517
+ // log errors
518
+ if (error) {
519
+ if (error?.type === "STALE_RPC_ERROR" || error?.type === "WEBSOCKET_ALLOCATION_EXHAUSTED_ERROR") return db$1.balances.where({
520
+ source: balanceModule.type,
521
+ chainId: error.chainId
522
+ }).filter(balance => {
523
+ if (!Object.keys(addressesByModuleToken).includes(balance.tokenId)) return false;
524
+ if (!addressesByModuleToken[balance.tokenId].includes(balance.address)) return false;
525
+ return true;
526
+ }).modify({
527
+ status: "stale"
528
+ });
529
+ return log.error(`Failed to fetch ${balanceModule.type} balances`, error);
530
+ }
531
+ // ignore empty balance responses
532
+ if (!balances) return;
533
+ // ignore balances from old subscriptions which are still in the process of unsubscribing
534
+ if (unsubscribed) return;
535
+ updateDb(balances);
116
536
  });
117
- }, [chaindata, chaindata?.generation]);
118
- return chains || {};
537
+ return () => {
538
+ // wait 2 seconds before actually unsubscribing, allowing for websocket to be reused
539
+ unsub.then(unsubscribe => {
540
+ setTimeout(unsubscribe, 2_000);
541
+ });
542
+ deleteSubscriptionId();
543
+ };
544
+ });
545
+ const unsubscribeAll = () => {
546
+ unsubscribed = true;
547
+ unsubs.forEach(unsub => unsub.then(unsubscribe => unsubscribe()));
548
+ };
549
+ return unsubscribeAll;
550
+ };
551
+
552
+ function useChains(withTestnets) {
553
+ // keep db data up to date
554
+ useDbCacheSubscription("chains");
555
+ const {
556
+ chainsWithTestnetsMap,
557
+ chainsWithoutTestnetsMap
558
+ } = useDbCache();
559
+ return withTestnets ? chainsWithTestnetsMap : chainsWithoutTestnetsMap;
119
560
  }
120
- function useChain(chaindata, chainId) {
121
- const [chain, setChain] = useState();
122
- useEffect(() => {
123
- if (chaindata === null) return;
124
- if (!chainId) return;
125
- chaindata.getChain(chainId).then(setChain);
126
- }, [chainId, chaindata, chaindata?.generation]);
127
- return chain;
561
+ function useChain(chainId, withTestnets) {
562
+ const chains = useChains(withTestnets);
563
+ return chainId ? chains[chainId] : undefined;
128
564
  }
129
- function useEvmNetworks(chaindata) {
130
- const [evmNetworks, setEvmNetworks] = useState();
131
- useEffect(() => {
132
- if (!chaindata) return;
133
- const thisGeneration = chaindata.generation;
134
- chaindata.evmNetworks().then(evmNetworks => {
135
- if (thisGeneration !== chaindata.generation) return;
136
- setEvmNetworks(evmNetworks);
137
- });
138
- }, [chaindata, chaindata?.generation]);
139
- return evmNetworks || {};
565
+
566
+ function useEvmNetworks(withTestnets) {
567
+ // keep db data up to date
568
+ useDbCacheSubscription("evmNetworks");
569
+ const {
570
+ evmNetworksWithTestnetsMap,
571
+ evmNetworksWithoutTestnetsMap
572
+ } = useDbCache();
573
+ return withTestnets ? evmNetworksWithTestnetsMap : evmNetworksWithoutTestnetsMap;
140
574
  }
141
- function useEvmNetwork(chaindata, evmNetworkId) {
142
- const [evmNetwork, setEvmNetwork] = useState();
143
- useEffect(() => {
144
- if (chaindata === null) return;
145
- if (!evmNetworkId) return;
146
- chaindata.getEvmNetwork(evmNetworkId).then(setEvmNetwork);
147
- }, [chaindata, chaindata?.generation, evmNetworkId]);
148
- return evmNetwork;
575
+ function useEvmNetwork(evmNetworkId, withTestnets) {
576
+ const evmNetworks = useEvmNetworks(withTestnets);
577
+ return evmNetworkId ? evmNetworks[evmNetworkId] : undefined;
149
578
  }
150
- function useTokens(chaindata) {
151
- const [tokens, setTokens] = useState();
152
- useEffect(() => {
153
- if (!chaindata) return;
154
- const thisGeneration = chaindata.generation;
155
- chaindata.tokens().then(tokens => {
156
- if (thisGeneration !== chaindata.generation) return;
157
- setTokens(tokens);
158
- });
159
- }, [chaindata, chaindata?.generation]);
160
- return tokens || {};
579
+
580
+ function useTokenRates() {
581
+ // keep db data up to date
582
+ useDbCacheTokenRatesSubscription();
583
+ const {
584
+ tokenRatesMap
585
+ } = useDbCache();
586
+ return tokenRatesMap;
161
587
  }
162
- function useToken(chaindata, tokenId) {
163
- const [token, setToken] = useState();
164
- useEffect(() => {
165
- if (chaindata === null) return;
166
- if (!tokenId) return;
167
- chaindata.getToken(tokenId).then(setToken);
168
- }, [chaindata, chaindata?.generation, tokenId]);
169
- return token;
588
+ function useTokenRate(tokenId) {
589
+ const tokenRates = useTokenRates();
590
+ return tokenId ? tokenRates[tokenId] : undefined;
170
591
  }
171
592
 
172
- function useTokenRates(tokens) {
173
- const generation = useRef(0);
174
- const [tokenRates, setTokenRates] = useState();
175
- useEffect(() => {
176
- if (!tokens) return;
177
- if (Object.keys(tokens).length < 1) return;
178
-
179
- // when we make a new request, we want to ignore any old requests which haven't yet completed
180
- // otherwise we risk replacing the most recent data with older data
181
- generation.current = (generation.current + 1) % Number.MAX_SAFE_INTEGER;
182
- const thisGeneration = generation.current;
183
- fetchTokenRates(tokens).then(tokenRates => {
184
- if (thisGeneration !== generation.current) return;
185
- setTokenRates(tokenRates);
186
- });
187
- }, [tokens]);
188
- return tokenRates || {};
593
+ function useTokens(withTestnets) {
594
+ // keep db data up to date
595
+ useDbCacheSubscription("tokens");
596
+ const {
597
+ tokensWithTestnetsMap,
598
+ tokensWithoutTestnetsMap
599
+ } = useDbCache();
600
+ return withTestnets ? tokensWithTestnetsMap : tokensWithoutTestnetsMap;
601
+ }
602
+ function useToken(tokenId, withTestnets) {
603
+ const tokens = useTokens(withTestnets);
604
+ return tokenId ? tokens[tokenId] : undefined;
189
605
  }
190
606
 
191
- // TODO: Add the equivalent functionalty of `useDbCache` directly to this library.
192
- //
193
- // How it will work:
194
- //
195
- // useChains/useEvmNetworks/useTokens/useTokenRates will all make use of a
196
- // useCachedDb hook, which internally subscribes to all of the db tables
197
- // for everything, and then filters the subscribed data based on what params
198
- // the caller of useChains/useTokens/etc has provided.
199
- function useBalances(
200
- // TODO: Make this array of BalanceModules more type-safe
201
- balanceModules, chaindataProvider, addressesByToken, options = {}) {
202
- useBalancesSubscriptions(balanceModules, chaindataProvider, addressesByToken, options);
203
- const chains = useChains(chaindataProvider);
204
- const evmNetworks = useEvmNetworks(chaindataProvider);
205
- const tokens = useTokens(chaindataProvider);
206
- const tokenRates = useTokenRates(tokens);
207
- const balances = useLiveQuery(async () => new Balances(await db.balances.filter(balance => {
607
+ const useBalancesHydrate = () => {
608
+ const {
609
+ withTestnets
610
+ } = useWithTestnets();
611
+ const chains = useChains(withTestnets);
612
+ const evmNetworks = useEvmNetworks(withTestnets);
613
+ const tokens = useTokens(withTestnets);
614
+ const tokenRates = useTokenRates();
615
+ return useMemo(() => ({
616
+ chains,
617
+ evmNetworks,
618
+ tokens,
619
+ tokenRates
620
+ }), [chains, evmNetworks, tokens, tokenRates]);
621
+ };
622
+
623
+ function useBalances(addressesByToken) {
624
+ // keep db data up to date
625
+ useDbCacheBalancesSubscription();
626
+ const balanceModules = useBalanceModules();
627
+ const {
628
+ balances
629
+ } = useDbCache();
630
+ const hydrate = useBalancesHydrate();
631
+ return useMemo(() => new Balances(deriveStatuses([...getValidSubscriptionIds()], balances.filter(balance => {
208
632
  // check that this balance is included in our queried balance modules
209
633
  if (!balanceModules.map(({
210
634
  type
@@ -221,136 +645,60 @@ balanceModules, chaindataProvider, addressesByToken, options = {}) {
221
645
 
222
646
  // keep this balance
223
647
  return true;
224
- }).toArray(),
648
+ })),
225
649
  // hydrate balance chains, evmNetworks, tokens and tokenRates
226
- {
227
- chains,
228
- evmNetworks,
229
- tokens,
230
- tokenRates
231
- }), [balanceModules, addressesByToken, chains, evmNetworks, tokens, tokenRates]);
232
-
233
- // debounce every 100ms to prevent hammering UI with updates
234
- const [debouncedBalances, setDebouncedBalances] = useState(balances);
235
- useDebounce(() => balances && setDebouncedBalances(balances), 100, [balances]);
236
- return debouncedBalances;
650
+ hydrate), [balances, hydrate, balanceModules, addressesByToken]);
237
651
  }
238
652
 
239
- // TODO: Turn into react context
240
- const subscriptions = {};
653
+ /**
654
+ * Given a collection of `Balances`, this hook returns a `BalancesStatus` summary for the collection.
655
+ *
656
+ * @param balances The collection of balances to get the status from.
657
+ * @returns An instance of `BalancesStatus` which represents the status of the balances collection.
241
658
 
242
- // This hook is responsible for allowing us to call useBalances
243
- // from multiple components, without setting up unnecessary
244
- // balance subscriptions
245
- function useBalancesSubscriptions(
246
- // TODO: Make this array of BalanceModules more type-safe
247
- balanceModules, chaindataProvider, addressesByToken, options = {}) {
248
- // const subscriptions = useRef<
249
- // Record<string, { unsub: Promise<() => void>; refcount: number; generation: number }>
250
- // >({})
251
-
252
- const addSubscription = (key, balanceModule, chainConnectors, chaindataProvider, addressesByToken) => {
253
- // create subscription if it doesn't already exist
254
- if (!subscriptions[key] || subscriptions[key].refcount === 0) {
255
- const generation = ((subscriptions[key]?.generation || 0) + 1) % Number.MAX_SAFE_INTEGER;
256
- const unsub = balances(balanceModule, chainConnectors, chaindataProvider, addressesByToken, (error, balances) => {
257
- if (error) return log.error(`Failed to fetch ${balanceModule.type} balances`, error);
258
- if (!balances) return;
259
-
260
- // ignore balances from old subscriptions which are still in the process of unsubscribing
261
- if (subscriptions[key].generation !== generation) return;
262
- const putBalances = Object.entries(balances.toJSON()).map(([id, balance]) => ({
263
- id,
264
- ...balance
265
- }));
266
- db.transaction("rw", db.balances, async () => await db.balances.bulkPut(putBalances));
267
- });
268
- subscriptions[key] = {
269
- unsub,
270
- refcount: 0,
271
- generation
272
- };
273
- }
659
+ */
660
+ const useBalancesStatus = balances => useMemo(() => {
661
+ // stale
662
+ const staleChains = getStaleChains(balances);
663
+ if (staleChains.length > 0) return {
664
+ status: "stale",
665
+ staleChains
666
+ };
274
667
 
275
- // bump up the refcount by 1
276
- subscriptions[key].refcount += 1;
668
+ // fetching
669
+ const hasCachedBalances = balances.each.some(b => b.status === "cache");
670
+ if (hasCachedBalances) return {
671
+ status: "fetching"
277
672
  };
278
- const removeSubscription = (key, balanceModule, addressesByToken) => {
279
- // ignore dead subscriptions
280
- if (!subscriptions[key] || subscriptions[key].refcount === 0) return;
281
-
282
- // drop the refcount by one
283
- subscriptions[key].refcount -= 1;
284
-
285
- // unsubscribe if refcount is now 0 (nobody wants this subcription anymore)
286
- if (subscriptions[key].refcount < 1) {
287
- // remove subscription
288
- subscriptions[key].unsub.then(unsub => unsub());
289
- delete subscriptions[key];
290
-
291
- // set this subscription's balances in the store to status: cache
292
- db.transaction("rw", db.balances, async () => await db.balances.filter(balance => {
293
- if (balance.source !== balanceModule.type) return false;
294
- if (!Object.keys(addressesByToken).includes(balance.tokenId)) return false;
295
- if (!addressesByToken[balance.tokenId].includes(balance.address)) return false;
296
- return true;
297
- }).modify({
298
- status: "cache"
299
- }));
300
- }
673
+
674
+ // live
675
+ return {
676
+ status: "live"
301
677
  };
302
- const chainConnector = useChainConnector(chaindataProvider);
303
- const chainConnectorEvm = useChainConnectorEvm(chaindataProvider, options);
304
- const tokens = useTokens(chaindataProvider);
305
- useEffect(() => {
306
- if (chainConnector === null) return;
307
- if (chainConnectorEvm === null) return;
308
- if (chaindataProvider === null) return;
309
- if (addressesByToken === null) return;
310
- const unsubs = balanceModules.map(balanceModule => {
311
- const subscriptionKey = `${balanceModule.type}-${JSON.stringify(addressesByToken)}`;
312
-
313
- // filter out tokens to only include those which this module knows how to fetch balances for
314
- const moduleTokenIds = Object.values(tokens).filter(({
315
- type
316
- }) => type === balanceModule.type).map(({
317
- id
318
- }) => id);
319
- const addressesByModuleToken = Object.fromEntries(Object.entries(addressesByToken).filter(([tokenId]) => moduleTokenIds.includes(tokenId)));
320
-
321
- // add balance subscription for this module
322
- addSubscription(subscriptionKey, balanceModule, {
323
- substrate: chainConnector,
324
- evm: chainConnectorEvm
325
- }, chaindataProvider, addressesByModuleToken);
326
-
327
- // return an unsub method, to be called when this effect unmounts
328
- return () => removeSubscription(subscriptionKey, balanceModule, addressesByToken);
329
- });
330
- const unsubAll = () => unsubs.forEach(unsub => unsub());
331
- return unsubAll;
332
- }, [addressesByToken, balanceModules, chainConnector, chainConnectorEvm, chaindataProvider, tokens]);
333
- }
678
+ }, [balances]);
679
+ const getStaleChains = balances => [...new Set(balances.sorted.filter(b => b.status === "stale").map(b => b.chain?.name ?? b.chainId ?? "Unknown"))];
334
680
 
335
- // TODO: Allow advanced users of this library to provide their own chain connector
336
- function useChainConnector(chaindataProvider) {
337
- const [chainConnector, setChainConnector] = useState(null);
338
- useEffect(() => {
339
- if (chaindataProvider === null) return;
340
- setChainConnector(new ChainConnector(chaindataProvider));
341
- }, [chaindataProvider]);
342
- return chainConnector;
343
- }
344
- // TODO: Allow advanced users of this library to provide their own chain connector
345
- function useChainConnectorEvm(chaindataProvider, options = {}) {
346
- const [chainConnectorEvm, setChainConnectorEvm] = useState(null);
347
- useEffect(() => {
348
- if (chaindataProvider === null) return;
349
- setChainConnectorEvm(new ChainConnectorEvm(chaindataProvider, {
350
- onfinalityApiKey: options.onfinalityApiKey
351
- }));
352
- }, [chaindataProvider, options.onfinalityApiKey]);
353
- return chainConnectorEvm;
354
- }
681
+ const BalancesProvider = ({
682
+ balanceModules,
683
+ onfinalityApiKey,
684
+ withTestnets,
685
+ children
686
+ }) => /*#__PURE__*/jsx(WithTestnetsProvider, {
687
+ withTestnets: withTestnets,
688
+ children: /*#__PURE__*/jsx(ChaindataProvider, {
689
+ onfinalityApiKey: onfinalityApiKey,
690
+ children: /*#__PURE__*/jsx(ChainConnectorsProvider, {
691
+ onfinalityApiKey: onfinalityApiKey,
692
+ children: /*#__PURE__*/jsx(AllAddressesProvider, {
693
+ children: /*#__PURE__*/jsx(BalanceModulesProvider, {
694
+ balanceModules: balanceModules,
695
+ children: /*#__PURE__*/jsx(DbCacheProvider, {
696
+ children: children
697
+ })
698
+ })
699
+ })
700
+ })
701
+ })
702
+ });
355
703
 
356
- export { useBalances, useChain, useChaindata, useChains, useEvmNetwork, useEvmNetworks, useToken, useTokenRates, useTokens };
704
+ export { AllAddressesProvider, BalanceModulesProvider, BalancesProvider, ChainConnectorsProvider, ChaindataProvider, DbCacheProvider, WithTestnetsProvider, createMulticastSubscription, getStaleChains, provideContext, useAllAddresses, useBalanceModules, useBalances, useBalancesHydrate, useBalancesStatus, useChain, useChainConnectors, useChaindata, useChains, useDbCache, useDbCacheBalancesSubscription, useDbCacheSubscription, useDbCacheTokenRatesSubscription, useEvmNetwork, useEvmNetworks, useMulticastSubscription, useToken, useTokenRate, useTokenRates, useTokens, useWithTestnets };