Skip to content

Commit eb6aff5

Browse files
committed
feat(cardano-services-client): implement credential-based UTXO queries
Implement optimized UTXO fetching using payment credentials and reward accounts instead of individual addresses, reducing API calls to Blockfrost. Changes: - Add fetchUtxosByPaymentCredential() to query by payment credential - Add fetchUtxosByRewardAccount() with payment credential filtering - Add mergeAndDeduplicateUtxos() for result consolidation - Update utxoByAddresses() to support queryUtxosByCredentials flag - Add comprehensive integration tests with feature flag ON - Reuse credential extraction/minimization from transaction history The implementation maintains backward compatibility with feature flag defaulting to false. When enabled, reduces API calls significantly for wallets with shared stake keys (e.g., 20 addresses → 1 query).
1 parent c27028b commit eb6aff5

File tree

2 files changed

+457
-9
lines changed

2 files changed

+457
-9
lines changed

packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts

Lines changed: 194 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { BlockfrostClient, BlockfrostProvider, BlockfrostToCore, fetchSequentially } from '../blockfrost';
22
import { Cardano, Serialization, UtxoByAddressesArgs, UtxoProvider } from '@cardano-sdk/core';
33
import { Logger } from 'ts-log';
4+
import { createPaymentCredentialFilter, extractCredentials, minimizeCredentialSet } from '../credentialUtils';
5+
import uniqBy from 'lodash/uniqBy.js';
46
import type { Cache } from '@cardano-sdk/util';
57
import type { Responses } from '@blockfrost/blockfrost-js';
68

@@ -55,6 +57,182 @@ export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoPr
5557
return Promise.all(utxoPromises);
5658
}
5759

