Skip to content

Commit fb58bf2

Browse files
Merge pull request #1539 from input-output-hk/feat/lw-11893-use-cache-for-wallet-assets
feat: asset tracker now uses local cache before fetching asset metadata
2 parents cfb81dc + 0fd4b5b commit fb58bf2

File tree

6 files changed

+441
-35
lines changed

6 files changed

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

62154
export interface AssetsTrackerProps {
63155
transactionsTracker: TransactionsTracker;
64156
assetProvider: TrackedAssetProvider;
65157
retryBackoffConfig: RetryBackoffConfig;
66158
logger: Logger;
159+
assetsCache$: Observable<Assets>;
160+
balanceTracker: BalanceTracker;
67161
onFatalError?: (value: unknown) => void;
162+
maxAssetInfoCacheAge?: Milliseconds;
68163
}
69164

70165
interface AssetsTrackerInternals {
@@ -76,8 +171,28 @@ const uniqueAssetIds = ({ body: { outputs } }: Cardano.OnChainTx) =>
76171
const flatUniqueAssetIds = (txes: Cardano.OnChainTx[]) => uniq(txes.flatMap(uniqueAssetIds));
77172

78173
export const createAssetsTracker = (
79-
{ assetProvider, transactionsTracker: { history$ }, retryBackoffConfig, logger, onFatalError }: AssetsTrackerProps,
80-
{ assetService = createAssetService(assetProvider, retryBackoffConfig, onFatalError) }: AssetsTrackerInternals = {}
174+
{
175+
assetProvider,
176+
assetsCache$,
177+
transactionsTracker: { history$ },
178+
balanceTracker: {
179+
utxo: { total$ }
180+
},
181+
retryBackoffConfig,
182+
logger,
183+
onFatalError,
184+
maxAssetInfoCacheAge
185+
}: AssetsTrackerProps,
186+
{
187+
assetService = createAssetService(
188+
assetProvider,
189+
assetsCache$,
190+
total$,
191+
retryBackoffConfig,
192+
onFatalError,
193+
maxAssetInfoCacheAge
194+
)
195+
}: AssetsTrackerInternals = {}
81196
) =>
82197
new Observable<Map<Cardano.AssetId, Asset.AssetInfo>>((subscriber) => {
83198
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)