diff --git a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts index 88b47e9ea4e..d766a68e376 100644 --- a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts +++ b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts @@ -522,17 +522,15 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement const allTransactions = addressTransactions.flat(1); - const dedupedSortedTransactions = uniq( + const dedupedSortedTransactionsIds = uniq( allTransactions .filter(({ block_height }) => block_height >= lowerBound && block_height <= upperBound) .sort(pagination.order === 'desc' ? (a, b) => compareTx(b, a) : compareTx) .map(({ tx_hash }) => Cardano.TransactionId(tx_hash)) ); - const ids = dedupedSortedTransactions.slice(pagination.startAt, pagination.limit); + const pageResults = await this.transactionsByHashes({ ids: dedupedSortedTransactionsIds }); - const pageResults = await this.transactionsByHashes({ ids }); - - return { pageResults, totalResultCount: dedupedSortedTransactions.length }; + return { pageResults, totalResultCount: dedupedSortedTransactionsIds.length }; } catch (error) { throw this.toProviderError(error); } diff --git a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts index 18e94fbc8bb..b461131e711 100644 --- a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts +++ b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts @@ -11,8 +11,14 @@ describe('blockfrostChainHistoryProvider', () => { let provider: BlockfrostChainHistoryProvider; let networkInfoProvider: NetworkInfoProvider; + const txId1 = Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + const txId2 = Cardano.TransactionId('2e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + const address1 = Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr'); + const address2 = Cardano.PaymentAddress( + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' + ); const txsUtxosResponse = { - hash: '4123d70f66414cc921f6ffc29a899aafc7137a99a0fd453d6b200863ef5702d6', + hash: txId1, inputs: [ { address: @@ -58,13 +64,13 @@ describe('blockfrostChainHistoryProvider', () => { } ] }; - const mockedTxResponse = { + const mockedTx1Response = { asset_mint_or_burn_count: 5, block: '356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940', block_height: 123_456, delegation_count: 0, fees: '182485', - hash: '1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477', + hash: txId1, index: 1, invalid_before: null, invalid_hereafter: '13885913', @@ -89,6 +95,10 @@ describe('blockfrostChainHistoryProvider', () => { valid_contract: true, withdrawal_count: 1 }; + const mockedTx2Response = { + ...mockedTx1Response, + hash: txId2 + }; const mockedMetadataResponse = [ { json_metadata: { @@ -186,11 +196,20 @@ describe('blockfrostChainHistoryProvider', () => { unit_steps: '476468' } ]; + const mockedAddressTransactionResponse: Responses['address_transactions_content'] = [ { block_height: 123, block_time: 131_322, - tx_hash: '1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477', + tx_hash: txId1, + tx_index: 0 + } + ]; + const mockedAddress2TransactionResponse: Responses['address_transactions_content'] = [ + { + block_height: 124, + block_time: 131_322, + tx_hash: txId2, tx_index: 0 } ]; @@ -352,25 +371,30 @@ describe('blockfrostChainHistoryProvider', () => { ]) } as unknown as NetworkInfoProvider; provider = new BlockfrostChainHistoryProvider(client, networkInfoProvider, logger); - const txId = Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); - const id = txId.toString(); mockResponses(request, [ - [`txs/${id}/utxos`, txsUtxosResponse], - [`txs/${id}`, mockedTxResponse], - [`txs/${id}/metadata`, mockedMetadataResponse], - [`txs/${id}/mirs`, mockedMirResponse], - [`txs/${id}/pool_updates`, mockedPoolUpdateResponse], - [`txs/${id}/pool_retires`, mockedPoolRetireResponse], - [`txs/${id}/stakes`, mockedStakeResponse], - [`txs/${id}/delegations`, mockedDelegationResponse], - [`txs/${id}/withdrawals`, mockedWithdrawalResponse], - [`txs/${id}/redeemers`, mockedReedemerResponse], - [ - `addresses/${Cardano.PaymentAddress( - '2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr' - ).toString()}/transactions?page=1&count=20`, - mockedAddressTransactionResponse - ], + [`txs/${txId1}/utxos`, txsUtxosResponse], + [`txs/${txId1}`, mockedTx1Response], + [`txs/${txId1}/metadata`, mockedMetadataResponse], + [`txs/${txId1}/mirs`, mockedMirResponse], + [`txs/${txId1}/pool_updates`, mockedPoolUpdateResponse], + [`txs/${txId1}/pool_retires`, mockedPoolRetireResponse], + [`txs/${txId1}/stakes`, mockedStakeResponse], + [`txs/${txId1}/delegations`, mockedDelegationResponse], + [`txs/${txId1}/withdrawals`, mockedWithdrawalResponse], + [`txs/${txId1}/redeemers`, mockedReedemerResponse], + [`txs/${txId2}/utxos`, txsUtxosResponse], + [`txs/${txId2}`, mockedTx2Response], + [`txs/${txId2}/metadata`, mockedMetadataResponse], + [`txs/${txId2}/mirs`, mockedMirResponse], + [`txs/${txId2}/pool_updates`, mockedPoolUpdateResponse], + [`txs/${txId2}/pool_retires`, mockedPoolRetireResponse], + [`txs/${txId2}/stakes`, mockedStakeResponse], + [`txs/${txId2}/delegations`, mockedDelegationResponse], + [`txs/${txId2}/withdrawals`, mockedWithdrawalResponse], + [`txs/${txId2}/redeemers`, mockedReedemerResponse], + [`addresses/${address1}/transactions?page=1&count=20`, mockedAddressTransactionResponse], + [`addresses/${address1}/transactions?page=1&count=1`, mockedAddressTransactionResponse], + [`addresses/${address2}/transactions?page=1&count=1`, mockedAddress2TransactionResponse], [ `addresses/${Cardano.PaymentAddress( 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' @@ -382,7 +406,7 @@ describe('blockfrostChainHistoryProvider', () => { mockedAddressTransactionDescResponse ], ['epochs/420000/parameters', mockedEpochParametersResponse], - [`txs/${id}/cbor`, new Error('CBOR is null')] + [`txs/${txId1}/cbor`, new Error('CBOR is null')] ]); }); @@ -394,7 +418,7 @@ describe('blockfrostChainHistoryProvider', () => { pagination: { limit: 20, startAt: 0 } }); - expect(response.totalResultCount).toBe(1); + expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); expect(response.pageResults[0]).toEqual(expectedHydratedTx); }); test('supports desc order', async () => { @@ -418,6 +442,16 @@ describe('blockfrostChainHistoryProvider', () => { expect(response.pageResults).toHaveLength(mockedAddressTransactionResponse.length); expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); }); + test('returns up to the {limit*addresses.length} number of transactions', async () => { + const response = await provider.transactionsByAddresses({ + addresses: [address1, address2], + pagination: { limit: 1, startAt: 0 } + }); + + const totalResultCount = mockedAddressTransactionResponse.length + mockedAddress2TransactionResponse.length; + expect(response.totalResultCount).toBe(totalResultCount); + expect(response.pageResults.length).toBe(totalResultCount); + }); }); describe('transactionsByHashes', () => { @@ -514,7 +548,7 @@ describe('blockfrostChainHistoryProvider', () => { const id = txId.toString(); mockResponses(request, [ [`txs/${id}/utxos`, txsUtxosResponse], - [`txs/${id}`, mockedTxResponse], + [`txs/${id}`, mockedTx1Response], [`txs/${id}/metadata`, mockedMetadataResponse], [`txs/${id}/mirs`, mockedMirResponse], [`txs/${id}/pool_updates`, mockedPoolUpdateResponse], @@ -541,7 +575,7 @@ describe('blockfrostChainHistoryProvider', () => { pagination: { limit: 20, startAt: 0 } }); - expect(response.totalResultCount).toBe(1); + expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); expect(response.pageResults[0]).toEqual(expectedHydratedTxCBOR); }); }); diff --git a/packages/wallet/src/services/AssetsTracker.ts b/packages/wallet/src/services/AssetsTracker.ts index 42b93b001e6..e3f70d8dd3f 100644 --- a/packages/wallet/src/services/AssetsTracker.ts +++ b/packages/wallet/src/services/AssetsTracker.ts @@ -5,6 +5,7 @@ import { Logger } from 'ts-log'; import { Observable, buffer, + combineLatest, concat, connect, debounceTime, @@ -151,6 +152,11 @@ export const createAssetService = ).pipe(map((arr) => arr.flat())); // Concatenate the chunk results export type AssetService = ReturnType; +export type UtxoTotalBalance = { + utxo: { + total$: BalanceTracker['utxo']['total$']; + }; +}; export interface AssetsTrackerProps { transactionsTracker: TransactionsTracker; @@ -158,7 +164,7 @@ export interface AssetsTrackerProps { retryBackoffConfig: RetryBackoffConfig; logger: Logger; assetsCache$: Observable; - balanceTracker: BalanceTracker; + balanceTracker: UtxoTotalBalance; maxAssetInfoCacheAge?: Milliseconds; } @@ -198,13 +204,14 @@ export const createAssetsTracker = ( const allAssetIds = new Set(); const sharedHistory$ = history$.pipe(share()); return concat( - sharedHistory$.pipe( - map((historyTxs) => uniq(historyTxs.flatMap(uniqueAssetIds))), + combineLatest([ + sharedHistory$.pipe(map((historyTxs) => uniq(historyTxs.flatMap(uniqueAssetIds)))), + total$.pipe(map((balance) => [...(balance.assets?.keys() || [])])) + ]).pipe( + map(([txAssets, balanceAssets]) => uniq([...txAssets, ...balanceAssets])), tap((assetIds) => logger.debug( - assetIds.length > 0 - ? `Historical total assets: ${assetIds.length}` - : 'Setting assetProvider stats as initialized' + assetIds.length > 0 ? `Total assets: ${assetIds.length}` : 'Setting assetProvider stats as initialized' ) ), tap((assetIds) => assetIds.length === 0 && assetProvider.setStatInitialized(assetProvider.stats.getAsset$)), diff --git a/packages/wallet/test/services/AssetsTracker.test.ts b/packages/wallet/test/services/AssetsTracker.test.ts index e86d6d89eee..23646da1ac3 100644 --- a/packages/wallet/test/services/AssetsTracker.test.ts +++ b/packages/wallet/test/services/AssetsTracker.test.ts @@ -3,15 +3,16 @@ import { AssetId, createTestScheduler, generateRandomHexString, logger } from '@ import { AssetService, AssetsTrackerProps, - BalanceTracker, TrackedAssetProvider, TransactionsTracker, + UtxoTotalBalance, createAssetService, createAssetsTracker } from '../../src/services'; -import { Observable, firstValueFrom, from, lastValueFrom, of, tap } from 'rxjs'; +import { Observable, concat, firstValueFrom, lastValueFrom, of, tap, timer } from 'rxjs'; import { RetryBackoffConfig } from 'backoff-rxjs'; +import { toEmpty } from '@cardano-sdk/util-rxjs'; const createTxWithValues = (values: Partial[]): Cardano.HydratedTx => ({ body: { outputs: values.map((value) => ({ value })) }, id: generateRandomHexString(64) } as Cardano.HydratedTx); @@ -55,7 +56,7 @@ const assetInfo = { describe('createAssetsTracker', () => { let assetService: AssetService; let assetProvider: TrackedAssetProvider; - let balanceTracker: BalanceTracker; + let balanceTracker: UtxoTotalBalance; let assetsCache$: Observable>; const retryBackoffConfig: RetryBackoffConfig = { initialInterval: 2 }; @@ -86,14 +87,8 @@ describe('createAssetsTracker', () => { } as unknown as TrackedAssetProvider; balanceTracker = { - rewardAccounts: { - deposit$: of(0n), - rewards$: of(0n) - }, utxo: { - available$: of({ coins: 0n }), - total$: of({ coins: 0n }), - unspendable$: of({ coins: 0n }) + total$: of({ coins: 0n }) } }; @@ -144,6 +139,52 @@ describe('createAssetsTracker', () => { }); }); + it('fetches asset info for assets in balance but not in transaction history', () => { + createTestScheduler().run(({ cold, expectObservable, flush }) => { + const transactionsTracker: Partial = { + history$: cold('a', { + a: [createTxWithValues([{ assets: new Map([[AssetId.TSLA, 1n]]) }])] + }) + }; + + balanceTracker = { + utxo: { + total$: cold('a', { + a: { + assets: new Map([ + [AssetId.TSLA, 1n], + [AssetId.PXL, 2n] + ]), + coins: 1n + } + }) + } + }; + + const target$ = createAssetsTracker( + { + assetProvider, + assetsCache$, + balanceTracker, + logger, + retryBackoffConfig, + transactionsTracker + } as unknown as AssetsTrackerProps, + { + assetService + } + ); + expectObservable(target$).toBe('a', { + a: new Map([ + [AssetId.TSLA, assetInfo.TSLA], + [AssetId.PXL, assetInfo.PXL] + ]) + }); + flush(); + expect(assetService).toHaveBeenCalledTimes(1); + }); + }); + it('re-fetches asset info when there is a cip68 reference nft in some tx history output', () => { createTestScheduler().run(({ cold, expectObservable, flush }) => { const transactionsTracker: Partial = { @@ -349,9 +390,10 @@ describe('createAssetsTracker', () => { } as unknown as TrackedAssetProvider; const transactionsTracker: Partial = { - history$: from([ - [createTxWithValues([{}])], - [ + history$: concat( + of([createTxWithValues([{}])]), + timer(1).pipe(toEmpty), + of([ createTxWithValues([ { assets: new Map([ @@ -360,8 +402,8 @@ describe('createAssetsTracker', () => { ]) } ]) - ] - ]) + ]) + ) }; const target$ = createAssetsTracker({