60+
private async processUtxoContents(utxoContents: Responses['address_utxo_content']): Promise<Cardano.Utxo[]> {
61+
const utxoPromises = utxoContents.map((utxo) =>
62+
this.fetchDetailsFromCBOR(utxo.tx_hash).then((tx) => {
63+
const txOut = tx ? tx.body.outputs.find((output) => output.address === utxo.address) : undefined;
64+
return BlockfrostToCore.addressUtxoContent(utxo.address, utxo, txOut);
65+
})
66+
);
67+
return Promise.all(utxoPromises);
68+
}
69+
70+
protected async fetchUtxosByPaymentCredential(credential: Cardano.PaymentCredential): Promise<Cardano.Utxo[]> {
71+
const utxoContents = await fetchSequentially<
72+
Responses['address_utxo_content'][0],
73+
Responses['address_utxo_content'][0]
74+
>({
75+
haveEnoughItems: () => false, // Fetch all pages
76+
request: async (paginationQueryString) => {
77+
const queryString = `addresses/${credential}/utxos?${paginationQueryString}`;
78+
return this.request<Responses['address_utxo_content']>(queryString);
79+
}
80+
});
81+
82+
return this.processUtxoContents(utxoContents);
83+
}
84+
85+
protected async fetchUtxosByRewardAccount(
86+
rewardAccount: Cardano.RewardAccount,
87+
paymentCredentialFilter: (address: Cardano.PaymentAddress) => boolean
88+
): Promise<Cardano.Utxo[]> {
89+
const utxoContents = await fetchSequentially<
90+
Responses['address_utxo_content'][0],
91+
Responses['address_utxo_content'][0]
92+
>({
93+
haveEnoughItems: () => false, // Fetch all pages
94+
request: async (paginationQueryString) => {
95+
const queryString = `accounts/${rewardAccount}/utxos?${paginationQueryString}`;
96+
return this.request<Responses['address_utxo_content']>(queryString);
97+
}
98+
});
99+
100+
// Filter UTXOs by payment credential before processing
101+
const filteredUtxos = utxoContents.filter((utxo) => paymentCredentialFilter(Cardano.PaymentAddress(utxo.address)));
102+
103+
// Log debug message about filtering
104+
if (filteredUtxos.length < utxoContents.length) {
105+
this.logger.debug(
106+
`Filtered ${utxoContents.length - filteredUtxos.length} UTXO(s) from reward account query, kept ${
107+
filteredUtxos.length
108+
}`
109+
);
110+
}
111+
112+
return this.processUtxoContents(filteredUtxos);
113+
}
114+
115+
protected mergeAndDeduplicateUtxos(
116+
paymentUtxos: Cardano.Utxo[],
117+
rewardAccountUtxos: Cardano.Utxo[],
118+
skippedAddressUtxos: Cardano.Utxo[]
119+
): Cardano.Utxo[] {
120+
const allUtxos = [...paymentUtxos, ...rewardAccountUtxos, ...skippedAddressUtxos];
121+
122+
// Deduplicate by txId + index combination
123+
const deduplicated = uniqBy(allUtxos, (utxo: Cardano.Utxo) => `${utxo[0].txId}#${utxo[0].index}`);
124+
125+
// Sort by txId and index for deterministic ordering
126+
return deduplicated.sort((a, b) => {
127+
const txIdCompare = a[0].txId.localeCompare(b[0].txId);
128+
if (txIdCompare !== 0) return txIdCompare;
129+
return a[0].index - b[0].index;
130+
});
131+
}
132+
133+
private logSkippedAddresses(skippedAddresses: {
134+
byron: Cardano.PaymentAddress[];
135+
pointer: Cardano.PaymentAddress[];
136+
}): void {
137+
if (skippedAddresses.byron.length > 0) {
138+
this.logger.info(
139+
`Found ${skippedAddresses.byron.length} Byron address(es), falling back to per-address fetching`
140+
);
141+
}
142+
if (skippedAddresses.pointer.length > 0) {
143+
this.logger.info(
144+
`Found ${skippedAddresses.pointer.length} Pointer address(es), falling back to per-address fetching`
145+
);
146+
}
147+
}
148+
149+
private logMinimizationStats(
150+
totalAddresses: number,
151+
minimized: { paymentCredentials: Map<unknown, unknown>; rewardAccounts: Map<unknown, unknown> },
152+
skippedAddresses: { byron: Cardano.PaymentAddress[]; pointer: Cardano.PaymentAddress[] }
153+
): void {
154+
const paymentCredCount = minimized.paymentCredentials.size;
155+
const rewardAccountCount = minimized.rewardAccounts.size;
156+
const skippedCount = skippedAddresses.byron.length + skippedAddresses.pointer.length;
157+
const totalQueries = paymentCredCount + rewardAccountCount + skippedCount;
158+
159+
this.logger.debug(
160+
`Minimized ${totalAddresses} address(es) to ${totalQueries} query/queries: ` +
161+
`${paymentCredCount} payment credential(s), ${rewardAccountCount} reward account(s), ${skippedCount} skipped address(es)`
162+
);
163+
}
164+
165+
private async fetchAllByPaymentCredentials(
166+
credentials: Map<Cardano.PaymentCredential, Cardano.PaymentAddress[]>
167+
): Promise<Cardano.Utxo[]> {
168+
const results = await Promise.all(
169+
[...credentials.keys()].map((credential) => this.fetchUtxosByPaymentCredential(credential))
170+
);
171+
return results.flat();
172+
}
173+
174+
private async fetchAllByRewardAccounts(
175+
rewardAccounts: Map<Cardano.RewardAccount, Cardano.PaymentAddress[]>,
176+
paymentCredentialFilter: (address: Cardano.PaymentAddress) => boolean
177+
): Promise<Cardano.Utxo[]> {
178+
const results = await Promise.all(
179+
[...rewardAccounts.keys()].map((rewardAccount) =>
180+
this.fetchUtxosByRewardAccount(rewardAccount, paymentCredentialFilter)
181+
)
182+
);
183+
return results.flat();
184+
}
185+
186+
private async fetchUtxosForAddresses(addresses: Cardano.PaymentAddress[]): Promise<Cardano.Utxo[]> {
187+
const results = await Promise.all(
188+
addresses.map((address) =>
189+
fetchSequentially<Cardano.Utxo, Cardano.Utxo>({
190+
request: async (paginationQueryString) => await this.fetchUtxos(address, paginationQueryString)
191+
})
192+
)
193+
);
194+
return results.flat();
195+
}
196+
197+
private async fetchSkippedAddresses(skippedAddresses: {
198+
byron: Cardano.PaymentAddress[];
199+
pointer: Cardano.PaymentAddress[];
200+
}): Promise<Cardano.Utxo[]> {
201+
const allSkippedAddresses = [...skippedAddresses.byron, ...skippedAddresses.pointer];
202+
return this.fetchUtxosForAddresses(allSkippedAddresses);
203+
}
204+
205+
private async fetchUtxosByCredentials(addresses: Cardano.PaymentAddress[]): Promise<Cardano.Utxo[]> {
206+
const addressGroups = extractCredentials(addresses);
207+
208+
this.logSkippedAddresses(addressGroups.skippedAddresses);
209+
210+
const minimized = minimizeCredentialSet({
211+
paymentCredentials: addressGroups.paymentCredentials,
212+
rewardAccounts: addressGroups.rewardAccounts
213+
});
214+
215+
this.logMinimizationStats(addresses.length, minimized, addressGroups.skippedAddresses);
216+
217+
const paymentCredentialFilter = createPaymentCredentialFilter(addresses);
218+
219+
this.logger.debug(
220+
`Fetching UTXOs for ${minimized.paymentCredentials.size} payment credential(s) and ${minimized.rewardAccounts.size} reward account(s)`
221+
);
222+
223+
const [paymentUtxos, rewardAccountUtxos, skippedAddressUtxos] = await Promise.all([
224+
this.fetchAllByPaymentCredentials(minimized.paymentCredentials),
225+
this.fetchAllByRewardAccounts(minimized.rewardAccounts, paymentCredentialFilter),
226+
this.fetchSkippedAddresses(addressGroups.skippedAddresses)
227+
]);
228+
229+
const result = this.mergeAndDeduplicateUtxos(paymentUtxos, rewardAccountUtxos, skippedAddressUtxos);
230+
231+
this.logger.debug(`Merged results: ${result.length} UTXO(s)`);
232+
233+
return result;
234+
}
235+
58236
async fetchCBOR(hash: string): Promise<string> {
59237
return this.request<Responses['tx_content_cbor']>(`txs/${hash}/cbor`)
60238
.then((response) => {
@@ -88,16 +266,24 @@ export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoPr
88266
return result;
89267
}
90268

269+
/**
270+
* Retrieves UTXOs for the given addresses.
271+
*
272+
* Important assumption: All addresses provided must be addresses where the caller
273+
* controls the payment credential. When queryUtxosByCredentials is enabled, this
274+
* provider queries by reward accounts (stake addresses) and filters results to only
275+
* include UTXOs with payment credentials extracted from the input addresses. UTXOs
276+
* with payment credentials not present in the input will be excluded.
277+
*/
91278
public async utxoByAddresses({ addresses }: UtxoByAddressesArgs): Promise<Cardano.Utxo[]> {
92279
try {
93-
const utxoResults = await Promise.all(
94-
addresses.map(async (address) =>
95-
fetchSequentially<Cardano.Utxo, Cardano.Utxo>({
96-
request: async (paginationQueryString) => await this.fetchUtxos(address, paginationQueryString)
97-
})
98-
)
99-
);
100-
return utxoResults.flat(1);
280+
// If feature flag is disabled, use original implementation
281+
if (!this.queryUtxosByCredentials) {
282+
return this.fetchUtxosForAddresses(addresses);
283+
}
284+
285+
// Use credential-based fetching
286+
return await this.fetchUtxosByCredentials(addresses);
101287
} catch (error) {
102288
throw this.toProviderError(error);
103289
}

0 commit comments

Comments
 (0)