Skip to content

fixes: blockfrost chain history transactions limit per address & balance token info #1565

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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',
Expand All @@ -89,6 +95,10 @@ describe('blockfrostChainHistoryProvider', () => {
valid_contract: true,
withdrawal_count: 1
};
const mockedTx2Response = {
...mockedTx1Response,
hash: txId2
};
const mockedMetadataResponse = [
{
json_metadata: {
Expand Down Expand Up @@ -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
}
];
Expand Down Expand Up @@ -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'
Expand All @@ -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')]
]);
});

Expand All @@ -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 () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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],
Expand All @@ -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);
});
});
Expand Down
19 changes: 13 additions & 6 deletions packages/wallet/src/services/AssetsTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Logger } from 'ts-log';
import {
Observable,
buffer,
combineLatest,
concat,
connect,
debounceTime,
Expand Down Expand Up @@ -151,14 +152,19 @@ export const createAssetService =
).pipe(map((arr) => arr.flat())); // Concatenate the chunk results

export type AssetService = ReturnType<typeof createAssetService>;
export type UtxoTotalBalance = {
utxo: {
total$: BalanceTracker['utxo']['total$'];
};
};

export interface AssetsTrackerProps {
transactionsTracker: TransactionsTracker;
assetProvider: TrackedAssetProvider;
retryBackoffConfig: RetryBackoffConfig;
logger: Logger;
assetsCache$: Observable<Assets>;
balanceTracker: BalanceTracker;
balanceTracker: UtxoTotalBalance;
maxAssetInfoCacheAge?: Milliseconds;
}

Expand Down Expand Up @@ -198,13 +204,14 @@ export const createAssetsTracker = (
const allAssetIds = new Set<Cardano.AssetId>();
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$)),
Expand Down
72 changes: 57 additions & 15 deletions packages/wallet/test/services/AssetsTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.Value>[]): Cardano.HydratedTx =>
({ body: { outputs: values.map((value) => ({ value })) }, id: generateRandomHexString(64) } as Cardano.HydratedTx);
Expand Down Expand Up @@ -55,7 +56,7 @@ const assetInfo = {
describe('createAssetsTracker', () => {
let assetService: AssetService;
let assetProvider: TrackedAssetProvider;
let balanceTracker: BalanceTracker;
let balanceTracker: UtxoTotalBalance;
let assetsCache$: Observable<Map<Cardano.AssetId, Asset.AssetInfo>>;
const retryBackoffConfig: RetryBackoffConfig = { initialInterval: 2 };

Expand Down Expand Up @@ -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 })
}
};

Expand Down Expand Up @@ -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<TransactionsTracker> = {
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<TransactionsTracker> = {
Expand Down Expand Up @@ -349,9 +390,10 @@ describe('createAssetsTracker', () => {
} as unknown as TrackedAssetProvider;

const transactionsTracker: Partial<TransactionsTracker> = {
history$: from([
[createTxWithValues([{}])],
[
history$: concat(
of([createTxWithValues([{}])]),
timer(1).pipe(toEmpty),
of([
createTxWithValues([
{
assets: new Map([
Expand All @@ -360,8 +402,8 @@ describe('createAssetsTracker', () => {
])
}
])
]
])
])
)
};

const target$ = createAssetsTracker({
Expand Down
Loading