Skip to content

Commit 383085e

Browse files
feat: asset tracker now uses local cache before fetching asset metadata
1 parent cfb81dc commit 383085e

File tree

6 files changed

+437
-35
lines changed

6 files changed

+437
-35
lines changed

packages/core/src/Asset/types/AssetInfo.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ export interface AssetInfo {
2525
tokenMetadata?: TokenMetadata | null;
2626
/** CIP-0025. `undefined` if not loaded, `null` if no metadata found */
2727
nftMetadata?: NftMetadata | null;
28+
staleAt?: Date | null;
2829
}

packages/wallet/src/Wallets/BaseWallet.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
DelegationTracker,
2323
DynamicChangeAddressResolver,
2424
FailedTx,
25+
Milliseconds,
2526
OutgoingTx,
2627
PersistentDocumentTrackerSubject,
2728
PollingConfig,
@@ -78,6 +79,8 @@ import {
7879
Subject,
7980
Subscription,
8081
catchError,
82+
defaultIfEmpty,
83+
defer,
8184
distinctUntilChanged,
8285
filter,
8386
firstValueFrom,
@@ -116,6 +119,7 @@ export interface BaseWalletProps {
116119
readonly name: string;
117120
readonly polling?: PollingConfig;
118121
readonly retryBackoffConfig?: RetryBackoffConfig;
122+
readonly maxAssetInfoCacheAge?: Milliseconds;
119123
}
120124

121125
export enum PublicCredentialsManagerType {
@@ -295,6 +299,7 @@ export class BaseWallet implements ObservableWallet {
295299
constructor(
296300
{
297301
name,
302+
maxAssetInfoCacheAge,
298303
polling: {
299304
interval: pollInterval = DEFAULT_POLLING_CONFIG.pollInterval,
300305
maxInterval = pollInterval * DEFAULT_POLLING_CONFIG.maxIntervalMultiplier,
@@ -564,10 +569,17 @@ export class BaseWallet implements ObservableWallet {
564569
: new TrackerSubject(of(new Array<PubStakeKeyAndStatus>()));
565570

566571
this.balance = createBalanceTracker(this.protocolParameters$, this.utxo, this.delegation);
572+
573+
// TODO[LW-11929]: Implement `observe` method in DocumentStore interface.
574+
const assetsCache$ = defer(() => stores.assets.get().pipe(defaultIfEmpty(new Map())));
575+
567576
this.assetInfo$ = new PersistentDocumentTrackerSubject(
568577
createAssetsTracker({
569578
assetProvider: this.assetProvider,
579+
assetsCache$,
580+
balanceTracker: this.balance,
570581
logger: contextLogger(this.#logger, 'assets$'),
582+
maxAssetInfoCacheAge,
571583
onFatalError,
572584
retryBackoffConfig,
573585
transactionsTracker: this.transactions

packages/wallet/src/services/AssetsTracker.ts

Lines changed: 123 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { Asset, Cardano } from '@cardano-sdk/core';
2+
import { Assets } from '../types';
3+
import { BalanceTracker, Milliseconds, TransactionsTracker } from './types';
24
import { Logger } from 'ts-log';
35
import {
46
Observable,
@@ -8,6 +10,7 @@ import {
810
debounceTime,
911
distinctUntilChanged,
1012
filter,
13+
firstValueFrom,
1114
map,
1215
of,
1316
share,
@@ -17,7 +20,6 @@ import {
1720
} from 'rxjs';
1821
import { RetryBackoffConfig } from 'backoff-rxjs';
1922
import { TrackedAssetProvider } from './ProviderTracker';
20-
import { TransactionsTracker } from './types';
2123
import { coldObservableProvider, concatAndCombineLatest } from '@cardano-sdk/util-rxjs';
2224
import { deepEquals, isNotNil } from '@cardano-sdk/util';
2325
import { newTransactions$ } from './TransactionsTracker';
@@ -35,11 +37,102 @@ const bufferTick =
3537
source$.pipe(connect((shared$) => shared$.pipe(buffer(shared$.pipe(debounceTime(1))))));
3638

3739
const ASSET_INFO_FETCH_CHUNK_SIZE = 100;
40+
const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;
41+
42+
const isInBalance = (assetId: Cardano.AssetId, balance: Cardano.Value): boolean =>
43+
balance.assets?.has(assetId) ?? false;
44+
45+
/**
46+
* Splits a list of asset IDs into cached and uncached groups based on their presence in the cache,
47+
* their freshness, and their balance status:
48+
*
49+
* 1. Assets not in Balance:
50+
* - Always use the cached version if present in the cache, ignoring freshness.
51+
* 2. Assets in Balance:
52+
* - Use the cached version only if it exists and its `staleAt` timestamp already expired.
53+
* 3. Uncached Assets:
54+
* - If an asset is not in the cache or does not meet the above criteria, mark it as uncached.
55+
*/
56+
const splitCachedAndUncachedAssets = (
57+
cache: Assets,
58+
balance: Cardano.Value,
59+
assetIds: Cardano.AssetId[]
60+
): { cachedAssets: Assets; uncachedAssetIds: Cardano.AssetId[] } => {
61+
const cachedAssets: Assets = new Map();
62+
const uncachedAssetIds: Cardano.AssetId[] = [];
63+
const now = new Date();
64+
65+
for (const id of assetIds) {
66+
const cachedAssetInfo = cache.get(id);
67+
68+
if (!cachedAssetInfo) {
69+
uncachedAssetIds.push(id);
70+
continue;
71+
}
72+
73+
const { staleAt } = cachedAssetInfo;
74+
75+
const expired = !staleAt || new Date(staleAt) < now;
76+
77+
const mustFetch = !isAssetInfoComplete(cachedAssetInfo) || (isInBalance(id, balance) && expired);
78+
79+
if (mustFetch) {
80+
uncachedAssetIds.push(id);
81+
} else {
82+
cachedAssets.set(id, cachedAssetInfo);
83+
}
84+
}
85+
86+
return { cachedAssets, uncachedAssetIds };
87+
};
88+
89+
const getAssetsWithCache = async (
90+
assetIdsChunk: Cardano.AssetId[],
91+
assetCache$: Observable<Assets>,
92+
totalBalance$: Observable<Cardano.Value>,
93+
assetProvider: TrackedAssetProvider,
94+
maxAssetInfoCacheAge: Milliseconds
95+
): Promise<Asset.AssetInfo[]> => {
96+
const [cache, totalValue] = await Promise.all([firstValueFrom(assetCache$), firstValueFrom(totalBalance$)]);
97+
98+
const { cachedAssets, uncachedAssetIds } = splitCachedAndUncachedAssets(cache, totalValue, assetIdsChunk);
99+
100+
if (uncachedAssetIds.length === 0) {
101+
// If all assets are cached we wont perform any fetches from assetProvider, but still need to
102+
// mark it as initialized.
103+
if (!assetProvider.stats.getAsset$.value.initialized) {
104+
assetProvider.setStatInitialized(assetProvider.stats.getAsset$);
105+
}
106+
107+
return [...cachedAssets.values()];
108+
}
109+
110+
const fetchedAssets = await assetProvider.getAssets({
111+
assetIds: uncachedAssetIds,
112+
extraData: { nftMetadata: true, tokenMetadata: true }
113+
});
114+
115+
const now = Date.now();
116+
const updatedFetchedAssets = fetchedAssets.map((asset) => {
117+
const randomDelta = Math.floor(Math.random() * 2 * 24 * 60 * 60 * 1000); // Random time between 0 and 2 days
118+
return {
119+
...asset,
120+
staleAt: new Date(now + maxAssetInfoCacheAge + randomDelta)
121+
};
122+
});
123+
124+
return [...cachedAssets.values(), ...updatedFetchedAssets];
125+
};
126+
38127
export const createAssetService =
39128
(
40129
assetProvider: TrackedAssetProvider,
130+
assetCache$: Observable<Assets>,
131+
totalBalance$: Observable<Cardano.Value>,
41132
retryBackoffConfig: RetryBackoffConfig,
42-
onFatalError?: (value: unknown) => void
133+
onFatalError?: (value: unknown) => void,
134+
maxAssetInfoCacheAge: Milliseconds = ONE_WEEK
135+
// eslint-disable-next-line max-params
43136
) =>
44137
(assetIds: Cardano.AssetId[]) =>
45138
concatAndCombineLatest(
@@ -48,23 +141,24 @@ export const createAssetService =
48141
onFatalError,
49142
pollUntil: isEveryAssetInfoComplete,
50143
provider: () =>
51-
assetProvider.getAssets({
52-
assetIds: assetIdsChunk,
53-
extraData: { nftMetadata: true, tokenMetadata: true }
54-
}),
144+
getAssetsWithCache(assetIdsChunk, assetCache$, totalBalance$, assetProvider, maxAssetInfoCacheAge),
55145
retryBackoffConfig,
56146
trigger$: of(true) // fetch only once
57147
})
58148
)
59-
).pipe(map((arr) => arr.flat())); // concat the chunk results
149+
).pipe(map((arr) => arr.flat())); // Concatenate the chunk results
150+
60151
export type AssetService = ReturnType<typeof createAssetService>;
61152

62153
export interface AssetsTrackerProps {
63154
transactionsTracker: TransactionsTracker;
64155
assetProvider: TrackedAssetProvider;
65156
retryBackoffConfig: RetryBackoffConfig;
66157
logger: Logger;
158+
assetsCache$: Observable<Assets>;
159+
balanceTracker: BalanceTracker;
67160
onFatalError?: (value: unknown) => void;
161+
maxAssetInfoCacheAge?: Milliseconds;
68162
}
69163

70164
interface AssetsTrackerInternals {
@@ -76,8 +170,28 @@ const uniqueAssetIds = ({ body: { outputs } }: Cardano.OnChainTx) =>
76170
const flatUniqueAssetIds = (txes: Cardano.OnChainTx[]) => uniq(txes.flatMap(uniqueAssetIds));
77171

78172
export const createAssetsTracker = (
79-
{ assetProvider, transactionsTracker: { history$ }, retryBackoffConfig, logger, onFatalError }: AssetsTrackerProps,
80-
{ assetService = createAssetService(assetProvider, retryBackoffConfig, onFatalError) }: AssetsTrackerInternals = {}
173+
{
174+
assetProvider,
175+
assetsCache$,
176+
transactionsTracker: { history$ },
177+
balanceTracker: {
178+
utxo: { total$ }
179+
},
180+
retryBackoffConfig,
181+
logger,
182+
onFatalError,
183+
maxAssetInfoCacheAge
184+
}: AssetsTrackerProps,
185+
{
186+
assetService = createAssetService(
187+
assetProvider,
188+
assetsCache$,
189+
total$,
190+
retryBackoffConfig,
191+
onFatalError,
192+
maxAssetInfoCacheAge
193+
)
194+
}: AssetsTrackerInternals = {}
81195
) =>
82196
new Observable<Map<Cardano.AssetId, Asset.AssetInfo>>((subscriber) => {
83197
let fetchedAssetInfoMap = new Map<Cardano.AssetId, Asset.AssetInfo>();

packages/wallet/test/PersonalWallet/load.test.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,7 @@ import {
1212
} from '../../src';
1313
import { AddressType, AsyncKeyAgent, Bip32Account, GroupedAddress, util } from '@cardano-sdk/key-management';
1414
import {
15-
AssetId,
16-
createStubStakePoolProvider,
17-
generateRandomBigInt,
18-
generateRandomHexString,
19-
mockProviders as mocks,
20-
somePartialStakePools
21-
} from '@cardano-sdk/util-dev';
22-
import {
15+
Asset,
2316
Cardano,
2417
ChainHistoryProvider,
2518
HandleProvider,
@@ -28,6 +21,14 @@ import {
2821
UtxoProvider,
2922
coalesceValueQuantities
3023
} from '@cardano-sdk/core';
24+
import {
25+
AssetId,
26+
createStubStakePoolProvider,
27+
generateRandomBigInt,
28+
generateRandomHexString,
29+
mockProviders as mocks,
30+
somePartialStakePools
31+
} from '@cardano-sdk/util-dev';
3132
import { InvalidConfigurationError } from '@cardano-sdk/tx-construction';
3233
import { InvalidStringError } from '@cardano-sdk/util';
3334
import { ReplaySubject, firstValueFrom } from 'rxjs';
@@ -142,6 +143,9 @@ const createWallet = async (props: CreateWalletProps) => {
142143
);
143144
};
144145

146+
const removeStaleAt = (assetInfos: Map<Cardano.AssetId, Asset.AssetInfo>) =>
147+
new Map([...assetInfos.entries()].map(([key, value]) => [key, { ...value, staleAt: undefined }]));
148+
145149
const assertWalletProperties = async (
146150
wallet: BaseWallet,
147151
expectedDelegateeId: Cardano.PoolId | undefined,
@@ -188,7 +192,7 @@ const assertWalletProperties = async (
188192
expect(rewardAccounts[0].delegatee?.nextNextEpoch?.id).toEqual(expectedDelegateeId);
189193
expect(rewardAccounts[0].rewardBalance).toBe(mocks.rewardAccountBalance);
190194
// assets$
191-
expect(await firstValueFrom(wallet.assetInfo$)).toEqual(
195+
expect(removeStaleAt(await firstValueFrom(wallet.assetInfo$))).toEqual(
192196
new Map([
193197
[AssetId.TSLA, mocks.asset],
194198
[handleAssetId, handleAssetInfo]

packages/wallet/test/PersonalWallet/shutdown.test.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
/* eslint-disable max-statements */
22
/* eslint-disable @typescript-eslint/no-explicit-any */
33
import { AddressType, Bip32Account, GroupedAddress, util } from '@cardano-sdk/key-management';
4+
import {
5+
Asset,
6+
Cardano,
7+
ChainHistoryProvider,
8+
NetworkInfoProvider,
9+
RewardsProvider,
10+
UtxoProvider,
11+
coalesceValueQuantities
12+
} from '@cardano-sdk/core';
413
import {
514
AssetId,
615
createStubStakePoolProvider,
@@ -15,14 +24,6 @@ import {
1524
WalletNetworkInfoProviderStats,
1625
createPersonalWallet
1726
} from '../../src';
18-
import {
19-
Cardano,
20-
ChainHistoryProvider,
21-
NetworkInfoProvider,
22-
RewardsProvider,
23-
UtxoProvider,
24-
coalesceValueQuantities
25-
} from '@cardano-sdk/core';
2627
import { WalletStores, createInMemoryWalletStores } from '../../src/persistence';
2728
import { firstValueFrom } from 'rxjs';
2829
import { dummyLogger as logger } from 'ts-log';
@@ -81,6 +82,9 @@ const createWallet = async (stores: WalletStores, providers: Providers, pollingC
8182
);
8283
};
8384

85+
const removeStaleAt = (assetInfos: Map<Cardano.AssetId, Asset.AssetInfo>) =>
86+
new Map([...assetInfos.entries()].map(([key, value]) => [key, { ...value, staleAt: undefined }]));
87+
8488
const assertWalletProperties = async (
8589
wallet: BaseWallet,
8690
expectedDelegateeId: Cardano.PoolId | undefined,
@@ -126,7 +130,7 @@ const assertWalletProperties = async (
126130
expect(addresses[0].address).toEqual(address);
127131
expect(addresses[0].rewardAccount).toEqual(rewardAccount);
128132
// assets$
129-
expect(await firstValueFrom(wallet.assetInfo$)).toEqual(
133+
expect(removeStaleAt(await firstValueFrom(wallet.assetInfo$))).toEqual(
130134
new Map([
131135
[AssetId.TSLA, mocks.asset],
132136
[mocks.handleAssetId, mocks.handleAssetInfo]

0 commit comments

Comments
 (0)