From 6bc3b404e801972417dfff7f75da21dd012515d1 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Tue, 4 Nov 2025 16:59:23 +0100 Subject: [PATCH 01/10] feat(core): add PaymentCredential type for CIP-5 bech32 encoding Introduces PaymentCredential opaque type to represent payment credentials as bech32-encoded strings following CIP-5 specification. This type supports both key hash credentials (addr_vkh prefix) and script hash credentials (script prefix). This is foundational work for optimizing BlockfrostChainHistoryProvider's transactionsByAddresses() method. The optimization will group addresses by their underlying credentials and query Blockfrost's credential-based endpoints (/addresses/:addr_vkh/transactions and /accounts/:stake_cred/transactions) instead of querying each address individually. This can reduce API calls by up to 95% for wallets with many addresses sharing the same stake key. The PaymentCredential type provides: - Validation for bech32-encoded payment credentials - Conversion from Credential objects to bech32 format via fromCredential() - Type safety through OpaqueString pattern --- .../src/Cardano/Address/PaymentCredential.ts | 30 +++++++++ packages/core/src/Cardano/Address/index.ts | 1 + .../Cardano/Address/PaymentCredential.test.ts | 66 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 packages/core/src/Cardano/Address/PaymentCredential.ts create mode 100644 packages/core/test/Cardano/Address/PaymentCredential.test.ts diff --git a/packages/core/src/Cardano/Address/PaymentCredential.ts b/packages/core/src/Cardano/Address/PaymentCredential.ts new file mode 100644 index 00000000000..f36f277747d --- /dev/null +++ b/packages/core/src/Cardano/Address/PaymentCredential.ts @@ -0,0 +1,30 @@ +import * as BaseEncoding from '@scure/base'; +import { Credential, CredentialType } from './Address'; +import { OpaqueString, typedBech32 } from '@cardano-sdk/util'; + +/** Payment credential as bech32 (addr_vkh for key hash, script for script hash - per CIP-5) */ +export type PaymentCredential = OpaqueString<'PaymentCredential'>; + +/** + * @param {string} value payment credential as bech32 string (addr_vkh for key hash, script for script hash) + * @throws InvalidStringError + */ +export const PaymentCredential = (value: string): PaymentCredential => { + try { + return typedBech32(value, ['addr_vkh'], 45); + } catch { + return typedBech32(value, ['script'], 45); + } +}; + +/** + * Converts address Credential to bech32 PaymentCredential. + * + * @param credential The credential to convert. + * @returns The payment credential as bech32 string. + */ +PaymentCredential.fromCredential = (credential: Credential): PaymentCredential => { + const words = BaseEncoding.bech32.toWords(Buffer.from(credential.hash, 'hex')); + const prefix = credential.type === CredentialType.KeyHash ? 'addr_vkh' : 'script'; + return BaseEncoding.bech32.encode(prefix, words, 1023) as PaymentCredential; +}; diff --git a/packages/core/src/Cardano/Address/index.ts b/packages/core/src/Cardano/Address/index.ts index 4c6958ee690..3b09b537a33 100644 --- a/packages/core/src/Cardano/Address/index.ts +++ b/packages/core/src/Cardano/Address/index.ts @@ -4,6 +4,7 @@ export * from './ByronAddress'; export * from './DRepID'; export * from './EnterpriseAddress'; export * from './PaymentAddress'; +export * from './PaymentCredential'; export * from './PointerAddress'; export * from './RewardAccount'; export * from './RewardAddress'; diff --git a/packages/core/test/Cardano/Address/PaymentCredential.test.ts b/packages/core/test/Cardano/Address/PaymentCredential.test.ts new file mode 100644 index 00000000000..55beddba305 --- /dev/null +++ b/packages/core/test/Cardano/Address/PaymentCredential.test.ts @@ -0,0 +1,66 @@ +import * as cip19TestVectors from '../../../../util-dev/src/Cip19TestVectors'; +import { Cardano } from '../../../src'; +import { typedBech32 } from '@cardano-sdk/util'; + +jest.mock('@cardano-sdk/util', () => { + const actual = jest.requireActual('@cardano-sdk/util'); + return { + ...actual, + typedBech32: jest.fn().mockImplementation((...args) => actual.typedBech32(...args)) + }; +}); + +describe('Cardano/Address/PaymentCredential', () => { + const validAddrVkh = 'addr_vkh1an33fpxnkj09r8upnlw55a59kcysk8u3jcm00rfu44dk75ut34v'; + const validScript = 'script1cda3khwqv60360rp5m7akt50m6ttapacs8rqhn5w342z7r35m37'; + + describe('PaymentCredential()', () => { + it('accepts a valid addr_vkh bech32 for key hash credential', () => { + expect(() => Cardano.PaymentCredential(validAddrVkh)).not.toThrow(); + }); + + it('accepts a valid script bech32 for script hash credential', () => { + expect(() => Cardano.PaymentCredential(validScript)).not.toThrow(); + }); + + it('is implemented using util.typedBech32 for addr_vkh', () => { + Cardano.PaymentCredential(validAddrVkh); + expect(typedBech32).toHaveBeenCalledWith(validAddrVkh, ['addr_vkh'], 45); + }); + + it('throws on invalid bech32 prefix (stake address)', () => { + expect(() => Cardano.PaymentCredential('stake1u89sasnfyjtmgk8ydqfv3fdl52f36x3djedfnzfc9rkgzrcss5vgr')).toThrow(); + }); + }); + + describe('fromCredential', () => { + it('converts key hash credential to addr_vkh bech32', () => { + const paymentCredential = Cardano.PaymentCredential.fromCredential(cip19TestVectors.KEY_PAYMENT_CREDENTIAL); + expect(paymentCredential).toMatch(/^addr_vkh1/); + // Verify it can be parsed back + expect(() => Cardano.PaymentCredential(paymentCredential)).not.toThrow(); + }); + + it('converts script hash credential to script bech32', () => { + const paymentCredential = Cardano.PaymentCredential.fromCredential(cip19TestVectors.SCRIPT_CREDENTIAL); + expect(paymentCredential).toMatch(/^script1/); + // Verify it can be parsed back + expect(() => Cardano.PaymentCredential(paymentCredential)).not.toThrow(); + }); + + it('produces valid bech32 with correct length', () => { + const keyHashCredential = Cardano.PaymentCredential.fromCredential(cip19TestVectors.KEY_PAYMENT_CREDENTIAL); + const scriptHashCredential = Cardano.PaymentCredential.fromCredential(cip19TestVectors.SCRIPT_CREDENTIAL); + + // Both should be 56+ characters (prefix + separator + encoded data + checksum) + expect(keyHashCredential.length).toBeGreaterThan(56); + expect(scriptHashCredential.length).toBeGreaterThan(56); + }); + + it('is consistent - same credential produces same bech32', () => { + const cred1 = Cardano.PaymentCredential.fromCredential(cip19TestVectors.KEY_PAYMENT_CREDENTIAL); + const cred2 = Cardano.PaymentCredential.fromCredential(cip19TestVectors.KEY_PAYMENT_CREDENTIAL); + expect(cred1).toBe(cred2); + }); + }); +}); From a222fac8aad46fc77b300cf17bc72b9aa7bcf3f5 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Tue, 4 Nov 2025 18:18:22 +0100 Subject: [PATCH 02/10] feat(cardano-services-client): add credential extraction and minimization utilities Implements reusable utilities at package root for querying by credentials across providers: extractCredentials(): Extracts payment credentials and reward accounts from addresses - Converts payment credentials to CIP-5 bech32 (addr_vkh/script) - network-agnostic - Converts stake keys to RewardAccount bech32 (stake/stake_test) - includes network ID - Handles BaseAddress (payment + stake), EnterpriseAddress (payment only) - Collects Byron and Pointer addresses for fallback minimizeCredentialSet(): Finds minimal set using greedy algorithm - Reduces API calls from N addresses to M credentials/accounts - Prefers payment credentials over reward accounts when coverage is equal Note: RewardAccount is used for stake keys as it's a stake address (credential + network ID), which is what we need for API queries - a superset of a pure credential. Enables optimization in BlockfrostChainHistoryProvider and BlockfrostUtxoProvider. Ref: LW-13806 --- .../src/credentialUtils.ts | 248 ++++++++++ .../test/credentialUtils.test.ts | 450 ++++++++++++++++++ 2 files changed, 698 insertions(+) create mode 100644 packages/cardano-services-client/src/credentialUtils.ts create mode 100644 packages/cardano-services-client/test/credentialUtils.test.ts diff --git a/packages/cardano-services-client/src/credentialUtils.ts b/packages/cardano-services-client/src/credentialUtils.ts new file mode 100644 index 00000000000..b373c803a04 --- /dev/null +++ b/packages/cardano-services-client/src/credentialUtils.ts @@ -0,0 +1,248 @@ +import { Cardano } from '@cardano-sdk/core'; + +/** + * Maps of payment credentials and reward accounts (stake addresses) grouped by their addresses. + * + * Note: We use RewardAccount for stake keys because it's a stake address (credential + network ID), + * which is what we need for API queries. It's a superset of what a pure credential would be. + */ +export interface CredentialMaps { + /** Payment credentials (bech32: addr_vkh for KeyHash, script for ScriptHash) - network-agnostic */ + paymentCredentials: Map; + /** Reward accounts / stake addresses (bech32: stake/stake_test) - includes network ID */ + rewardAccounts: Map; +} + +export interface AddressGroups extends CredentialMaps { + skippedAddresses: { + byron: Cardano.PaymentAddress[]; + pointer: Cardano.PaymentAddress[]; + }; +} + +/** Parses an address, handling both bech32 and base58 formats */ +const parseAddress = (address: Cardano.PaymentAddress): Cardano.Address | null => { + try { + return Cardano.Address.fromBech32(address); + } catch { + try { + return Cardano.Address.fromBase58(address); + } catch { + return null; + } + } +}; + +/** Adds a credential to the map, creating the array if needed */ +const addToCredentialMap = ( + map: Map, + credential: T, + address: Cardano.PaymentAddress +) => { + if (!map.has(credential)) { + map.set(credential, []); + } + map.get(credential)!.push(address); +}; + +/** Processes a BaseAddress, extracting payment credential and reward account (stake address) */ +const processBaseAddress = ( + baseAddress: Cardano.BaseAddress, + parsedAddress: Cardano.Address, + address: Cardano.PaymentAddress, + paymentCredentials: Map, + rewardAccounts: Map +) => { + const paymentCredential = baseAddress.getPaymentCredential(); + const paymentBech32 = Cardano.PaymentCredential.fromCredential(paymentCredential); + addToCredentialMap(paymentCredentials, paymentBech32, address); + + const stakeCredential = baseAddress.getStakeCredential(); + const networkId = parsedAddress.getNetworkId(); + const rewardAccount = Cardano.RewardAccount.fromCredential(stakeCredential, networkId); + addToCredentialMap(rewardAccounts, rewardAccount, address); +}; + +/** Processes an EnterpriseAddress, extracting payment credential only */ +const processEnterpriseAddress = ( + enterpriseAddress: Cardano.EnterpriseAddress, + address: Cardano.PaymentAddress, + paymentCredentials: Map +) => { + const paymentCredential = enterpriseAddress.getPaymentCredential(); + const paymentBech32 = Cardano.PaymentCredential.fromCredential(paymentCredential); + addToCredentialMap(paymentCredentials, paymentBech32, address); +}; + +/** + * Extracts and groups payment credentials and reward accounts (stake addresses) from addresses. + * Pure function - no logging or side effects. + * + * @param addresses Array of payment addresses to extract credentials/addresses from + * @returns Grouped payment credentials, reward accounts (stake addresses), and skipped addresses + */ +export const extractCredentials = (addresses: Cardano.PaymentAddress[]): AddressGroups => { + const paymentCredentials = new Map(); + const rewardAccounts = new Map(); + const byron: Cardano.PaymentAddress[] = []; + const pointer: Cardano.PaymentAddress[] = []; + const seenAddresses = new Set(); + + for (const address of addresses) { + if (seenAddresses.has(address)) continue; + seenAddresses.add(address); + + const parsedAddress = parseAddress(address); + if (!parsedAddress) continue; + + if (parsedAddress.asByron()) { + byron.push(address); + continue; + } + + if (parsedAddress.asPointer()) { + pointer.push(address); + continue; + } + + const baseAddress = parsedAddress.asBase(); + if (baseAddress) { + processBaseAddress(baseAddress, parsedAddress, address, paymentCredentials, rewardAccounts); + continue; + } + + const enterpriseAddress = parsedAddress.asEnterprise(); + if (enterpriseAddress) { + processEnterpriseAddress(enterpriseAddress, address, paymentCredentials); + } + } + + return { + paymentCredentials, + rewardAccounts, + skippedAddresses: { + byron, + pointer + } + }; +}; + +interface CredentialInfo { + addresses: Cardano.PaymentAddress[]; + credential: Cardano.PaymentCredential | Cardano.RewardAccount; + type: 'payment' | 'stake'; + uncoveredCount: number; +} + +/** Builds a list of all payment credentials and reward accounts with their coverage info */ +const buildCredentialList = ( + paymentCredentials: Map, + rewardAccounts: Map +): CredentialInfo[] => { + const allCredentials: CredentialInfo[] = []; + + for (const [credential, addresses] of paymentCredentials.entries()) { + if (addresses.length > 0) { + allCredentials.push({ + addresses, + credential, + type: 'payment', + uncoveredCount: addresses.length + }); + } + } + + for (const [rewardAccount, addresses] of rewardAccounts.entries()) { + if (addresses.length > 0) { + allCredentials.push({ + addresses, + credential: rewardAccount, + type: 'stake', + uncoveredCount: addresses.length + }); + } + } + + return allCredentials; +}; + +/** Updates uncovered counts for all credentials */ +const updateUncoveredCounts = (credentialInfos: CredentialInfo[], coveredAddresses: Set) => { + for (const info of credentialInfos) { + info.uncoveredCount = info.addresses.filter((addr) => !coveredAddresses.has(addr)).length; + } +}; + +/** Finds the best credential to use (most coverage, prefer payment over stake on tie) */ +const findBestCredential = (credentialInfos: CredentialInfo[]): CredentialInfo | null => { + let maxInfo: CredentialInfo | null = null; + + for (const info of credentialInfos) { + if (info.uncoveredCount === 0) continue; + + if (maxInfo === null) { + maxInfo = info; + continue; + } + + const hasMoreCoverage = info.uncoveredCount > maxInfo.uncoveredCount; + const hasSameCoverageButPreferPayment = + info.uncoveredCount === maxInfo.uncoveredCount && info.type === 'payment' && maxInfo.type === 'stake'; + + if (hasMoreCoverage || hasSameCoverageButPreferPayment) { + maxInfo = info; + } + } + + return maxInfo; +}; + +/** Adds selected credential/address to result maps */ +const addToResultMaps = ( + selectedCredential: CredentialInfo, + minimizedPayment: Map, + minimizedRewardAccounts: Map +) => { + if (selectedCredential.type === 'payment') { + minimizedPayment.set(selectedCredential.credential as Cardano.PaymentCredential, selectedCredential.addresses); + } else { + minimizedRewardAccounts.set(selectedCredential.credential as Cardano.RewardAccount, selectedCredential.addresses); + } +}; + +/** + * Finds the minimum set of payment credentials and reward accounts that cover all addresses using a greedy algorithm. + * Prefers payment credentials over reward accounts (stake addresses) when coverage is equal. + * Pure function - no logging or side effects. + * + * @param credentials Payment credentials and reward accounts maps + * @returns Minimized credential maps + */ +export const minimizeCredentialSet = (credentials: CredentialMaps): CredentialMaps => { + const { paymentCredentials, rewardAccounts } = credentials; + const coveredAddresses = new Set(); + const minimizedPayment = new Map(); + const minimizedRewardAccounts = new Map(); + + const allCredentials = buildCredentialList(paymentCredentials, rewardAccounts); + + // Greedy algorithm: repeatedly pick the credential/address that covers the most uncovered addresses + while (allCredentials.some((info) => info.uncoveredCount > 0)) { + updateUncoveredCounts(allCredentials, coveredAddresses); + + const selectedCredential = findBestCredential(allCredentials); + if (!selectedCredential || selectedCredential.uncoveredCount === 0) break; + + addToResultMaps(selectedCredential, minimizedPayment, minimizedRewardAccounts); + + // Mark all addresses covered by this credential + for (const addr of selectedCredential.addresses) { + coveredAddresses.add(addr); + } + } + + return { + paymentCredentials: minimizedPayment, + rewardAccounts: minimizedRewardAccounts + }; +}; diff --git a/packages/cardano-services-client/test/credentialUtils.test.ts b/packages/cardano-services-client/test/credentialUtils.test.ts new file mode 100644 index 00000000000..10ea8b57944 --- /dev/null +++ b/packages/cardano-services-client/test/credentialUtils.test.ts @@ -0,0 +1,450 @@ +import { Cardano } from '@cardano-sdk/core'; +import { cip19TestVectors } from '@cardano-sdk/util-dev'; +import { extractCredentials, minimizeCredentialSet } from '../src/credentialUtils'; + +describe('credentialUtils', () => { + describe('extractCredentials', () => { + describe('BaseAddress with different credential type combinations', () => { + it('extracts payment KeyHash + stake KeyHash credentials', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + // Should extract one payment credential and one reward account (stake address) + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(1); + + // Payment credential should be addr_vkh (key hash) + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^addr_vkh1/); + + // RewardAccount (stake address) should have stake/stake_test prefix + const rewardAccount = [...result.rewardAccounts.keys()][0]; + expect(rewardAccount).toMatch(/^stake1/); + + // Both should reference the same address + expect(result.paymentCredentials.get(paymentCred)).toEqual([cip19TestVectors.basePaymentKeyStakeKey]); + expect(result.rewardAccounts.get(rewardAccount)).toEqual([cip19TestVectors.basePaymentKeyStakeKey]); + + // No skipped addresses + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('extracts payment ScriptHash + stake KeyHash credentials', () => { + const addresses = [cip19TestVectors.basePaymentScriptStakeKey]; + const result = extractCredentials(addresses); + + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(1); + + // Payment credential should be script (script hash) + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^script1/); + + // Stake credential should be RewardAccount + const stakeCred = [...result.rewardAccounts.keys()][0]; + expect(stakeCred).toMatch(/^stake1/); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('extracts payment KeyHash + stake ScriptHash credentials', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeScript]; + const result = extractCredentials(addresses); + + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(1); + + // Payment credential should be addr_vkh (key hash) + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^addr_vkh1/); + + // Stake credential should be RewardAccount with stake prefix + const stakeCred = [...result.rewardAccounts.keys()][0]; + expect(stakeCred).toMatch(/^stake1/); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('extracts payment ScriptHash + stake ScriptHash credentials', () => { + const addresses = [cip19TestVectors.basePaymentScriptStakeScript]; + const result = extractCredentials(addresses); + + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(1); + + // Payment credential should be script + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^script1/); + + // Stake credential should be RewardAccount + const stakeCred = [...result.rewardAccounts.keys()][0]; + expect(stakeCred).toMatch(/^stake1/); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + }); + + describe('EnterpriseAddress with different credential types', () => { + it('extracts payment KeyHash only (no stake credential)', () => { + const addresses = [cip19TestVectors.enterpriseKey]; + const result = extractCredentials(addresses); + + // Should extract only payment credential + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(0); + + // Payment credential should be addr_vkh (key hash) + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^addr_vkh1/); + + expect(result.paymentCredentials.get(paymentCred)).toEqual([cip19TestVectors.enterpriseKey]); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('extracts payment ScriptHash only (no stake credential)', () => { + const addresses = [cip19TestVectors.enterpriseScript]; + const result = extractCredentials(addresses); + + // Should extract only payment credential + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(0); + + // Payment credential should be script + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^script1/); + + expect(result.paymentCredentials.get(paymentCred)).toEqual([cip19TestVectors.enterpriseScript]); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + }); + + describe('Special address types', () => { + it('collects Byron addresses in skippedAddresses.byron', () => { + const addresses = [cip19TestVectors.byronMainnetYoroi, cip19TestVectors.byronTestnetDaedalus]; + const result = extractCredentials(addresses); + + // No credentials extracted + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(0); + + // Byron addresses should be in skipped list + expect(result.skippedAddresses.byron).toEqual(addresses); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('collects Pointer addresses in skippedAddresses.pointer', () => { + const addresses = [cip19TestVectors.pointerKey, cip19TestVectors.pointerScript]; + const result = extractCredentials(addresses); + + // No credentials extracted + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(0); + + // Pointer addresses should be in skipped list + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual(addresses); + }); + }); + + describe('Grouping behavior', () => { + it('groups multiple addresses with same payment credential', () => { + // Create two different BaseAddresses that happen to share the same payment credential + // but have different stake credentials (this is a realistic scenario) + const addr1 = cip19TestVectors.basePaymentKeyStakeKey; + const addr2 = cip19TestVectors.basePaymentKeyStakeScript; + + const addresses = [addr1, addr2]; + const result = extractCredentials(addresses); + + // Should have one payment credential (shared) + expect(result.paymentCredentials.size).toBe(1); + + // Should have two stake credentials (different) + expect(result.rewardAccounts.size).toBe(2); + + // Payment credential should reference both addresses + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(result.paymentCredentials.get(paymentCred)).toHaveLength(2); + expect(result.paymentCredentials.get(paymentCred)).toContain(addr1); + expect(result.paymentCredentials.get(paymentCred)).toContain(addr2); + }); + + it('groups multiple addresses with same stake credential', () => { + // basePaymentKeyStakeKey and basePaymentScriptStakeKey share the same stake credential + const addr1 = cip19TestVectors.basePaymentKeyStakeKey; + const addr2 = cip19TestVectors.basePaymentScriptStakeKey; + + const addresses = [addr1, addr2]; + const result = extractCredentials(addresses); + + // Should have two payment credentials (different) + expect(result.paymentCredentials.size).toBe(2); + + // Should have one stake credential (shared) + expect(result.rewardAccounts.size).toBe(1); + + // Stake credential should reference both addresses + const stakeCred = [...result.rewardAccounts.keys()][0]; + expect(result.rewardAccounts.get(stakeCred)).toHaveLength(2); + expect(result.rewardAccounts.get(stakeCred)).toContain(addr1); + expect(result.rewardAccounts.get(stakeCred)).toContain(addr2); + }); + + it('handles mixed BaseAddress and EnterpriseAddress', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey, cip19TestVectors.enterpriseKey]; + const result = extractCredentials(addresses); + + // Both addresses share the same payment key hash, so only one payment credential + expect(result.paymentCredentials.size).toBe(1); + + // Should have one stake credential (only from BaseAddress) + expect(result.rewardAccounts.size).toBe(1); + + // Both addresses should be grouped under the same payment credential + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(result.paymentCredentials.get(paymentCred)).toHaveLength(2); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + }); + + describe('Bech32 encoding verification', () => { + it('encodes payment KeyHash credentials as addr_vkh', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^addr_vkh1/); + + // Verify it's valid PaymentCredential + expect(() => Cardano.PaymentCredential(paymentCred)).not.toThrow(); + }); + + it('encodes payment ScriptHash credentials as script', () => { + const addresses = [cip19TestVectors.basePaymentScriptStakeKey]; + const result = extractCredentials(addresses); + + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^script1/); + + // Verify it's valid PaymentCredential + expect(() => Cardano.PaymentCredential(paymentCred)).not.toThrow(); + }); + + it('encodes reward accounts (stake addresses) with stake prefix for KeyHash', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + const rewardAccount = [...result.rewardAccounts.keys()][0]; + expect(rewardAccount).toMatch(/^stake1/); + + // Verify it's valid RewardAccount + expect(() => Cardano.RewardAccount(rewardAccount)).not.toThrow(); + }); + + it('encodes reward accounts (stake addresses) with stake prefix for ScriptHash', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeScript]; + const result = extractCredentials(addresses); + + const rewardAccount = [...result.rewardAccounts.keys()][0]; + expect(rewardAccount).toMatch(/^stake1/); + + // Verify it's valid RewardAccount + expect(() => Cardano.RewardAccount(rewardAccount)).not.toThrow(); + }); + + it('encodes mainnet reward accounts with "stake" prefix', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + const rewardAccount = [...result.rewardAccounts.keys()][0]; + expect(rewardAccount).toMatch(/^stake1/); + expect(rewardAccount).not.toMatch(/^stake_test/); + + // Verify it's valid RewardAccount + expect(() => Cardano.RewardAccount(rewardAccount)).not.toThrow(); + }); + + it('encodes testnet reward accounts with "stake_test" prefix', () => { + const addresses = [cip19TestVectors.testnetBasePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + const rewardAccount = [...result.rewardAccounts.keys()][0]; + expect(rewardAccount).toMatch(/^stake_test1/); + + // Verify it's valid RewardAccount + expect(() => Cardano.RewardAccount(rewardAccount)).not.toThrow(); + }); + }); + + describe('Edge cases', () => { + it('handles empty address array', () => { + const result = extractCredentials([]); + + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(0); + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('handles duplicate addresses', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey, cip19TestVectors.basePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + // Should still have one of each credential + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(1); + + // Address should appear once in the list (deduplicated) + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(result.paymentCredentials.get(paymentCred)).toHaveLength(1); + }); + }); + }); + + describe('minimizeCredentialSet', () => { + it('Scenario 1: Single address (BaseAddress) - prefer payment credential', () => { + // Single BaseAddress has both payment and stake credentials + // Should prefer payment credential over stake when both cover the same address + const addr1 = cip19TestVectors.basePaymentKeyStakeKey; + const addressGroups = extractCredentials([addr1]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // Should keep payment credential, remove stake credential + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('Scenario 2: Single address (EnterpriseAddress) - only payment credential', () => { + // EnterpriseAddress has only payment credential + const addr1 = cip19TestVectors.enterpriseKey; + const addressGroups = extractCredentials([addr1]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // Should keep payment credential (only option) + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('Scenario 3: 2 addresses with same stake credential, different payment credentials', () => { + // Two addresses share stake credential but have different payment credentials + const addr1 = cip19TestVectors.basePaymentKeyStakeKey; + const addr2 = cip19TestVectors.basePaymentScriptStakeKey; + const addressGroups = extractCredentials([addr1, addr2]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // Stake credential covers both addresses, so it should be preferred + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(1); + }); + + it('Scenario 4: prefers payment credentials when coverage is equal (3 addresses, overlapping credentials)', () => { + // Three addresses: + // addr1 = payment KEY + stake KEY + // addr2 = payment SCRIPT + stake KEY (shares stake with addr1) + // addr3 = payment KEY + stake SCRIPT (shares payment with addr1) + const addr1 = cip19TestVectors.basePaymentKeyStakeKey; + const addr2 = cip19TestVectors.basePaymentScriptStakeKey; + const addr3 = cip19TestVectors.basePaymentKeyStakeScript; + + const addressGroups = extractCredentials([addr1, addr2, addr3]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // Payment KEY covers addr1 and addr3 (2 addresses) + // Payment SCRIPT covers addr2 (1 address) + // Stake KEY covers addr1 and addr2 (2 addresses) + // Stake SCRIPT covers addr3 (1 address) + // Both approaches need 2 credentials, but greedy algorithm prefers payment credentials + expect(result.paymentCredentials.size).toBe(2); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('Scenario 5: All EnterpriseAddresses with different payment credentials', () => { + // All addresses are EnterpriseAddress + // Note: enterpriseKey and testnetEnterpriseKey share the same key hash + // Payment credentials don't include network ID (per CIP-5), so they have the same credential + const addr1 = cip19TestVectors.enterpriseKey; + const addr2 = cip19TestVectors.enterpriseScript; + const addr3 = cip19TestVectors.testnetEnterpriseScript; + + const addressGroups = extractCredentials([addr1, addr2, addr3]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // Should keep all payment credentials (no stake credentials to optimize) + // addr1 uses key hash, addr2 uses script hash (mainnet), addr3 uses script hash (testnet) + // Since payment credentials don't include network, addr2 and addr3 share the same credential + expect(result.paymentCredentials.size).toBe(2); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('Scenario 6: Mixed BaseAddress and EnterpriseAddress', () => { + // Mix of BaseAddress and EnterpriseAddress with different payment credentials + const addr1 = cip19TestVectors.enterpriseKey; // Enterprise (payment1) + const addr2 = cip19TestVectors.basePaymentScriptStakeKey; // Base (paymentScript + stake1) + const addr3 = cip19TestVectors.basePaymentScriptStakeScript; // Base (paymentScript + stake2) + // addr2 and addr3 share the same payment credential (script hash) + + const addressGroups = extractCredentials([addr1, addr2, addr3]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // payment1 covers addr1 (1 address) + // paymentScript covers addr2 + addr3 (2 addresses) - prefer over stake1+stake2 + expect(result.paymentCredentials.size).toBe(2); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('handles empty credential maps', () => { + const result = minimizeCredentialSet({ + paymentCredentials: new Map(), + rewardAccounts: new Map() + }); + + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('handles credentials with no addresses', () => { + const result = minimizeCredentialSet({ + paymentCredentials: new Map(), + rewardAccounts: new Map([[Cardano.RewardAccount(cip19TestVectors.rewardKey), []]]) + }); + + // Credential with no addresses should be removed + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(0); + }); + }); +}); From d666485f402497a3234faf3fb7c81d91ae078df3 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Wed, 5 Nov 2025 07:33:40 +0100 Subject: [PATCH 03/10] feat(cardano-services-client): add queryTxsByCredentials feature flag Add constructor overloads to BlockfrostChainHistoryProvider supporting opt-in credential-based transaction querying. Feature flag defaults to false for backward compatibility. - Add BlockfrostChainHistoryProviderOptions interface - Implement dual constructor signatures (old + new with options) - Add constructor overload tests - Refactor existing tests into "default behavior" suite --- .../BlockfrostChainHistoryProvider.ts | 36 +- .../BlockfrostChainHistoryProvider.test.ts | 1129 +++++++++-------- 2 files changed, 645 insertions(+), 520 deletions(-) diff --git a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts index 4b07ae85b9e..5defd97039b 100644 --- a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts +++ b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts @@ -34,21 +34,45 @@ export const DB_MAX_SAFE_INTEGER = 2_147_483_647; type BlockfrostTx = Pick; const compareTx = (a: BlockfrostTx, b: BlockfrostTx) => a.block_height - b.block_height || a.tx_index - b.tx_index; -type BlockfrostChainHistoryProviderDependencies = { +interface BlockfrostChainHistoryProviderOptions { + queryTxsByCredentials?: boolean; +} + +interface BlockfrostChainHistoryProviderDependencies { cache: Cache; client: BlockfrostClient; networkInfoProvider: NetworkInfoProvider; logger: Logger; -}; +} export class BlockfrostChainHistoryProvider extends BlockfrostProvider implements ChainHistoryProvider { private readonly cache: Cache; private networkInfoProvider: NetworkInfoProvider; + // Feature flag to enable credential-based transaction fetching (used in transactionsByAddresses) + protected readonly queryTxsByCredentials: boolean; - constructor({ cache, client, networkInfoProvider, logger }: BlockfrostChainHistoryProviderDependencies) { - super(client, logger); - this.cache = cache; - this.networkInfoProvider = networkInfoProvider; + // Overload 1: Old signature (backward compatibility) + constructor(dependencies: BlockfrostChainHistoryProviderDependencies); + + // Overload 2: New signature with options + constructor(options: BlockfrostChainHistoryProviderOptions, dependencies: BlockfrostChainHistoryProviderDependencies); + + // Implementation signature + constructor( + optionsOrDependencies: BlockfrostChainHistoryProviderOptions | BlockfrostChainHistoryProviderDependencies, + maybeDependencies?: BlockfrostChainHistoryProviderDependencies + ) { + // Detect which overload was used + const isOldSignature = 'cache' in optionsOrDependencies; + const options = isOldSignature ? {} : (optionsOrDependencies as BlockfrostChainHistoryProviderOptions); + const dependencies = isOldSignature + ? (optionsOrDependencies as BlockfrostChainHistoryProviderDependencies) + : maybeDependencies!; + + super(dependencies.client, dependencies.logger); + this.cache = dependencies.cache; + this.networkInfoProvider = dependencies.networkInfoProvider; + this.queryTxsByCredentials = options.queryTxsByCredentials ?? false; } protected async fetchRedeemers({ diff --git a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts index 8760aeb2260..effd5398247 100644 --- a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts +++ b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts @@ -8,496 +8,290 @@ import type { Cache } from '@cardano-sdk/util'; jest.mock('@blockfrost/blockfrost-js'); describe('blockfrostChainHistoryProvider', () => { - let request: jest.Mock; - 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: txId1, - inputs: [ - { - address: - 'addr_test1qr05llxkwg5t6c4j3ck5mqfax9wmz35rpcgw3qthrn9z7xcxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknstdz3k2', - amount: [ - { - quantity: '9732978705764', - unit: 'lovelace' - } - ], - output_index: 1, - tx_hash: '6d50c330a6fba79de6949a8dcd5e4b7ffa3f9442f0c5bed7a78fa6d786c6c863' - } - ], - outputs: [ - { - address: - 'addr_test1qzx9hu8j4ah3auytk0mwcupd69hpc52t0cw39a65ndrah86djs784u92a3m5w475w3w35tyd6v3qumkze80j8a6h5tuqq5xe8y', - amount: [ - { - quantity: '1000000000', - unit: 'lovelace' - }, - { - quantity: '63', - unit: '06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364' - }, - { - quantity: '22', - unit: '06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464' - } - ] - }, - { - address: - 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x', - amount: [ - { - quantity: '9731978536963', - unit: 'lovelace' - } - ] - } - ] - }; - const mockedTx1Response = { - asset_mint_or_burn_count: 5, - block: '356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940', - block_height: 123_456, - delegation_count: 0, - fees: '182485', - hash: txId1, - index: 1, - invalid_before: null, - invalid_hereafter: '13885913', - mir_cert_count: 1, - output_amount: [ - { - quantity: '42000000', - unit: 'lovelace' - }, - { - quantity: '12', - unit: 'b0d07d45fe9514f80213f4020e5a61241458be626841cde717cb38a76e7574636f696e' - } - ], - pool_retire_count: 1, - pool_update_count: 1, - redeemer_count: 1, - size: 433, - slot: 42_000_000, - stake_cert_count: 1, - utxo_count: 5, - valid_contract: true, - withdrawal_count: 1 - }; - const mockedTx2Response = { - ...mockedTx1Response, - hash: txId2 - }; - const mockedMetadataResponse = [ - { - json_metadata: { - hash: '6bf124f217d0e5a0a8adb1dbd8540e1334280d49ab861127868339f43b3948af', - metadata: 'https://nut.link/metadata.json' - }, - label: '1967' - }, - { - json_metadata: { - ADAUSD: [ - { - source: 'ergoOracles', - value: 3 - } - ] - }, - label: '1968' - } - ]; - const mockedMirResponse = [ - { - address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', - amount: '431833601', - cert_index: 0, - pot: 'reserve' - } - ]; - const mockedPoolUpdateResponse = [ - { - active_epoch: 210, - cert_index: 0, - fixed_cost: '340000000', - margin_cost: 0.05, - metadata: { - description: 'The best pool ever', - hash: '47c0c68cb57f4a5b4a87bad896fc274678e7aea98e200fa14a1cb40c0cab1d8c', - homepage: 'https://stakentus.com/', - name: 'Stake Nuts', - ticker: 'NUTS', - url: 'https://stakenuts.com/mainnet.json' - }, - owners: ['stake1u98nnlkvkk23vtvf9273uq7cph5ww6u2yq2389psuqet90sv4xv9v'], - pledge: '5000000000', - pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', - relays: [ - { - dns: 'relay1.stakenuts.com', - dns_srv: '_relays._tcp.relays.stakenuts.com', - ipv4: '4.4.4.4', - ipv6: 'https://stakenuts.com/mainnet.json', - port: 3001 - } - ], - reward_account: 'stake1uxkptsa4lkr55jleztw43t37vgdn88l6ghclfwuxld2eykgpgvg3f', - vrf_key: '0b5245f9934ec2151116fb8ec00f35fd00e0aa3b075c4ed12cce440f999d8233' - } - ]; - const mockedPoolRetireResponse = [ - { - cert_index: 0, - pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', - retiring_epoch: 216 - } - ]; - const mockedStakeResponse = [ - { - address: 'stake1u9t3a0tcwune5xrnfjg4q7cpvjlgx9lcv0cuqf5mhfjwrvcwrulda', - cert_index: 0, - registration: true - } - ]; - const mockedDelegationResponse = [ - { - active_epoch: 210, - address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', - cert_index: 0, - pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy' - } - ]; - const mockedWithdrawalResponse = [ - { - address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', - amount: '431833601' - } - ]; - const mockedReedemerResponse = [ - { - fee: '172033', - purpose: 'spend', - redeemer_data_hash: '923918e403bf43c34b4ef6b48eb2ee04babed17320d8d1b9ff9ad086e86f44ec', - script_hash: 'ec26b89af41bef0f7585353831cb5da42b5b37185e0c8a526143b824', - tx_index: 0, - unit_mem: '1700', - unit_steps: '476468' - } - ]; - - const mockedAddressTransactionResponse: Responses['address_transactions_content'] = [ - { - block_height: 123, - block_time: 131_322, - 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 - } - ]; - const mockedAddressTransactionDescResponse: Responses['address_transactions_content'] = []; - const mockedEpochParametersResponse = { key_deposit: '0', pool_deposit: '0' }; - const expectedHydratedTx = { - auxiliaryData: { - blob: new Map([ - [ - 1967n, - new Map([ - ['hash', '6bf124f217d0e5a0a8adb1dbd8540e1334280d49ab861127868339f43b3948af'], - ['metadata', 'https://nut.link/metadata.json'] - ]) - ], - [ - 1968n, - new Map([ - [ - 'ADAUSD', - [ - new Map([ - ['source', 'ergoOracles'], - ['value', 3n] - ]) - ] - ] - ]) - ] - ]) - }, - blockHeader: { - blockNo: Cardano.BlockNo(123_456), - hash: Cardano.BlockId('356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940'), - slot: Cardano.Slot(42_000_000) - }, - body: { - certificates: [ - { - __typename: Cardano.CertificateType.PoolRetirement, - epoch: 216, - poolId: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy' - } as unknown as Cardano.HydratedCertificate, - { - __typename: 'PoolRegistrationCertificate', - deposit: 0n, - poolId: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', - poolParameters: { - cost: 340_000_000n, - id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', - margin: { - denominator: 20, - numerator: 1 - }, - owners: [], - pledge: 5_000_000_000n, - relays: [], - rewardAccount: 'stake1uxkptsa4lkr55jleztw43t37vgdn88l6ghclfwuxld2eykgpgvg3f', - vrf: '0b5245f9934ec2151116fb8ec00f35fd00e0aa3b075c4ed12cce440f999d8233' - } - }, - { - __typename: 'MirCertificate', - kind: 'ToStakeCreds', - pot: 'reserve', - quantity: 431_833_601n, - rewardAccount: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc' - }, - { - __typename: 'RegistrationCertificate', - deposit: 0n, - stakeCredential: { - hash: '571ebd7877279a18734c91507b0164be8317f863f1c0269bba64e1b3', - type: 0 - } - } - ], - collateralReturn: undefined, - collaterals: new Array(), - fee: 182_485n, + describe('with default behavior (queryTxsByCredentials: false)', () => { + let request: jest.Mock; + 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: txId1, inputs: [ { - address: Cardano.PaymentAddress( - 'addr_test1qr05llxkwg5t6c4j3ck5mqfax9wmz35rpcgw3qthrn9z7xcxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknstdz3k2' - ), - index: 1, - txId: Cardano.TransactionId('6d50c330a6fba79de6949a8dcd5e4b7ffa3f9442f0c5bed7a78fa6d786c6c863') + address: + 'addr_test1qr05llxkwg5t6c4j3ck5mqfax9wmz35rpcgw3qthrn9z7xcxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknstdz3k2', + amount: [ + { + quantity: '9732978705764', + unit: 'lovelace' + } + ], + output_index: 1, + tx_hash: '6d50c330a6fba79de6949a8dcd5e4b7ffa3f9442f0c5bed7a78fa6d786c6c863' } ], - mint: new Map([ - [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364'), 63n], - [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464'), 22n] - ]), outputs: [ { - address: Cardano.PaymentAddress( - 'addr_test1qzx9hu8j4ah3auytk0mwcupd69hpc52t0cw39a65ndrah86djs784u92a3m5w475w3w35tyd6v3qumkze80j8a6h5tuqq5xe8y' - ), - value: { - assets: new Map([ - [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364'), 63n], - [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464'), 22n] - ]), - coins: 1_000_000_000n - } + address: + 'addr_test1qzx9hu8j4ah3auytk0mwcupd69hpc52t0cw39a65ndrah86djs784u92a3m5w475w3w35tyd6v3qumkze80j8a6h5tuqq5xe8y', + amount: [ + { + quantity: '1000000000', + unit: 'lovelace' + }, + { + quantity: '63', + unit: '06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364' + }, + { + quantity: '22', + unit: '06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464' + } + ] }, { - address: Cardano.PaymentAddress( - 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' - ), - value: { - coins: 9_731_978_536_963n - } - } - ], - proposalProcedures: undefined, - validityInterval: { - invalidBefore: undefined, - invalidHereafter: Cardano.Slot(13_885_913) - }, - votingProcedures: undefined, - withdrawals: [ - { - quantity: 431_833_601n, - stakeAddress: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc' + address: + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x', + amount: [ + { + quantity: '9731978536963', + unit: 'lovelace' + } + ] } ] - }, - id: Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'), - index: 1, - inputSource: Cardano.InputSource.inputs, - txSize: 433, - witness: { - redeemers: [ + }; + const mockedTx1Response = { + asset_mint_or_burn_count: 5, + block: '356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940', + block_height: 123_456, + delegation_count: 0, + fees: '182485', + hash: txId1, + index: 1, + invalid_before: null, + invalid_hereafter: '13885913', + mir_cert_count: 1, + output_amount: [ { - data: Buffer.from(new Uint8Array([110, 111, 116, 32, 105, 109, 112, 108, 101, 109, 101, 110, 116, 101, 100])), - executionUnits: { - memory: 1700, - steps: 476_468 - }, - index: 0, - purpose: 'spend' - } as Cardano.Redeemer - ], - signatures: new Map() // not available in blockfrost - } - } as Cardano.HydratedTx; - - beforeEach(() => { - request = jest.fn(); - const client = { request } as unknown as BlockfrostClient; - networkInfoProvider = { - eraSummaries: jest.fn().mockResolvedValue([ + quantity: '42000000', + unit: 'lovelace' + }, { - end: { slot: 100, time: new Date(1_506_203_092_000) }, - parameters: { epochLength: 100, safeZone: 0, slotLength: 1 }, - start: { slot: 0, time: new Date(1_506_203_091_000) } + quantity: '12', + unit: 'b0d07d45fe9514f80213f4020e5a61241458be626841cde717cb38a76e7574636f696e' } - ]) - } as unknown as NetworkInfoProvider; - const cacheStorage = new Map(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const cache: Cache = { - async get(key) { - return cacheStorage.get(key); + ], + pool_retire_count: 1, + pool_update_count: 1, + redeemer_count: 1, + size: 433, + slot: 42_000_000, + stake_cert_count: 1, + utxo_count: 5, + valid_contract: true, + withdrawal_count: 1 + }; + const mockedTx2Response = { + ...mockedTx1Response, + hash: txId2 + }; + const mockedMetadataResponse = [ + { + json_metadata: { + hash: '6bf124f217d0e5a0a8adb1dbd8540e1334280d49ab861127868339f43b3948af', + metadata: 'https://nut.link/metadata.json' + }, + label: '1967' }, - async set(key, value) { - cacheStorage.set(key, value); + { + json_metadata: { + ADAUSD: [ + { + source: 'ergoOracles', + value: 3 + } + ] + }, + label: '1968' } - }; - - provider = new BlockfrostChainHistoryProvider({ - cache, - client, - logger, - networkInfoProvider - }); - mockResponses(request, [ - [`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' - )}/transactions?page=1&count=20`, - mockedAddressTransactionResponse - ], - [ - 'addresses/2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr/transactions?page=1&count=20&order=desc', - mockedAddressTransactionDescResponse - ], - ['epochs/420000/parameters', mockedEpochParametersResponse], - [`txs/${txId1}/cbor`, new Error('CBOR is null')] - ]); - }); - - describe('transactionsBy*', () => { - describe('transactionsByAddresses', () => { - test('converts responses correctly', async () => { - const response = await provider.transactionsByAddresses({ - addresses: [Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr')], - pagination: { limit: 20, startAt: 0 } - }); - - expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); - expect(response.pageResults[0]).toEqual(expectedHydratedTx); - }); - test('supports desc order', async () => { - const response = await provider.transactionsByAddresses({ - addresses: [Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr')], - pagination: { limit: 20, order: 'desc', startAt: 0 } - }); + ]; + const mockedMirResponse = [ + { + address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', + amount: '431833601', + cert_index: 0, + pot: 'reserve' + } + ]; + const mockedPoolUpdateResponse = [ + { + active_epoch: 210, + cert_index: 0, + fixed_cost: '340000000', + margin_cost: 0.05, + metadata: { + description: 'The best pool ever', + hash: '47c0c68cb57f4a5b4a87bad896fc274678e7aea98e200fa14a1cb40c0cab1d8c', + homepage: 'https://stakentus.com/', + name: 'Stake Nuts', + ticker: 'NUTS', + url: 'https://stakenuts.com/mainnet.json' + }, + owners: ['stake1u98nnlkvkk23vtvf9273uq7cph5ww6u2yq2389psuqet90sv4xv9v'], + pledge: '5000000000', + pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', + relays: [ + { + dns: 'relay1.stakenuts.com', + dns_srv: '_relays._tcp.relays.stakenuts.com', + ipv4: '4.4.4.4', + ipv6: 'https://stakenuts.com/mainnet.json', + port: 3001 + } + ], + reward_account: 'stake1uxkptsa4lkr55jleztw43t37vgdn88l6ghclfwuxld2eykgpgvg3f', + vrf_key: '0b5245f9934ec2151116fb8ec00f35fd00e0aa3b075c4ed12cce440f999d8233' + } + ]; + const mockedPoolRetireResponse = [ + { + cert_index: 0, + pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', + retiring_epoch: 216 + } + ]; + const mockedStakeResponse = [ + { + address: 'stake1u9t3a0tcwune5xrnfjg4q7cpvjlgx9lcv0cuqf5mhfjwrvcwrulda', + cert_index: 0, + registration: true + } + ]; + const mockedDelegationResponse = [ + { + active_epoch: 210, + address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', + cert_index: 0, + pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy' + } + ]; + const mockedWithdrawalResponse = [ + { + address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', + amount: '431833601' + } + ]; + const mockedReedemerResponse = [ + { + fee: '172033', + purpose: 'spend', + redeemer_data_hash: '923918e403bf43c34b4ef6b48eb2ee04babed17320d8d1b9ff9ad086e86f44ec', + script_hash: 'ec26b89af41bef0f7585353831cb5da42b5b37185e0c8a526143b824', + tx_index: 0, + unit_mem: '1700', + unit_steps: '476468' + } + ]; - expect(response.totalResultCount).toBe(0); - }); - test('deduplicates transactions', async () => { - const response = await provider.transactionsByAddresses({ - addresses: [ - Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr'), - Cardano.PaymentAddress( - 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' - ) + const mockedAddressTransactionResponse: Responses['address_transactions_content'] = [ + { + block_height: 123, + block_time: 131_322, + 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 + } + ]; + const mockedAddressTransactionDescResponse: Responses['address_transactions_content'] = []; + const mockedEpochParametersResponse = { key_deposit: '0', pool_deposit: '0' }; + const expectedHydratedTx = { + auxiliaryData: { + blob: new Map([ + [ + 1967n, + new Map([ + ['hash', '6bf124f217d0e5a0a8adb1dbd8540e1334280d49ab861127868339f43b3948af'], + ['metadata', 'https://nut.link/metadata.json'] + ]) ], - pagination: { limit: 20, startAt: 0 } - }); - 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', () => { - test('converts responses correctly', async () => { - const response = await provider.transactionsByHashes({ - ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'].map(Cardano.TransactionId) - }); - - expect(response).toHaveLength(1); - expect(response[0]).toEqual(expectedHydratedTx); - }); - }); - }); - describe('transactionsBy* with CBOR', () => { - const mockedCborResponse = { - cbor: '84a90082825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a900825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a9010d81825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a90112818258205de304d9c8884dd62ad8535d529e3d8fd5f212cc3c0c39410e4f3bccfca6e46b010182a300581d7039a4c3afe97b4c2d3385fefd5206d1865c74786b7ce955ebb6532e7a01821a001b9f18a1581cdab1406f1c769fbdb00514c494eed47a54c7ffc5d7aafc524cca069aa14d446a65644f7261636c654e465401028201d81858a6d8799f58404fe28ad1e94e742a6c79eb8f7cc44128965579792e4b5be940bfa61c3797914970fe2055016c2b7c3bbd9de43194e82b22a4ccdbee80b4099f73a304d9550707d8799fd8799f1a000f42401a00053dc9ffd8799fd8799fd87a9f1b00000192515efa80ffd87a80ffd8799fd87a9f1b00000192516cb620ffd87a80ffff43555344ff581cdab1406f1c769fbdb00514c494eed47a54c7ffc5d7aafc524cca069aff82583900f0a26fc170ad82b64a3d43dede08e78ea6e2028b5101058d0263a80a4c0ec23eba3aa27a4b8d61107f59f3e0d24b7bb6d7ae1a4bbd689c8d1abe8373ea021a0003ff5f031a044e9894081a044e95100e81581cf0a26fc170ad82b64a3d43dede08e78ea6e2028b5101058d0263a80a0b58205b3d8c3bc5032e4d2dbe3c07d23d7fb5133f4ec7466e73744317cebe2ed12610a200818258205401b7f67442e6cc870fbcffe921d24ab03e97e03bb655a24b4f424fd832c61558405bddd2f0d88e254ef1f0a84276aaadea4df3b05f8c441d9774b6a8d1c2556437a79e5131ea4475169d45e6e02bb3b31cd93216c5499e2c316aa68e485d6800000581840000d87980821a0006a7f41a0ab2d5a5f5f6' - }; - const expectedHydratedTxCBOR = { - auxiliaryData: undefined, + [ + 1968n, + new Map([ + [ + 'ADAUSD', + [ + new Map([ + ['source', 'ergoOracles'], + ['value', 3n] + ]) + ] + ] + ]) + ] + ]) + }, blockHeader: { blockNo: Cardano.BlockNo(123_456), hash: Cardano.BlockId('356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940'), slot: Cardano.Slot(42_000_000) }, body: { - certificates: undefined, + certificates: [ + { + __typename: Cardano.CertificateType.PoolRetirement, + epoch: 216, + poolId: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy' + } as unknown as Cardano.HydratedCertificate, + { + __typename: 'PoolRegistrationCertificate', + deposit: 0n, + poolId: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', + poolParameters: { + cost: 340_000_000n, + id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', + margin: { + denominator: 20, + numerator: 1 + }, + owners: [], + pledge: 5_000_000_000n, + relays: [], + rewardAccount: 'stake1uxkptsa4lkr55jleztw43t37vgdn88l6ghclfwuxld2eykgpgvg3f', + vrf: '0b5245f9934ec2151116fb8ec00f35fd00e0aa3b075c4ed12cce440f999d8233' + } + }, + { + __typename: 'MirCertificate', + kind: 'ToStakeCreds', + pot: 'reserve', + quantity: 431_833_601n, + rewardAccount: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc' + }, + { + __typename: 'RegistrationCertificate', + deposit: 0n, + stakeCredential: { + hash: '571ebd7877279a18734c91507b0164be8317f863f1c0269bba64e1b3', + type: 0 + } + } + ], collateralReturn: undefined, collaterals: new Array(), - fee: 261_983n, + fee: 182_485n, inputs: [ { address: Cardano.PaymentAddress( @@ -507,7 +301,10 @@ describe('blockfrostChainHistoryProvider', () => { txId: Cardano.TransactionId('6d50c330a6fba79de6949a8dcd5e4b7ffa3f9442f0c5bed7a78fa6d786c6c863') } ], - mint: undefined, + mint: new Map([ + [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364'), 63n], + [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464'), 22n] + ]), outputs: [ { address: Cardano.PaymentAddress( @@ -532,11 +329,16 @@ describe('blockfrostChainHistoryProvider', () => { ], proposalProcedures: undefined, validityInterval: { - invalidBefore: Cardano.Slot(72_258_832), - invalidHereafter: Cardano.Slot(72_259_732) + invalidBefore: undefined, + invalidHereafter: Cardano.Slot(13_885_913) }, votingProcedures: undefined, - withdrawals: undefined + withdrawals: [ + { + quantity: 431_833_601n, + stakeAddress: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc' + } + ] }, id: Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'), index: 1, @@ -549,83 +351,382 @@ describe('blockfrostChainHistoryProvider', () => { new Uint8Array([110, 111, 116, 32, 105, 109, 112, 108, 101, 109, 101, 110, 116, 101, 100]) ), executionUnits: { - memory: 436_212, - steps: 179_492_261 + memory: 1700, + steps: 476_468 }, index: 0, purpose: 'spend' - } + } as Cardano.Redeemer ], - signatures: new Map() + signatures: new Map() // not available in blockfrost } } as Cardano.HydratedTx; beforeEach(() => { - const txId = Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); - const id = txId.toString(); + request = jest.fn(); + const client = { request } as unknown as BlockfrostClient; + networkInfoProvider = { + eraSummaries: jest.fn().mockResolvedValue([ + { + end: { slot: 100, time: new Date(1_506_203_092_000) }, + parameters: { epochLength: 100, safeZone: 0, slotLength: 1 }, + start: { slot: 0, time: new Date(1_506_203_091_000) } + } + ]) + } as unknown as NetworkInfoProvider; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cache: Cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + + provider = new BlockfrostChainHistoryProvider({ + cache, + client, + logger, + networkInfoProvider + }); mockResponses(request, [ - [`txs/${id}/utxos`, txsUtxosResponse], - [`txs/${id}`, mockedTx1Response], - [`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], + [`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( - '2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr' - ).toString()}/transactions?page=1&count=20`, + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' + )}/transactions?page=1&count=20`, mockedAddressTransactionResponse ], + [ + 'addresses/2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr/transactions?page=1&count=20&order=desc', + mockedAddressTransactionDescResponse + ], ['epochs/420000/parameters', mockedEpochParametersResponse], - [`txs/${id}/cbor`, mockedCborResponse] + [`txs/${txId1}/cbor`, new Error('CBOR is null')] ]); }); - describe('transactionsByAddresses (CBOR)', () => { - test('converts responses correctly (CBOR)', async () => { - const response = await provider.transactionsByAddresses({ - addresses: [Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr')], - pagination: { limit: 20, startAt: 0 } + describe('transactionsBy*', () => { + describe('transactionsByAddresses', () => { + test('converts responses correctly', async () => { + const response = await provider.transactionsByAddresses({ + addresses: [ + Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr') + ], + pagination: { limit: 20, startAt: 0 } + }); + + expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); + expect(response.pageResults[0]).toEqual(expectedHydratedTx); + }); + test('supports desc order', async () => { + const response = await provider.transactionsByAddresses({ + addresses: [ + Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr') + ], + pagination: { limit: 20, order: 'desc', startAt: 0 } + }); + + expect(response.totalResultCount).toBe(0); }); + test('deduplicates transactions', async () => { + const response = await provider.transactionsByAddresses({ + addresses: [ + Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr'), + Cardano.PaymentAddress( + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' + ) + ], + pagination: { limit: 20, startAt: 0 } + }); + 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 } + }); - expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); - expect(response.pageResults[0]).toEqual(expectedHydratedTxCBOR); + const totalResultCount = mockedAddressTransactionResponse.length + mockedAddress2TransactionResponse.length; + expect(response.totalResultCount).toBe(totalResultCount); + expect(response.pageResults.length).toBe(totalResultCount); + }); + }); + + describe('transactionsByHashes', () => { + test('converts responses correctly', async () => { + const response = await provider.transactionsByHashes({ + ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'].map(Cardano.TransactionId) + }); + + expect(response).toHaveLength(1); + expect(response[0]).toEqual(expectedHydratedTx); + }); }); }); + describe('transactionsBy* with CBOR', () => { + const mockedCborResponse = { + cbor: '84a90082825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a900825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a9010d81825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a90112818258205de304d9c8884dd62ad8535d529e3d8fd5f212cc3c0c39410e4f3bccfca6e46b010182a300581d7039a4c3afe97b4c2d3385fefd5206d1865c74786b7ce955ebb6532e7a01821a001b9f18a1581cdab1406f1c769fbdb00514c494eed47a54c7ffc5d7aafc524cca069aa14d446a65644f7261636c654e465401028201d81858a6d8799f58404fe28ad1e94e742a6c79eb8f7cc44128965579792e4b5be940bfa61c3797914970fe2055016c2b7c3bbd9de43194e82b22a4ccdbee80b4099f73a304d9550707d8799fd8799f1a000f42401a00053dc9ffd8799fd8799fd87a9f1b00000192515efa80ffd87a80ffd8799fd87a9f1b00000192516cb620ffd87a80ffff43555344ff581cdab1406f1c769fbdb00514c494eed47a54c7ffc5d7aafc524cca069aff82583900f0a26fc170ad82b64a3d43dede08e78ea6e2028b5101058d0263a80a4c0ec23eba3aa27a4b8d61107f59f3e0d24b7bb6d7ae1a4bbd689c8d1abe8373ea021a0003ff5f031a044e9894081a044e95100e81581cf0a26fc170ad82b64a3d43dede08e78ea6e2028b5101058d0263a80a0b58205b3d8c3bc5032e4d2dbe3c07d23d7fb5133f4ec7466e73744317cebe2ed12610a200818258205401b7f67442e6cc870fbcffe921d24ab03e97e03bb655a24b4f424fd832c61558405bddd2f0d88e254ef1f0a84276aaadea4df3b05f8c441d9774b6a8d1c2556437a79e5131ea4475169d45e6e02bb3b31cd93216c5499e2c316aa68e485d6800000581840000d87980821a0006a7f41a0ab2d5a5f5f6' + }; + const expectedHydratedTxCBOR = { + auxiliaryData: undefined, + blockHeader: { + blockNo: Cardano.BlockNo(123_456), + hash: Cardano.BlockId('356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940'), + slot: Cardano.Slot(42_000_000) + }, + body: { + certificates: undefined, + collateralReturn: undefined, + collaterals: new Array(), + fee: 261_983n, + inputs: [ + { + address: Cardano.PaymentAddress( + 'addr_test1qr05llxkwg5t6c4j3ck5mqfax9wmz35rpcgw3qthrn9z7xcxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknstdz3k2' + ), + index: 1, + txId: Cardano.TransactionId('6d50c330a6fba79de6949a8dcd5e4b7ffa3f9442f0c5bed7a78fa6d786c6c863') + } + ], + mint: undefined, + outputs: [ + { + address: Cardano.PaymentAddress( + 'addr_test1qzx9hu8j4ah3auytk0mwcupd69hpc52t0cw39a65ndrah86djs784u92a3m5w475w3w35tyd6v3qumkze80j8a6h5tuqq5xe8y' + ), + value: { + assets: new Map([ + [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364'), 63n], + [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464'), 22n] + ]), + coins: 1_000_000_000n + } + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' + ), + value: { + coins: 9_731_978_536_963n + } + } + ], + proposalProcedures: undefined, + validityInterval: { + invalidBefore: Cardano.Slot(72_258_832), + invalidHereafter: Cardano.Slot(72_259_732) + }, + votingProcedures: undefined, + withdrawals: undefined + }, + id: Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'), + index: 1, + inputSource: Cardano.InputSource.inputs, + txSize: 433, + witness: { + redeemers: [ + { + data: Buffer.from( + new Uint8Array([110, 111, 116, 32, 105, 109, 112, 108, 101, 109, 101, 110, 116, 101, 100]) + ), + executionUnits: { + memory: 436_212, + steps: 179_492_261 + }, + index: 0, + purpose: 'spend' + } + ], + signatures: new Map() + } + } as Cardano.HydratedTx; + + beforeEach(() => { + const txId = Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + const id = txId.toString(); + mockResponses(request, [ + [`txs/${id}/utxos`, txsUtxosResponse], + [`txs/${id}`, mockedTx1Response], + [`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 + ], + ['epochs/420000/parameters', mockedEpochParametersResponse], + [`txs/${id}/cbor`, mockedCborResponse] + ]); + }); - describe('transactionsByHashes (CBOR)', () => { - test('converts responses correctly (CBOR)', async () => { - const response = await provider.transactionsByHashes({ - ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'].map(Cardano.TransactionId) + describe('transactionsByAddresses (CBOR)', () => { + test('converts responses correctly (CBOR)', async () => { + const response = await provider.transactionsByAddresses({ + addresses: [ + Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr') + ], + pagination: { limit: 20, startAt: 0 } + }); + + expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); + expect(response.pageResults[0]).toEqual(expectedHydratedTxCBOR); }); + }); + + describe('transactionsByHashes (CBOR)', () => { + test('converts responses correctly (CBOR)', async () => { + const response = await provider.transactionsByHashes({ + ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'].map(Cardano.TransactionId) + }); - expect(response).toHaveLength(1); - expect(response[0]).toEqual(expectedHydratedTxCBOR); + expect(response).toHaveLength(1); + expect(response[0]).toEqual(expectedHydratedTxCBOR); + }); }); - }); - test('caches transactions and returns them from the cache on subsequent calls', async () => { - const firstResponse = await provider.transactionsByHashes({ - ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477' as Cardano.TransactionId] + test('caches transactions and returns them from the cache on subsequent calls', async () => { + const firstResponse = await provider.transactionsByHashes({ + ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477' as Cardano.TransactionId] + }); + + expect(firstResponse).toHaveLength(1); + expect(firstResponse[0]).toEqual(expectedHydratedTxCBOR); + expect(request).toHaveBeenCalled(); + + request.mockClear(); + + const secondResponse = await provider.transactionsByHashes({ + ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477' as Cardano.TransactionId] + }); + + expect(secondResponse).toHaveLength(1); + expect(secondResponse[0]).toEqual(expectedHydratedTxCBOR); + expect(request).not.toHaveBeenCalled(); }); + }); + }); - expect(firstResponse).toHaveLength(1); - expect(firstResponse[0]).toEqual(expectedHydratedTxCBOR); - expect(request).toHaveBeenCalled(); + describe('constructor overloads', () => { + let client: BlockfrostClient; + let cache: Cache; + let networkInfoProvider: NetworkInfoProvider; - request.mockClear(); + beforeEach(() => { + const request = jest.fn(); + client = { request } as unknown as BlockfrostClient; + networkInfoProvider = { + eraSummaries: jest.fn().mockResolvedValue([ + { + end: { slot: 100, time: new Date(1_506_203_092_000) }, + parameters: { epochLength: 100, safeZone: 0, slotLength: 1 }, + start: { slot: 0, time: new Date(1_506_203_091_000) } + } + ]) + } as unknown as NetworkInfoProvider; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + }); - const secondResponse = await provider.transactionsByHashes({ - ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477' as Cardano.TransactionId] + test('old constructor signature works (backward compatibility)', () => { + const provider = new BlockfrostChainHistoryProvider({ + cache, + client, + logger, + networkInfoProvider }); - expect(secondResponse).toHaveLength(1); - expect(secondResponse[0]).toEqual(expectedHydratedTxCBOR); - expect(request).not.toHaveBeenCalled(); + expect(provider).toBeInstanceOf(BlockfrostChainHistoryProvider); + // @ts-expect-error accessing protected property for testing + expect(provider.queryTxsByCredentials).toBe(false); + }); + + test('new constructor signature with queryTxsByCredentials: false', () => { + const provider = new BlockfrostChainHistoryProvider( + { queryTxsByCredentials: false }, + { + cache, + client, + logger, + networkInfoProvider + } + ); + + expect(provider).toBeInstanceOf(BlockfrostChainHistoryProvider); + // @ts-expect-error accessing protected property for testing + expect(provider.queryTxsByCredentials).toBe(false); + }); + + test('new constructor signature with queryTxsByCredentials: true', () => { + const provider = new BlockfrostChainHistoryProvider( + { queryTxsByCredentials: true }, + { + cache, + client, + logger, + networkInfoProvider + } + ); + + expect(provider).toBeInstanceOf(BlockfrostChainHistoryProvider); + // @ts-expect-error accessing protected property for testing + expect(provider.queryTxsByCredentials).toBe(true); + }); + + test('new constructor signature defaults to false when not specified', () => { + const provider = new BlockfrostChainHistoryProvider( + {}, + { + cache, + client, + logger, + networkInfoProvider + } + ); + + expect(provider).toBeInstanceOf(BlockfrostChainHistoryProvider); + // @ts-expect-error accessing protected property for testing + expect(provider.queryTxsByCredentials).toBe(false); }); }); }); From e50ed89ccc64f4e90b53588f5da23687dd96a244 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Wed, 5 Nov 2025 09:46:01 +0100 Subject: [PATCH 04/10] feat(cardano-services-client): implement credential-based transaction queries Refactor transactionsByAddresses() to query by payment credentials and reward accounts when queryTxsByCredentials flag is enabled, reducing API calls for multi-address wallets. --- .../BlockfrostChainHistoryProvider.ts | 370 ++++++++++++++++-- .../BlockfrostChainHistoryProvider.test.ts | 346 ++++++++++++++++ 2 files changed, 679 insertions(+), 37 deletions(-) diff --git a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts index 5defd97039b..ebd6e2fe16f 100644 --- a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts +++ b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts @@ -22,6 +22,7 @@ import { createSlotEpochCalc } from '@cardano-sdk/core'; import { Logger } from 'ts-log'; +import { extractCredentials, minimizeCredentialSet } from '../credentialUtils'; import omit from 'lodash/omit.js'; import uniq from 'lodash/uniq.js'; import type { Cache } from '@cardano-sdk/util'; @@ -525,52 +526,183 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement ); } - // eslint-disable-next-line sonarjs/cognitive-complexity + /** Processes and filters transaction results, returning deduplicated and sorted transaction IDs. */ + private processTransactionResults( + transactions: BlockfrostTransactionContent[], + blockRange: { lowerBound?: number; upperBound?: number }, + order?: 'asc' | 'desc' + ): Cardano.TransactionId[] { + const lowerBound = blockRange.lowerBound ?? 0; + const upperBound = blockRange.upperBound ?? DB_MAX_SAFE_INTEGER; + + return uniq( + transactions + .filter(({ block_height }) => block_height >= lowerBound && block_height <= upperBound) + .sort(order === 'desc' ? (a, b) => compareTx(b, a) : compareTx) + .map(({ tx_hash }) => Cardano.TransactionId(tx_hash)) + ); + } + + /** Executes the legacy per-address transaction fetching implementation. */ + private async fetchTransactionsPerAddress( + addresses: Cardano.PaymentAddress[], + pagination: TransactionsByAddressesArgs['pagination'], + blockRange: TransactionsByAddressesArgs['blockRange'] + ): Promise { + const paginationOptions = pagination + ? { + count: pagination.limit, + order: pagination.order, + page: (pagination.startAt + pagination.limit) / pagination.limit + } + : undefined; + + const limit = pagination?.limit ?? DB_MAX_SAFE_INTEGER; + + const addressTransactions = await Promise.all( + addresses.map((address) => + this.fetchTransactionsByAddress(address, { + blockRange, + limit, + pagination: paginationOptions + }) + ) + ); + + return addressTransactions.flat(); + } + + /** Logs information about skipped addresses. */ + private logSkippedAddresses(skippedAddresses: { + byron: Cardano.PaymentAddress[]; + pointer: Cardano.PaymentAddress[]; + }): void { + if (skippedAddresses.byron.length > 0) { + this.logger.info( + `Found ${skippedAddresses.byron.length} Byron address(es), falling back to per-address fetching` + ); + } + if (skippedAddresses.pointer.length > 0) { + this.logger.info( + `Found ${skippedAddresses.pointer.length} Pointer address(es), falling back to per-address fetching` + ); + } + } + + /** Logs minimization statistics. */ + private logMinimizationStats( + totalAddresses: number, + minimized: { paymentCredentials: Map; rewardAccounts: Map }, + skippedAddresses: { byron: Cardano.PaymentAddress[]; pointer: Cardano.PaymentAddress[] } + ): void { + const paymentCredCount = minimized.paymentCredentials.size; + const rewardAccountCount = minimized.rewardAccounts.size; + const skippedCount = skippedAddresses.byron.length + skippedAddresses.pointer.length; + const totalQueries = paymentCredCount + rewardAccountCount + skippedCount; + + this.logger.debug( + `Minimized ${totalAddresses} address(es) to ${totalQueries} query/queries: ` + + `${paymentCredCount} payment credential(s), ${rewardAccountCount} reward account(s), ${skippedCount} skipped address(es)` + ); + } + + /** Fetches transactions for all payment credentials in parallel. */ + private async fetchAllByPaymentCredentials( + credentials: Map, + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }, + blockRange?: { lowerBound?: number; upperBound?: number } + ): Promise { + const results = await Promise.all( + [...credentials.keys()].map((credential) => + this.fetchTransactionsByPaymentCredential(credential, { blockRange, pagination }) + ) + ); + return results.flat(); + } + + /** Fetches transactions for all reward accounts in parallel. */ + private async fetchAllByRewardAccounts( + rewardAccounts: Map, + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }, + blockRange?: { lowerBound?: number; upperBound?: number } + ): Promise { + const results = await Promise.all( + [...rewardAccounts.keys()].map((rewardAccount) => + this.fetchTransactionsByRewardAccount(rewardAccount, { blockRange, pagination }) + ) + ); + return results.flat(); + } + + /** Fetches transactions for skipped addresses (Byron and Pointer). */ + private async fetchSkippedAddresses( + skippedAddresses: { byron: Cardano.PaymentAddress[]; pointer: Cardano.PaymentAddress[] }, + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }, + blockRange?: { lowerBound?: number; upperBound?: number }, + limit?: number + ): Promise { + const allSkippedAddresses = [...skippedAddresses.byron, ...skippedAddresses.pointer]; + const results = await Promise.all( + allSkippedAddresses.map((address) => this.fetchTransactionsByAddress(address, { blockRange, limit, pagination })) + ); + return results.flat(); + } + + /** Executes the optimized credential-based transaction fetching implementation. */ + private async fetchTransactionsByCredentials( + addresses: Cardano.PaymentAddress[], + pagination: TransactionsByAddressesArgs['pagination'], + blockRange: TransactionsByAddressesArgs['blockRange'] + ): Promise { + const addressGroups = extractCredentials(addresses); + + this.logSkippedAddresses(addressGroups.skippedAddresses); + + const minimized = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + this.logMinimizationStats(addresses.length, minimized, addressGroups.skippedAddresses); + + const paginationOptions = pagination + ? { + count: pagination.limit, + order: pagination.order ?? 'asc', + page: (pagination.startAt + pagination.limit) / pagination.limit + } + : undefined; + + const blockRangeOptions = { lowerBound: blockRange?.lowerBound, upperBound: blockRange?.upperBound }; + const limit = pagination?.limit ?? DB_MAX_SAFE_INTEGER; + + const [paymentTxs, rewardAccountTxs, skippedAddressTxs] = await Promise.all([ + this.fetchAllByPaymentCredentials(minimized.paymentCredentials, paginationOptions, blockRangeOptions), + this.fetchAllByRewardAccounts(minimized.rewardAccounts, paginationOptions, blockRangeOptions), + this.fetchSkippedAddresses(addressGroups.skippedAddresses, paginationOptions, blockRangeOptions, limit) + ]); + + return [...paymentTxs, ...rewardAccountTxs, ...skippedAddressTxs]; + } + public async transactionsByAddresses({ addresses, pagination, blockRange }: TransactionsByAddressesArgs): Promise> { this.logger.debug(`transactionsByAddresses: ${JSON.stringify(blockRange)} ${JSON.stringify(addresses)}`); + try { - const lowerBound = blockRange?.lowerBound ?? 0; - const upperBound = blockRange?.upperBound ?? DB_MAX_SAFE_INTEGER; - const limit = pagination?.limit ?? DB_MAX_SAFE_INTEGER; - - const addressTransactions = await Promise.all( - addresses.map(async (address) => - fetchSequentially<{ tx_hash: string; tx_index: number; block_height: number }, BlockfrostTransactionContent>({ - haveEnoughItems: blockRange?.lowerBound - ? (transactions) => - (transactions.length > 0 && - transactions[transactions.length - 1].block_height < blockRange!.lowerBound!) || - transactions.length >= limit - : (transactions) => transactions.length >= limit, - paginationOptions: pagination - ? { - count: pagination.limit, - order: pagination.order, - page: (pagination.startAt + pagination.limit) / pagination.limit - } - : undefined, - request: (paginationQueryString) => { - let queryString = `addresses/${address}/transactions?${paginationQueryString}`; - if (blockRange?.lowerBound) queryString += `&from=${blockRange.lowerBound.toString()}`; - if (blockRange?.upperBound) queryString += `&to=${blockRange.upperBound.toString()}`; - return this.request(queryString); - } - }) - ) + const allTransactions = this.queryTxsByCredentials + ? await this.fetchTransactionsByCredentials(addresses, pagination, blockRange) + : await this.fetchTransactionsPerAddress(addresses, pagination, blockRange); + + const dedupedSortedTransactionsIds = this.processTransactionResults( + allTransactions, + { lowerBound: blockRange?.lowerBound, upperBound: blockRange?.upperBound }, + pagination?.order ); - const allTransactions = addressTransactions.flat(1); - - 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 pageResults = await this.transactionsByHashes({ ids: dedupedSortedTransactionsIds }); return { pageResults, totalResultCount: dedupedSortedTransactionsIds.length }; @@ -632,4 +764,168 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement private fetchUtxos(id: Cardano.TransactionId): Promise { return this.request(`txs/${id}/utxos`); } + + /** + * Helper to build query string with pagination and block range filters. + * + * @param baseEndpoint - The base endpoint (e.g., "addresses/xyz/transactions") + * @param paginationQueryString - Pagination query string from fetchSequentially + * @param blockRange - Optional block range filters + * @param blockRange.lowerBound - Lower bound for block range + * @param blockRange.upperBound - Upper bound for block range + * @returns Complete query string with all parameters + */ + private buildTransactionQueryString( + baseEndpoint: string, + paginationQueryString: string, + blockRange?: { lowerBound?: number; upperBound?: number } + ): string { + let queryString = `${baseEndpoint}?${paginationQueryString}`; + if (blockRange?.lowerBound) queryString += `&from=${blockRange.lowerBound.toString()}`; + if (blockRange?.upperBound) queryString += `&to=${blockRange.upperBound.toString()}`; + return queryString; + } + + /** + * Fetches transactions for a given address using the fetchSequentially pattern. + * + * @param address - The address to fetch transactions for + * @param options - Options for pagination, block range, and limit + * @param options.pagination - Pagination options + * @param options.pagination.count - Number of results per page + * @param options.pagination.order - Sort order (asc or desc) + * @param options.pagination.page - Page number + * @param options.blockRange - Block range filters + * @param options.blockRange.lowerBound - Lower bound for block range + * @param options.blockRange.upperBound - Upper bound for block range + * @param options.limit - Maximum number of transactions to fetch + * @returns Promise resolving to array of transaction contents + */ + private async fetchTransactionsByAddress( + address: Cardano.PaymentAddress, + options: { + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }; + blockRange?: { lowerBound?: number; upperBound?: number }; + limit?: number; + } + ): Promise { + const limit = options.limit ?? DB_MAX_SAFE_INTEGER; + + return fetchSequentially<{ tx_hash: string; tx_index: number; block_height: number }, BlockfrostTransactionContent>( + { + haveEnoughItems: options.blockRange?.lowerBound + ? (transactions) => + (transactions.length > 0 && + transactions[transactions.length - 1].block_height < options.blockRange!.lowerBound!) || + transactions.length >= limit + : (transactions) => transactions.length >= limit, + paginationOptions: options.pagination, + request: (paginationQueryString) => { + const queryString = this.buildTransactionQueryString( + `addresses/${address}/transactions`, + paginationQueryString, + options.blockRange + ); + return this.request(queryString); + } + } + ); + } + + /** + * Fetches transactions for a payment credential (bech32: addr_vkh or script). + * + * @param credential - Payment credential as bech32 string + * @param options - Pagination and block range options + * @param options.pagination - Pagination options + * @param options.pagination.count - Number of results per page + * @param options.pagination.order - Sort order (asc or desc) + * @param options.pagination.page - Page number + * @param options.blockRange - Block range filters + * @param options.blockRange.lowerBound - Lower bound for block range + * @param options.blockRange.upperBound - Upper bound for block range + * @returns Promise resolving to array of transaction contents + */ + protected async fetchTransactionsByPaymentCredential( + credential: Cardano.PaymentCredential, + options: { + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }; + blockRange?: { lowerBound?: number; upperBound?: number }; + } + ): Promise { + return fetchSequentially<{ tx_hash: string; tx_index: number; block_height: number }, BlockfrostTransactionContent>( + { + haveEnoughItems: () => false, // Fetch all pages + paginationOptions: options.pagination, + request: (paginationQueryString) => { + const queryString = this.buildTransactionQueryString( + `addresses/${credential}/transactions`, + paginationQueryString, + options.blockRange + ); + return this.request(queryString); + } + } + ); + } + + /** + * Fetches transactions for a reward account (stake address, bech32: stake or stake_test). + * + * @param rewardAccount - Reward account (stake address) as bech32 string + * @param options - Pagination and block range options + * @param options.pagination - Pagination options + * @param options.pagination.count - Number of results per page + * @param options.pagination.order - Sort order (asc or desc) + * @param options.pagination.page - Page number + * @param options.blockRange - Block range filters + * @param options.blockRange.lowerBound - Lower bound for block range + * @param options.blockRange.upperBound - Upper bound for block range + * @returns Promise resolving to array of transaction contents + */ + protected async fetchTransactionsByRewardAccount( + rewardAccount: Cardano.RewardAccount, + options: { + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }; + blockRange?: { lowerBound?: number; upperBound?: number }; + } + ): Promise { + return fetchSequentially<{ tx_hash: string; tx_index: number; block_height: number }, BlockfrostTransactionContent>( + { + haveEnoughItems: () => false, // Fetch all pages + paginationOptions: options.pagination, + request: (paginationQueryString) => { + const queryString = this.buildTransactionQueryString( + `accounts/${rewardAccount}/transactions`, + paginationQueryString, + options.blockRange + ); + // Note: accounts/{stake_address}/transactions returns the same structure as address_transactions_content + return this.request(queryString); + } + } + ); + } + + /** + * Merges and deduplicates transaction results from multiple sources (payment credentials, reward accounts, skipped addresses). + * + * @param paymentTxs Transactions from payment credential queries + * @param rewardAccountTxs Transactions from reward account (stake address) queries + * @param skippedAddressTxs Transactions from skipped address queries (Byron, Pointer) + * @param order Sort order (asc or desc) + * @returns Sorted and deduplicated array of transaction IDs + */ + protected mergeAndDeduplicateTransactionResults( + paymentTxs: BlockfrostTransactionContent[], + rewardAccountTxs: BlockfrostTransactionContent[], + skippedAddressTxs: BlockfrostTransactionContent[], + order: 'asc' | 'desc' = 'asc' + ): Cardano.TransactionId[] { + const allTransactions = [...paymentTxs, ...rewardAccountTxs, ...skippedAddressTxs]; + + const sortedTransactions = allTransactions.sort(order === 'desc' ? (a, b) => compareTx(b, a) : compareTx); + + return uniq(sortedTransactions.map(({ tx_hash }) => Cardano.TransactionId(tx_hash))); + } } diff --git a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts index effd5398247..3d95ef03b5c 100644 --- a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts +++ b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts @@ -1,12 +1,82 @@ import { BlockfrostChainHistoryProvider, BlockfrostClient } from '../../src'; import { Cardano, NetworkInfoProvider } from '@cardano-sdk/core'; import { Responses } from '@blockfrost/blockfrost-js'; +import { cip19TestVectors } from '@cardano-sdk/util-dev'; import { dummyLogger as logger } from 'ts-log'; import { mockResponses } from '../util'; import type { Cache } from '@cardano-sdk/util'; jest.mock('@blockfrost/blockfrost-js'); +// Test constants +const MOCK_EPOCH_PARAMS = { key_deposit: '0', pool_deposit: '0' }; +const CBOR_NULL_ERROR = new Error('CBOR is null'); +const ADDR_VKH_PREFIX = 'addresses/addr_vkh'; +const TRANSACTIONS_PATH = '/transactions'; +const UTXOS_PATH = '/utxos'; +const EPOCHS_PATH = 'epochs/'; +const CBOR_PATH = '/cbor'; +const ACCOUNTS_PATH = 'accounts/'; + +// Mock endpoints that return empty arrays +const EMPTY_ENDPOINTS = [ + '/metadata', + '/mirs', + '/pool_updates', + '/pool_retires', + '/stakes', + '/delegations', + '/withdrawals', + '/redeemers' +]; + +/** Helper to create handlers for credential-based queries. */ +const createCredentialHandler = (credentialTxs: unknown[]) => (url: string) => { + if (url.includes(ADDR_VKH_PREFIX) && url.includes(TRANSACTIONS_PATH)) return Promise.resolve(credentialTxs); + return null; +}; + +/** Helper to create handlers for account-based queries. */ +const createAccountHandler = (accountTxs: unknown[]) => (url: string) => { + if (url.includes(ACCOUNTS_PATH) && url.includes(TRANSACTIONS_PATH)) return Promise.resolve(accountTxs); + return null; +}; + +/** Helper to create a common mock request handler for transaction detail tests. */ +const createMockRequestHandler = + ( + txResponses: Record, + utxosResponse: unknown, + additionalHandlers?: (url: string) => Promise | null + ) => + (url: string): Promise => { + // Handle transaction detail endpoints + for (const txId of Object.keys(txResponses)) { + if (url === `txs/${txId}`) return Promise.resolve(txResponses[txId]); + } + + // Handle empty endpoints + for (const endpoint of EMPTY_ENDPOINTS) { + if (url.includes(endpoint)) return Promise.resolve([]); + } + + // Handle standard endpoints + if (url.includes(UTXOS_PATH)) return Promise.resolve(utxosResponse); + if (url.includes(EPOCHS_PATH)) return Promise.resolve(MOCK_EPOCH_PARAMS); + if (url.includes(CBOR_PATH)) throw CBOR_NULL_ERROR; + + // Handle additional custom handlers + if (additionalHandlers) { + const result = additionalHandlers(url); + if (result !== null) return result; + } + + // Default: return empty for transaction queries + if (url.includes(TRANSACTIONS_PATH)) return Promise.resolve([]); + + return Promise.resolve([]); + }; + describe('blockfrostChainHistoryProvider', () => { describe('with default behavior (queryTxsByCredentials: false)', () => { let request: jest.Mock; @@ -729,4 +799,280 @@ describe('blockfrostChainHistoryProvider', () => { expect(provider.queryTxsByCredentials).toBe(false); }); }); + + describe('with feature flag ON (queryTxsByCredentials: true)', () => { + let request: jest.Mock; + let provider: BlockfrostChainHistoryProvider; + let networkInfoProvider: NetworkInfoProvider; + let cache: Cache; + + const txId1 = Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + const txId2 = Cardano.TransactionId('2e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + const txId3 = Cardano.TransactionId('3e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + + // Use real test addresses from CIP-19 test vectors + const baseAddress1 = cip19TestVectors.basePaymentKeyStakeKey; // Base address with payment key + stake key + const baseAddress2 = cip19TestVectors.basePaymentScriptStakeKey; // Base address with script + stake key + const enterpriseAddress = cip19TestVectors.enterpriseKey; // Enterprise address (no stake) + + const mockedTx1Response = { + asset_mint_or_burn_count: 0, + block: '356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940', + block_height: 100, + delegation_count: 0, + fees: '182485', + hash: txId1, + index: 1, + invalid_before: null, + invalid_hereafter: '13885913', + mir_cert_count: 0, + output_amount: [{ quantity: '42000000', unit: 'lovelace' }], + pool_retire_count: 0, + pool_update_count: 0, + redeemer_count: 0, + size: 433, + slot: 42_000_000, + stake_cert_count: 0, + utxo_count: 2, + valid_contract: true, + withdrawal_count: 0 + }; + + const mockedTx2Response = { ...mockedTx1Response, block_height: 101, hash: txId2 }; + const mockedTx3Response = { ...mockedTx1Response, block_height: 102, hash: txId3 }; + + const txsUtxosResponse = { + hash: txId1, + inputs: [ + { + address: baseAddress1, + amount: [{ quantity: '50000000', unit: 'lovelace' }], + output_index: 0, + tx_hash: '0000000000000000000000000000000000000000000000000000000000000000' + } + ], + outputs: [ + { + address: baseAddress1, + amount: [{ quantity: '42000000', unit: 'lovelace' }] + } + ] + }; + + beforeEach(() => { + request = jest.fn(); + const client = { request } as unknown as BlockfrostClient; + networkInfoProvider = { + eraSummaries: jest.fn().mockResolvedValue([ + { + end: { slot: 100, time: new Date(1_506_203_092_000) }, + parameters: { epochLength: 100, safeZone: 0, slotLength: 1 }, + start: { slot: 0, time: new Date(1_506_203_091_000) } + } + ]) + } as unknown as NetworkInfoProvider; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + + provider = new BlockfrostChainHistoryProvider( + { queryTxsByCredentials: true }, + { + cache, + client, + logger, + networkInfoProvider + } + ); + }); + + describe('transactionsByAddresses with credential-based queries', () => { + test('single address is minimized to payment credential (1 API call)', async () => { + const credentialTxs = [{ block_height: 100, block_time: 1_000_000, tx_hash: txId1, tx_index: 0 }]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response }, + txsUtxosResponse, + createCredentialHandler(credentialTxs) + ) + ); + + await provider.transactionsByAddresses({ + addresses: [baseAddress1], + pagination: { limit: 20, startAt: 0 } + }); + + // Verify we queried by payment credential, not by address + const credentialCalls = request.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBeGreaterThan(0); + + // Should not query by address directly + const addressCalls = request.mock.calls.filter((call: string[]) => + call[0].includes(`addresses/${baseAddress1}${TRANSACTIONS_PATH}`) + ); + expect(addressCalls.length).toBe(0); + }); + + test('multiple addresses with shared stake key use reward account query', async () => { + const rewardAccountTxs = [ + { block_height: 100, block_time: 1_000_000, tx_hash: txId1, tx_index: 0 }, + { block_height: 101, block_time: 1_000_020, tx_hash: txId2, tx_index: 0 } + ]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response, [txId2]: mockedTx2Response }, + txsUtxosResponse, + createAccountHandler(rewardAccountTxs) + ) + ); + + await provider.transactionsByAddresses({ + addresses: [baseAddress1, baseAddress2], + pagination: { limit: 20, startAt: 0 } + }); + + // Should query by reward account (stake address) + const rewardAccountCalls = request.mock.calls.filter( + (call: string[]) => call[0].includes(ACCOUNTS_PATH) && call[0].includes(TRANSACTIONS_PATH) + ); + expect(rewardAccountCalls.length).toBeGreaterThan(0); + + // Should not query by payment credentials + const credentialCalls = request.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBe(0); + }); + + test('deduplicates transactions correctly', async () => { + const credentialTxs = [ + { block_height: 100, block_time: 1_000_000, tx_hash: txId1, tx_index: 0 }, + { block_height: 101, block_time: 1_000_020, tx_hash: txId2, tx_index: 0 }, + { block_height: 102, block_time: 1_000_040, tx_hash: txId2, tx_index: 1 } // Duplicate txId2 + ]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response, [txId2]: mockedTx2Response }, + txsUtxosResponse, + createCredentialHandler(credentialTxs) + ) + ); + + const result = await provider.transactionsByAddresses({ + addresses: [baseAddress1], + pagination: { limit: 20, startAt: 0 } + }); + + // Should deduplicate to 2 unique transactions (txId1, txId2) + expect(result.pageResults.length).toBe(2); + expect(result.totalResultCount).toBe(2); + }); + + test('sorts transactions in descending order', async () => { + const credentialTxs = [ + { block_height: 100, block_time: 1_000_000, tx_hash: txId1, tx_index: 0 }, + { block_height: 101, block_time: 1_000_020, tx_hash: txId2, tx_index: 0 }, + { block_height: 102, block_time: 1_000_040, tx_hash: txId3, tx_index: 0 } + ]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response, [txId2]: mockedTx2Response, [txId3]: mockedTx3Response }, + txsUtxosResponse, + createCredentialHandler(credentialTxs) + ) + ); + + const result = await provider.transactionsByAddresses({ + addresses: [baseAddress1], + pagination: { limit: 20, order: 'desc', startAt: 0 } + }); + + // Should be sorted descending (newest first) + expect(result.pageResults.length).toBe(3); + expect(result.pageResults[0].id).toBe(txId3); + expect(result.pageResults[1].id).toBe(txId2); + expect(result.pageResults[2].id).toBe(txId1); + + // Verify block heights are actually in descending order + expect(result.pageResults[0].blockHeader.blockNo).toBeGreaterThan(result.pageResults[1].blockHeader.blockNo); + expect(result.pageResults[1].blockHeader.blockNo).toBeGreaterThan(result.pageResults[2].blockHeader.blockNo); + }); + + test('applies blockRange filters', async () => { + const credentialTxs = [ + { block_height: 50, block_time: 500_000, tx_hash: txId1, tx_index: 0 }, + { block_height: 101, block_time: 1_000_000, tx_hash: txId2, tx_index: 0 }, + { block_height: 150, block_time: 1_500_000, tx_hash: txId3, tx_index: 0 } + ]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response, [txId2]: mockedTx2Response, [txId3]: mockedTx3Response }, + txsUtxosResponse, + createCredentialHandler(credentialTxs) + ) + ); + + const result = await provider.transactionsByAddresses({ + addresses: [baseAddress1], + blockRange: { lowerBound: Cardano.BlockNo(75), upperBound: Cardano.BlockNo(125) }, + pagination: { limit: 20, startAt: 0 } + }); + + // Should only include txId2 (block_height: 101, within range 75-125) + // txId1 (block_height: 50) and txId3 (block_height: 150) should be filtered out + expect(result.pageResults.length).toBe(1); + expect(result.pageResults[0].id).toBe(txId2); + expect(result.pageResults[0].blockHeader.blockNo).toBe(Cardano.BlockNo(101)); + }); + + test('handles enterprise addresses (no stake credential)', async () => { + const credentialTxs = [{ block_height: 100, block_time: 1_000_000, tx_hash: txId1, tx_index: 0 }]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response }, + txsUtxosResponse, + createCredentialHandler(credentialTxs) + ) + ); + + await provider.transactionsByAddresses({ + addresses: [enterpriseAddress], + pagination: { limit: 20, startAt: 0 } + }); + + // Should query by payment credential (no stake account query) + const credentialCalls = request.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBeGreaterThan(0); + + // Should not query by stake account + const stakeCalls = request.mock.calls.filter( + (call: string[]) => call[0].includes(ACCOUNTS_PATH) && call[0].includes(TRANSACTIONS_PATH) + ); + expect(stakeCalls.length).toBe(0); + }); + + test('handles empty result sets', async () => { + request.mockImplementation(createMockRequestHandler({}, txsUtxosResponse)); + + const result = await provider.transactionsByAddresses({ + addresses: [baseAddress1], + pagination: { limit: 20, startAt: 0 } + }); + + expect(result.pageResults.length).toBe(0); + expect(result.totalResultCount).toBe(0); + }); + }); + }); }); From 5f5548f2ee75c0a5ed5d66ee9868073a2ac6f0c5 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Wed, 5 Nov 2025 10:55:29 +0100 Subject: [PATCH 05/10] feat(cardano-services-client): add queryUtxosByCredentials feature flag to BlockfrostUtxoProvider Add constructor overloads with queryUtxosByCredentials option (defaults to false). Maintains backward compatibility with existing constructor signature. Related to LW-13806 --- .../UtxoProvider/BlockfrostUtxoProvider.ts | 34 +++- .../test/Utxo/BlockfrostUtxoProvider.test.ts | 178 ++++++++++++------ 2 files changed, 150 insertions(+), 62 deletions(-) diff --git a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts index 76e1295a278..2d548ba924e 100644 --- a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts +++ b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts @@ -4,18 +4,42 @@ import { Logger } from 'ts-log'; import type { Cache } from '@cardano-sdk/util'; import type { Responses } from '@blockfrost/blockfrost-js'; -type BlockfrostUtxoProviderDependencies = { +interface BlockfrostUtxoProviderOptions { + queryUtxosByCredentials?: boolean; +} + +interface BlockfrostUtxoProviderDependencies { client: BlockfrostClient; cache: Cache; logger: Logger; -}; +} export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoProvider { private readonly cache: Cache; + // Feature flag to enable credential-based UTXO fetching (used in utxoByAddresses) + protected readonly queryUtxosByCredentials: boolean; + + // Overload 1: Old signature (backward compatibility) + constructor(dependencies: BlockfrostUtxoProviderDependencies); + + // Overload 2: New signature with options + constructor(options: BlockfrostUtxoProviderOptions, dependencies: BlockfrostUtxoProviderDependencies); + + // Implementation signature + constructor( + optionsOrDependencies: BlockfrostUtxoProviderOptions | BlockfrostUtxoProviderDependencies, + maybeDependencies?: BlockfrostUtxoProviderDependencies + ) { + // Detect which overload was used + const isOldSignature = 'cache' in optionsOrDependencies; + const options = isOldSignature ? {} : (optionsOrDependencies as BlockfrostUtxoProviderOptions); + const dependencies = isOldSignature + ? (optionsOrDependencies as BlockfrostUtxoProviderDependencies) + : maybeDependencies!; - constructor({ cache, client, logger }: BlockfrostUtxoProviderDependencies) { - super(client, logger); - this.cache = cache; + super(dependencies.client, dependencies.logger); + this.cache = dependencies.cache; + this.queryUtxosByCredentials = options.queryUtxosByCredentials ?? false; } protected async fetchUtxos(addr: Cardano.PaymentAddress, paginationQueryString: string): Promise { diff --git a/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts b/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts index 9aead010d87..66bf9deb698 100644 --- a/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts +++ b/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts @@ -53,76 +53,140 @@ describe('blockfrostUtxoProvider', () => { ); }); - describe('utxoByAddresses', () => { - test('used addresses', async () => { - mockResponses(request, [ - [`addresses/${address.toString()}/utxos?page=1&count=100`, generateUtxoResponseMock(100)], - [`addresses/${address.toString()}/utxos?page=2&count=100`, generateUtxoResponseMock(100)], - [`addresses/${address.toString()}/utxos?page=3&count=100`, generateUtxoResponseMock(0)] - ]); - const response = await provider.utxoByAddresses({ - addresses: [address] - }); + describe('with default behavior (queryUtxosByCredentials: false)', () => { + describe('utxoByAddresses', () => { + test('used addresses', async () => { + mockResponses(request, [ + [`addresses/${address.toString()}/utxos?page=1&count=100`, generateUtxoResponseMock(100)], + [`addresses/${address.toString()}/utxos?page=2&count=100`, generateUtxoResponseMock(100)], + [`addresses/${address.toString()}/utxos?page=3&count=100`, generateUtxoResponseMock(0)] + ]); + const response = await provider.utxoByAddresses({ + addresses: [address] + }); - expect(response).toBeTruthy(); - expect(response[0]).toHaveLength(2); - expect(response[0][0]).toMatchObject({ - index: 0, - txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') - }); - expect(response[0][1]).toMatchObject({ - address, - value: { - assets: new Map([ - [Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 1n] - ]), - coins: 50_928_877n - } - }); + expect(response).toBeTruthy(); + expect(response[0]).toHaveLength(2); + expect(response[0][0]).toMatchObject({ + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }); + expect(response[0][1]).toMatchObject({ + address, + value: { + assets: new Map([ + [Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 1n] + ]), + coins: 50_928_877n + } + }); - expect(response[1]).toHaveLength(2); - expect(response[1][0]).toMatchObject({ - index: 1, - txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + expect(response[1]).toHaveLength(2); + expect(response[1][0]).toMatchObject({ + index: 1, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }); + expect(response[1][1]).toMatchObject({ + address, + value: { + assets: new Map([ + [Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 2n] + ]), + coins: 50_928_878n + } + }); }); - expect(response[1][1]).toMatchObject({ - address, - value: { - assets: new Map([ - [Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 2n] - ]), - coins: 50_928_878n - } + + test('does not call txs/${hash}/cbor when data is cached', async () => { + const txHash = '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + + mockResponses(request, [ + [ + `txs/${txHash}/cbor`, + { + cbor: '84a5008182582038685cff32e65bbf5be53e5478b2c9f91c686a663394484df2205d4733d19ad501018182583900bdb17d476dfdaa0d06b970a19e260fc97b9622fa7b85fc51205a552b3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea041a062a3a78021a0002a019031a049dea66048183098200581c3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea048102a10082825820518ab6ccb82cf0db3893dc532af0eb27bdfd68d696811ff5acf16c47d4792ab3584018f37f5f94397c10ab44d6d382a0abc83807ab8039b8bff3b172b5875d928cec5022f02d027e4077253a79910126562988c306c34f14dca9d79be4e1c6de940f82582076f79de7b22ea72556735ba30b49a6b176e03641d088f43b64ec299400d971b7584046f4ce5ee2d14ab15c38aa74063bf25c9481ceaec59f9cf55bc25f6aae20a3c8761db8f7e9621c5f6b3f5628c574f738c3c997f83210effd5790d54419254a0ef5f6' + } + ], + [`addresses/${address.toString()}/utxos?page=1&count=100`, generateUtxoResponseMock(1)], + [`addresses/${address.toString()}/utxos?page=2&count=100`, generateUtxoResponseMock(0)] + ]); + + const firstResponse = await provider.utxoByAddresses({ addresses: [address] }); + + expect(firstResponse).toBeTruthy(); + expect(firstResponse.length).toBe(1); + + expect(request).toHaveBeenCalled(); + request.mockClear(); + + const secondResponse = await provider.utxoByAddresses({ addresses: [address] }); + + expect(secondResponse).toEqual(firstResponse); + + expect(request).not.toHaveBeenCalledWith(`txs/${txHash}/cbor`, undefined); }); }); + }); - test('does not call txs/${hash}/cbor when data is cached', async () => { - const txHash = '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + describe('constructor', () => { + it('accepts old signature (backward compatibility)', () => { + const client = { request: jest.fn() } as unknown as BlockfrostClient; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cache: Cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; - mockResponses(request, [ - [ - `txs/${txHash}/cbor`, - { - cbor: '84a5008182582038685cff32e65bbf5be53e5478b2c9f91c686a663394484df2205d4733d19ad501018182583900bdb17d476dfdaa0d06b970a19e260fc97b9622fa7b85fc51205a552b3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea041a062a3a78021a0002a019031a049dea66048183098200581c3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea048102a10082825820518ab6ccb82cf0db3893dc532af0eb27bdfd68d696811ff5acf16c47d4792ab3584018f37f5f94397c10ab44d6d382a0abc83807ab8039b8bff3b172b5875d928cec5022f02d027e4077253a79910126562988c306c34f14dca9d79be4e1c6de940f82582076f79de7b22ea72556735ba30b49a6b176e03641d088f43b64ec299400d971b7584046f4ce5ee2d14ab15c38aa74063bf25c9481ceaec59f9cf55bc25f6aae20a3c8761db8f7e9621c5f6b3f5628c574f738c3c997f83210effd5790d54419254a0ef5f6' - } - ], - [`addresses/${address.toString()}/utxos?page=1&count=100`, generateUtxoResponseMock(1)], - [`addresses/${address.toString()}/utxos?page=2&count=100`, generateUtxoResponseMock(0)] - ]); + const testProvider = new BlockfrostUtxoProvider({ cache, client, logger }); - const firstResponse = await provider.utxoByAddresses({ addresses: [address] }); + expect(testProvider).toBeInstanceOf(BlockfrostUtxoProvider); + // @ts-expect-error - accessing private field for testing + expect(testProvider.queryUtxosByCredentials).toBe(false); + }); - expect(firstResponse).toBeTruthy(); - expect(firstResponse.length).toBe(1); + it('accepts new signature with options (queryUtxosByCredentials: false)', () => { + const client = { request: jest.fn() } as unknown as BlockfrostClient; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cache: Cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; - expect(request).toHaveBeenCalled(); - request.mockClear(); + const testProvider = new BlockfrostUtxoProvider({ queryUtxosByCredentials: false }, { cache, client, logger }); - const secondResponse = await provider.utxoByAddresses({ addresses: [address] }); + expect(testProvider).toBeInstanceOf(BlockfrostUtxoProvider); + // @ts-expect-error - accessing private field for testing + expect(testProvider.queryUtxosByCredentials).toBe(false); + }); + + it('accepts new signature with options (queryUtxosByCredentials: true)', () => { + const client = { request: jest.fn() } as unknown as BlockfrostClient; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cache: Cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; - expect(secondResponse).toEqual(firstResponse); + const testProvider = new BlockfrostUtxoProvider({ queryUtxosByCredentials: true }, { cache, client, logger }); - expect(request).not.toHaveBeenCalledWith(`txs/${txHash}/cbor`, undefined); + expect(testProvider).toBeInstanceOf(BlockfrostUtxoProvider); + // @ts-expect-error - accessing private field for testing + expect(testProvider.queryUtxosByCredentials).toBe(true); }); }); }); From c27028b1c9f4789a38e3c9f7a32b0d2596c96c0a Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Wed, 5 Nov 2025 11:32:36 +0100 Subject: [PATCH 06/10] feat(cardano-services-client): add payment credential filtering utilities for UTXO queries Implement createPaymentCredentialFilter utility to support filtering UTXOs by payment credentials. This is needed for querying UTXOs by stake address (reward account) where we need to ensure we only include UTXOs where we control the payment credential. The filter uses a two-tier approach for optimal performance: - O(1) fast path for exact address matches using Set lookup - O(1) credential lookup for cross-address-type matching Includes comprehensive test coverage (21 tests) for all Cardano address types (Base, Enterprise, Pointer) and credential types (KeyHash, ScriptHash), plus edge cases (Byron addresses, empty input, duplicates, testnet). --- .../src/credentialUtils.ts | 88 +++++++ .../test/credentialUtils.test.ts | 237 +++++++++++++++++- 2 files changed, 324 insertions(+), 1 deletion(-) diff --git a/packages/cardano-services-client/src/credentialUtils.ts b/packages/cardano-services-client/src/credentialUtils.ts index b373c803a04..92ba9a43530 100644 --- a/packages/cardano-services-client/src/credentialUtils.ts +++ b/packages/cardano-services-client/src/credentialUtils.ts @@ -33,6 +33,94 @@ const parseAddress = (address: Cardano.PaymentAddress): Cardano.Address | null = } }; +/** Extracts payment credential from an address if available */ +const extractPaymentCredential = (address: Cardano.PaymentAddress): Cardano.PaymentCredential | null => { + const parsed = parseAddress(address); + if (!parsed) return null; + + // Byron addresses don't have payment credentials we can extract + if (parsed.asByron()) return null; + + // Try BaseAddress + const baseAddress = parsed.asBase(); + if (baseAddress) { + const credential = baseAddress.getPaymentCredential(); + return Cardano.PaymentCredential.fromCredential(credential); + } + + // Try EnterpriseAddress + const enterpriseAddress = parsed.asEnterprise(); + if (enterpriseAddress) { + const credential = enterpriseAddress.getPaymentCredential(); + return Cardano.PaymentCredential.fromCredential(credential); + } + + // Try PointerAddress + const pointerAddress = parsed.asPointer(); + if (pointerAddress) { + const credential = pointerAddress.getPaymentCredential(); + return Cardano.PaymentCredential.fromCredential(credential); + } + + return null; +}; + +/** + * Creates a filter predicate that checks if an address has a payment credential we control. + * Pure function that creates a filter based on controlled payment credentials. + * + * This filter is used to filter UTXOs returned from reward account (stake address) queries, + * ensuring we only include UTXOs where we control the payment credential. + * + * Critical assumption: All addresses provided as input are addresses where the caller + * controls the payment credential. This assumption is fundamental to the filtering logic. + * + * Performance optimization: The filter first checks for exact address matches (O(1) Set lookup) + * before attempting more expensive credential extraction and comparison. + * + * @param addresses Array of payment addresses whose payment credentials we control + * @returns Filter function that returns true if the address has a controlled payment credential + * @example + * ```typescript + * const myAddresses = ['addr1...', 'addr_test1...']; + * const filter = createPaymentCredentialFilter(myAddresses); + * + * // Filter UTXOs from a stake address query + * const filteredUtxos = allUtxos.filter(utxo => filter(utxo[0])); + * ``` + */ +export const createPaymentCredentialFilter = ( + addresses: Cardano.PaymentAddress[] +): ((address: Cardano.PaymentAddress) => boolean) => { + // Build Set of exact addresses for O(1) fast path lookup + const exactAddresses = new Set(addresses); + + // Extract all payment credentials from input addresses + const controlledCredentials = new Set(); + for (const address of addresses) { + const credential = extractPaymentCredential(address); + if (credential) { + controlledCredentials.add(credential); + } + } + + // Return filter function + return (address: Cardano.PaymentAddress): boolean => { + // Fast path: exact address match + if (exactAddresses.has(address)) { + return true; + } + + // Slow path: extract payment credential and check if controlled + const credential = extractPaymentCredential(address); + if (!credential) { + return false; + } + + return controlledCredentials.has(credential); + }; +}; + /** Adds a credential to the map, creating the array if needed */ const addToCredentialMap = ( map: Map, diff --git a/packages/cardano-services-client/test/credentialUtils.test.ts b/packages/cardano-services-client/test/credentialUtils.test.ts index 10ea8b57944..60451b67396 100644 --- a/packages/cardano-services-client/test/credentialUtils.test.ts +++ b/packages/cardano-services-client/test/credentialUtils.test.ts @@ -1,6 +1,6 @@ import { Cardano } from '@cardano-sdk/core'; import { cip19TestVectors } from '@cardano-sdk/util-dev'; -import { extractCredentials, minimizeCredentialSet } from '../src/credentialUtils'; +import { createPaymentCredentialFilter, extractCredentials, minimizeCredentialSet } from '../src/credentialUtils'; describe('credentialUtils', () => { describe('extractCredentials', () => { @@ -447,4 +447,239 @@ describe('credentialUtils', () => { expect(result.rewardAccounts.size).toBe(0); }); }); + + describe('createPaymentCredentialFilter', () => { + // Constants for commonly used test inputs + const BASE_KEY_STAKE_KEY_INPUT = [cip19TestVectors.basePaymentKeyStakeKey]; + const BASE_SCRIPT_STAKE_KEY_INPUT = [cip19TestVectors.basePaymentScriptStakeKey]; + const ENTERPRISE_KEY_INPUT = [cip19TestVectors.enterpriseKey]; + const ENTERPRISE_SCRIPT_INPUT = [cip19TestVectors.enterpriseScript]; + const POINTER_KEY_INPUT = [cip19TestVectors.pointerKey]; + const POINTER_SCRIPT_INPUT = [cip19TestVectors.pointerScript]; + + // Constants for test description strings + const WITH_KEY_HASH = 'with KeyHash payment credential'; + const WITH_SCRIPT_HASH = 'with ScriptHash payment credential'; + const EXACT_MATCH_TEST = 'passes filter for address with controlled payment credential (exact match)'; + + describe('BaseAddress filtering', () => { + describe(WITH_KEY_HASH, () => { + const filter = createPaymentCredentialFilter(BASE_KEY_STAKE_KEY_INPUT); + + it(EXACT_MATCH_TEST, () => { + // Same address should pass (fast path - exact match) + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + }); + + it('passes filter for address with same payment credential but different stake credential', () => { + // Different address with same payment credential should pass + // basePaymentKeyStakeKey has payment key hash, basePaymentKeyStakeScript has same payment key hash but different stake + expect(filter(cip19TestVectors.basePaymentKeyStakeScript)).toBe(true); + }); + + it('rejects addresses with uncontrolled payment credentials', () => { + // basePaymentScriptStakeKey has different payment credential (script vs key hash) + expect(filter(cip19TestVectors.basePaymentScriptStakeKey)).toBe(false); + // Address with same stake credential but different payment credential should also be rejected + expect(filter(cip19TestVectors.basePaymentScriptStakeScript)).toBe(false); + }); + }); + + describe(WITH_SCRIPT_HASH, () => { + const filter = createPaymentCredentialFilter(BASE_SCRIPT_STAKE_KEY_INPUT); + + it('passes filter for addresses with same payment credential', () => { + // Same payment credential (script) should pass + expect(filter(cip19TestVectors.basePaymentScriptStakeKey)).toBe(true); + // Different stake but same payment credential (script) should pass + expect(filter(cip19TestVectors.basePaymentScriptStakeScript)).toBe(true); + }); + }); + }); + + describe('EnterpriseAddress filtering', () => { + describe(WITH_KEY_HASH, () => { + const filter = createPaymentCredentialFilter(ENTERPRISE_KEY_INPUT); + + it(EXACT_MATCH_TEST, () => { + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + }); + + it('rejects address with uncontrolled payment credential', () => { + // enterpriseScript has different payment credential + expect(filter(cip19TestVectors.enterpriseScript)).toBe(false); + }); + }); + + describe(WITH_SCRIPT_HASH, () => { + const filter = createPaymentCredentialFilter(ENTERPRISE_SCRIPT_INPUT); + + it('passes filter for script payment credential', () => { + expect(filter(cip19TestVectors.enterpriseScript)).toBe(true); + }); + }); + + it('passes filter when EnterpriseAddress matches payment credential from BaseAddress', () => { + const filter = createPaymentCredentialFilter(BASE_KEY_STAKE_KEY_INPUT); + + // enterpriseKey should have the same payment credential as basePaymentKeyStakeKey + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + }); + }); + + describe('PointerAddress filtering', () => { + describe(WITH_KEY_HASH, () => { + const filter = createPaymentCredentialFilter(POINTER_KEY_INPUT); + + it(EXACT_MATCH_TEST, () => { + expect(filter(cip19TestVectors.pointerKey)).toBe(true); + }); + + it('rejects address with uncontrolled payment credential', () => { + // pointerScript has different payment credential + expect(filter(cip19TestVectors.pointerScript)).toBe(false); + }); + }); + + describe(WITH_SCRIPT_HASH, () => { + const filter = createPaymentCredentialFilter(POINTER_SCRIPT_INPUT); + + it('passes filter for script payment credential', () => { + expect(filter(cip19TestVectors.pointerScript)).toBe(true); + }); + }); + + it('passes filter when PointerAddress matches payment credential from BaseAddress', () => { + const filter = createPaymentCredentialFilter(BASE_KEY_STAKE_KEY_INPUT); + + // pointerKey should have the same payment credential as basePaymentKeyStakeKey + expect(filter(cip19TestVectors.pointerKey)).toBe(true); + }); + }); + + describe('Mixed credential types', () => { + it('correctly filters KeyHash payment credentials', () => { + const inputAddresses = [ + cip19TestVectors.basePaymentKeyStakeKey, + cip19TestVectors.enterpriseKey, + cip19TestVectors.pointerKey + ]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // All addresses with same key hash should pass + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.basePaymentKeyStakeScript)).toBe(true); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + expect(filter(cip19TestVectors.pointerKey)).toBe(true); + + // Addresses with script hash should not pass + expect(filter(cip19TestVectors.basePaymentScriptStakeKey)).toBe(false); + expect(filter(cip19TestVectors.enterpriseScript)).toBe(false); + expect(filter(cip19TestVectors.pointerScript)).toBe(false); + }); + + it('correctly filters ScriptHash payment credentials', () => { + const inputAddresses = [ + cip19TestVectors.basePaymentScriptStakeKey, + cip19TestVectors.enterpriseScript, + cip19TestVectors.pointerScript + ]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // All addresses with same script hash should pass + expect(filter(cip19TestVectors.basePaymentScriptStakeKey)).toBe(true); + expect(filter(cip19TestVectors.basePaymentScriptStakeScript)).toBe(true); + expect(filter(cip19TestVectors.enterpriseScript)).toBe(true); + expect(filter(cip19TestVectors.pointerScript)).toBe(true); + + // Addresses with key hash should not pass + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(false); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(false); + expect(filter(cip19TestVectors.pointerKey)).toBe(false); + }); + + it('correctly filters mixed KeyHash and ScriptHash credentials', () => { + const inputAddresses = [ + cip19TestVectors.basePaymentKeyStakeKey, // key hash payment + cip19TestVectors.basePaymentScriptStakeKey // script hash payment + ]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // Addresses with either controlled payment credential should pass + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + expect(filter(cip19TestVectors.basePaymentScriptStakeKey)).toBe(true); + expect(filter(cip19TestVectors.enterpriseScript)).toBe(true); + }); + }); + + describe('Edge cases', () => { + it('rejects all addresses when input is empty', () => { + const filter = createPaymentCredentialFilter([]); + + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(false); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(false); + expect(filter(cip19TestVectors.pointerKey)).toBe(false); + }); + + it('skips Byron addresses in filter input (cannot extract payment credential)', () => { + const inputAddresses = [cip19TestVectors.byronMainnetYoroi, cip19TestVectors.basePaymentKeyStakeKey]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // Byron address itself should pass (exact match) + expect(filter(cip19TestVectors.byronMainnetYoroi)).toBe(true); + // Other addresses with key hash should still work + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + }); + + it('rejects Byron address when checking if it matches payment credentials', () => { + const inputAddresses = [cip19TestVectors.basePaymentKeyStakeKey]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // Byron address should not pass (cannot extract payment credential, no exact match) + expect(filter(cip19TestVectors.byronMainnetYoroi)).toBe(false); + }); + + it('handles duplicate addresses in input', () => { + const inputAddresses = [ + cip19TestVectors.basePaymentKeyStakeKey, + cip19TestVectors.basePaymentKeyStakeKey, + cip19TestVectors.enterpriseKey + ]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // Should work correctly despite duplicates + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + }); + + it('handles testnet addresses correctly', () => { + const inputAddresses = [cip19TestVectors.testnetBasePaymentKeyStakeKey]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // Same payment credential on testnet should pass + expect(filter(cip19TestVectors.testnetBasePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.testnetEnterpriseKey)).toBe(true); + expect(filter(cip19TestVectors.testnetPointerKey)).toBe(true); + + // Different payment credential should not pass + expect(filter(cip19TestVectors.testnetBasePaymentScriptStakeKey)).toBe(false); + }); + }); + + describe('Performance optimization - exact address match', () => { + it('uses fast path for exact address matches', () => { + const inputAddresses = [cip19TestVectors.basePaymentKeyStakeKey, cip19TestVectors.enterpriseKey]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // These should use the fast path (exact match in Set) + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + + // This should use the credential extraction path (not in exact match Set) + expect(filter(cip19TestVectors.basePaymentKeyStakeScript)).toBe(true); + }); + }); + }); }); From eb6aff587c54783f9229a289d087b8fb2a642783 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Wed, 5 Nov 2025 12:22:03 +0100 Subject: [PATCH 07/10] feat(cardano-services-client): implement credential-based UTXO queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../UtxoProvider/BlockfrostUtxoProvider.ts | 202 +++++++++++++- .../test/Utxo/BlockfrostUtxoProvider.test.ts | 264 +++++++++++++++++- 2 files changed, 457 insertions(+), 9 deletions(-) diff --git a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts index 2d548ba924e..3820328bfbf 100644 --- a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts +++ b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts @@ -1,6 +1,8 @@ import { BlockfrostClient, BlockfrostProvider, BlockfrostToCore, fetchSequentially } from '../blockfrost'; import { Cardano, Serialization, UtxoByAddressesArgs, UtxoProvider } from '@cardano-sdk/core'; import { Logger } from 'ts-log'; +import { createPaymentCredentialFilter, extractCredentials, minimizeCredentialSet } from '../credentialUtils'; +import uniqBy from 'lodash/uniqBy.js'; import type { Cache } from '@cardano-sdk/util'; import type { Responses } from '@blockfrost/blockfrost-js'; @@ -55,6 +57,182 @@ export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoPr return Promise.all(utxoPromises); } + private async processUtxoContents(utxoContents: Responses['address_utxo_content']): Promise { + const utxoPromises = utxoContents.map((utxo) => + this.fetchDetailsFromCBOR(utxo.tx_hash).then((tx) => { + const txOut = tx ? tx.body.outputs.find((output) => output.address === utxo.address) : undefined; + return BlockfrostToCore.addressUtxoContent(utxo.address, utxo, txOut); + }) + ); + return Promise.all(utxoPromises); + } + + protected async fetchUtxosByPaymentCredential(credential: Cardano.PaymentCredential): Promise { + const utxoContents = await fetchSequentially< + Responses['address_utxo_content'][0], + Responses['address_utxo_content'][0] + >({ + haveEnoughItems: () => false, // Fetch all pages + request: async (paginationQueryString) => { + const queryString = `addresses/${credential}/utxos?${paginationQueryString}`; + return this.request(queryString); + } + }); + + return this.processUtxoContents(utxoContents); + } + + protected async fetchUtxosByRewardAccount( + rewardAccount: Cardano.RewardAccount, + paymentCredentialFilter: (address: Cardano.PaymentAddress) => boolean + ): Promise { + const utxoContents = await fetchSequentially< + Responses['address_utxo_content'][0], + Responses['address_utxo_content'][0] + >({ + haveEnoughItems: () => false, // Fetch all pages + request: async (paginationQueryString) => { + const queryString = `accounts/${rewardAccount}/utxos?${paginationQueryString}`; + return this.request(queryString); + } + }); + + // Filter UTXOs by payment credential before processing + const filteredUtxos = utxoContents.filter((utxo) => paymentCredentialFilter(Cardano.PaymentAddress(utxo.address))); + + // Log debug message about filtering + if (filteredUtxos.length < utxoContents.length) { + this.logger.debug( + `Filtered ${utxoContents.length - filteredUtxos.length} UTXO(s) from reward account query, kept ${ + filteredUtxos.length + }` + ); + } + + return this.processUtxoContents(filteredUtxos); + } + + protected mergeAndDeduplicateUtxos( + paymentUtxos: Cardano.Utxo[], + rewardAccountUtxos: Cardano.Utxo[], + skippedAddressUtxos: Cardano.Utxo[] + ): Cardano.Utxo[] { + const allUtxos = [...paymentUtxos, ...rewardAccountUtxos, ...skippedAddressUtxos]; + + // Deduplicate by txId + index combination + const deduplicated = uniqBy(allUtxos, (utxo: Cardano.Utxo) => `${utxo[0].txId}#${utxo[0].index}`); + + // Sort by txId and index for deterministic ordering + return deduplicated.sort((a, b) => { + const txIdCompare = a[0].txId.localeCompare(b[0].txId); + if (txIdCompare !== 0) return txIdCompare; + return a[0].index - b[0].index; + }); + } + + private logSkippedAddresses(skippedAddresses: { + byron: Cardano.PaymentAddress[]; + pointer: Cardano.PaymentAddress[]; + }): void { + if (skippedAddresses.byron.length > 0) { + this.logger.info( + `Found ${skippedAddresses.byron.length} Byron address(es), falling back to per-address fetching` + ); + } + if (skippedAddresses.pointer.length > 0) { + this.logger.info( + `Found ${skippedAddresses.pointer.length} Pointer address(es), falling back to per-address fetching` + ); + } + } + + private logMinimizationStats( + totalAddresses: number, + minimized: { paymentCredentials: Map; rewardAccounts: Map }, + skippedAddresses: { byron: Cardano.PaymentAddress[]; pointer: Cardano.PaymentAddress[] } + ): void { + const paymentCredCount = minimized.paymentCredentials.size; + const rewardAccountCount = minimized.rewardAccounts.size; + const skippedCount = skippedAddresses.byron.length + skippedAddresses.pointer.length; + const totalQueries = paymentCredCount + rewardAccountCount + skippedCount; + + this.logger.debug( + `Minimized ${totalAddresses} address(es) to ${totalQueries} query/queries: ` + + `${paymentCredCount} payment credential(s), ${rewardAccountCount} reward account(s), ${skippedCount} skipped address(es)` + ); + } + + private async fetchAllByPaymentCredentials( + credentials: Map + ): Promise { + const results = await Promise.all( + [...credentials.keys()].map((credential) => this.fetchUtxosByPaymentCredential(credential)) + ); + return results.flat(); + } + + private async fetchAllByRewardAccounts( + rewardAccounts: Map, + paymentCredentialFilter: (address: Cardano.PaymentAddress) => boolean + ): Promise { + const results = await Promise.all( + [...rewardAccounts.keys()].map((rewardAccount) => + this.fetchUtxosByRewardAccount(rewardAccount, paymentCredentialFilter) + ) + ); + return results.flat(); + } + + private async fetchUtxosForAddresses(addresses: Cardano.PaymentAddress[]): Promise { + const results = await Promise.all( + addresses.map((address) => + fetchSequentially({ + request: async (paginationQueryString) => await this.fetchUtxos(address, paginationQueryString) + }) + ) + ); + return results.flat(); + } + + private async fetchSkippedAddresses(skippedAddresses: { + byron: Cardano.PaymentAddress[]; + pointer: Cardano.PaymentAddress[]; + }): Promise { + const allSkippedAddresses = [...skippedAddresses.byron, ...skippedAddresses.pointer]; + return this.fetchUtxosForAddresses(allSkippedAddresses); + } + + private async fetchUtxosByCredentials(addresses: Cardano.PaymentAddress[]): Promise { + const addressGroups = extractCredentials(addresses); + + this.logSkippedAddresses(addressGroups.skippedAddresses); + + const minimized = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + this.logMinimizationStats(addresses.length, minimized, addressGroups.skippedAddresses); + + const paymentCredentialFilter = createPaymentCredentialFilter(addresses); + + this.logger.debug( + `Fetching UTXOs for ${minimized.paymentCredentials.size} payment credential(s) and ${minimized.rewardAccounts.size} reward account(s)` + ); + + const [paymentUtxos, rewardAccountUtxos, skippedAddressUtxos] = await Promise.all([ + this.fetchAllByPaymentCredentials(minimized.paymentCredentials), + this.fetchAllByRewardAccounts(minimized.rewardAccounts, paymentCredentialFilter), + this.fetchSkippedAddresses(addressGroups.skippedAddresses) + ]); + + const result = this.mergeAndDeduplicateUtxos(paymentUtxos, rewardAccountUtxos, skippedAddressUtxos); + + this.logger.debug(`Merged results: ${result.length} UTXO(s)`); + + return result; + } + async fetchCBOR(hash: string): Promise { return this.request(`txs/${hash}/cbor`) .then((response) => { @@ -88,16 +266,24 @@ export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoPr return result; } + /** + * Retrieves UTXOs for the given addresses. + * + * Important assumption: All addresses provided must be addresses where the caller + * controls the payment credential. When queryUtxosByCredentials is enabled, this + * provider queries by reward accounts (stake addresses) and filters results to only + * include UTXOs with payment credentials extracted from the input addresses. UTXOs + * with payment credentials not present in the input will be excluded. + */ public async utxoByAddresses({ addresses }: UtxoByAddressesArgs): Promise { try { - const utxoResults = await Promise.all( - addresses.map(async (address) => - fetchSequentially({ - request: async (paginationQueryString) => await this.fetchUtxos(address, paginationQueryString) - }) - ) - ); - return utxoResults.flat(1); + // If feature flag is disabled, use original implementation + if (!this.queryUtxosByCredentials) { + return this.fetchUtxosForAddresses(addresses); + } + + // Use credential-based fetching + return await this.fetchUtxosByCredentials(addresses); } catch (error) { throw this.toProviderError(error); } diff --git a/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts b/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts index 66bf9deb698..7ecad7facf2 100644 --- a/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts +++ b/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts @@ -1,11 +1,59 @@ import { BlockfrostClient, BlockfrostUtxoProvider } from '../../src'; import { Cardano } from '@cardano-sdk/core'; import { Responses } from '@blockfrost/blockfrost-js'; -import { logger } from '@cardano-sdk/util-dev'; +import { cip19TestVectors, logger } from '@cardano-sdk/util-dev'; import { mockResponses } from '../util'; import type { Cache } from '@cardano-sdk/util'; jest.mock('@blockfrost/blockfrost-js'); +// Test constants +const ADDR_VKH_PREFIX = 'addresses/addr_vkh'; +const SCRIPT_PREFIX = 'addresses/script'; +const ACCOUNTS_PATH = 'accounts/'; +const UTXOS_PATH = '/utxos'; +const CBOR_PATH = '/cbor'; + +const CBOR_RESPONSE = { + cbor: '84a5008182582038685cff32e65bbf5be53e5478b2c9f91c686a663394484df2205d4733d19ad501018182583900bdb17d476dfdaa0d06b970a19e260fc97b9622fa7b85fc51205a552b3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea041a062a3a78021a0002a019031a049dea66048183098200581c3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea048102a10082825820518ab6ccb82cf0db3893dc532af0eb27bdfd68d696811ff5acf16c47d4792ab3584018f37f5f94397c10ab44d6d382a0abc83807ab8039b8bff3b172b5875d928cec5022f02d027e4077253a79910126562988c306c34f14dca9d79be4e1c6de940f82582076f79de7b22ea72556735ba30b49a6b176e03641d088f43b64ec299400d971b7584046f4ce5ee2d14ab15c38aa74063bf25c9481ceaec59f9cf55bc25f6aae20a3c8761db8f7e9621c5f6b3f5628c574f738c3c997f83210effd5790d54419254a0ef5f6' +}; + +/** Helper to create handlers for credential-based UTXO queries. */ +const createCredentialUtxoHandler = (credentialUtxos: Responses['address_utxo_content']) => (url: string) => { + if ((url.includes(ADDR_VKH_PREFIX) || url.includes(SCRIPT_PREFIX)) && url.includes(UTXOS_PATH)) { + return Promise.resolve(credentialUtxos); + } + return null; +}; + +/** Helper to create handlers for account-based UTXO queries. */ +const createAccountUtxoHandler = (accountUtxos: Responses['address_utxo_content']) => (url: string) => { + if (url.includes(ACCOUNTS_PATH) && url.includes(UTXOS_PATH)) { + return Promise.resolve(accountUtxos); + } + return null; +}; + +/** Helper to create a common mock request handler for UTXO tests. */ +const createMockUtxoHandler = + (additionalHandlers?: (url: string) => Promise | null) => + (url: string): Promise => { + // Handle CBOR endpoints + if (url.includes(CBOR_PATH)) { + return Promise.resolve(CBOR_RESPONSE); + } + + // Handle additional custom handlers + if (additionalHandlers) { + const result = additionalHandlers(url); + if (result !== null) return result; + } + + // Default: return empty for UTXO queries + if (url.includes(UTXOS_PATH)) return Promise.resolve([]); + + return Promise.resolve([]); + }; + const generateUtxoResponseMock = (qty: number) => [...Array.from({ length: qty }).keys()].map((num) => ({ amount: [ @@ -189,4 +237,218 @@ describe('blockfrostUtxoProvider', () => { expect(testProvider.queryUtxosByCredentials).toBe(true); }); }); + + describe('with feature flag ON (queryUtxosByCredentials: true)', () => { + let mockRequest: jest.Mock; + let credentialProvider: BlockfrostUtxoProvider; + let credentialCache: Cache; + + // Use real test addresses from CIP-19 test vectors + const baseAddress1 = cip19TestVectors.basePaymentKeyStakeKey; // Base address with payment key + stake key + const baseAddress2 = cip19TestVectors.basePaymentScriptStakeKey; // Base address with script + stake key + const enterpriseAddress = cip19TestVectors.enterpriseKey; // Enterprise address (no stake) + + const generateUtxoForAddress = (addr: Cardano.PaymentAddress, index: number) => ({ + address: addr, + amount: [ + { + quantity: String(50_000_000 + index), + unit: 'lovelace' + } + ], + block: 'b1b23210b9de8f3edef233f21f7d6e1fb93efe124ba126ba924edec3043e75b46', + data_hash: null, + inline_datum: null, + output_index: index, + reference_script_hash: null, + tx_hash: '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5', + tx_index: index + }); + + beforeEach(() => { + mockRequest = jest.fn(); + const client = { request: mockRequest } as unknown as BlockfrostClient; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + credentialCache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + + credentialProvider = new BlockfrostUtxoProvider( + { queryUtxosByCredentials: true }, + { cache: credentialCache, client, logger } + ); + }); + + describe('utxoByAddresses with credential-based queries', () => { + it('single address is minimized to payment credential (1 API call)', async () => { + const credentialUtxos = [generateUtxoForAddress(baseAddress1, 0), generateUtxoForAddress(baseAddress1, 1)]; + + mockRequest.mockImplementation(createMockUtxoHandler(createCredentialUtxoHandler(credentialUtxos))); + + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1] }); + + // Verify we queried by payment credential, not by address + const credentialCalls = mockRequest.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBeGreaterThan(0); + + // Should not query by address directly + const addressCalls = mockRequest.mock.calls.filter((call: string[]) => + call[0].includes(`addresses/${baseAddress1}${UTXOS_PATH}`) + ); + expect(addressCalls.length).toBe(0); + + // Should return 2 UTXOs + expect(result).toHaveLength(2); + }); + + it('multiple addresses with shared stake key use reward account query', async () => { + // Both addresses share the same stake key + const rewardAccountUtxos = [generateUtxoForAddress(baseAddress1, 0), generateUtxoForAddress(baseAddress1, 1)]; + + mockRequest.mockImplementation(createMockUtxoHandler(createAccountUtxoHandler(rewardAccountUtxos))); + + // Query with two addresses that share the same stake key + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1, baseAddress2] }); + + // Should query by reward account (stake address) + const rewardAccountCalls = mockRequest.mock.calls.filter( + (call: string[]) => call[0].includes(ACCOUNTS_PATH) && call[0].includes(UTXOS_PATH) + ); + expect(rewardAccountCalls.length).toBeGreaterThan(0); + + // Should not query by payment credentials (minimization prefers reward account) + const credentialCalls = mockRequest.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBe(0); + + // Should return filtered UTXOs (only those with payment credentials we control) + expect(result.length).toBeGreaterThan(0); + }); + + it('filters UTXOs by payment credential in reward account queries', async () => { + // When querying with multiple addresses that share a stake key, + // reward account query returns UTXOs for ALL addresses under that stake key + // We need to filter to only include UTXOs for addresses we queried + const otherAddress = cip19TestVectors.basePaymentScriptStakeKey; // Shares stake key but different payment + const rewardAccountUtxos = [ + generateUtxoForAddress(baseAddress1, 0), + generateUtxoForAddress(baseAddress1, 1), + generateUtxoForAddress(otherAddress, 2) // This should be filtered out (different payment cred) + ]; + + mockRequest.mockImplementation(createMockUtxoHandler(createAccountUtxoHandler(rewardAccountUtxos))); + + // Query with BOTH addresses that share the stake key + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1, baseAddress2] }); + + // Should use reward account query (minimization algorithm) + const rewardAccountCalls = mockRequest.mock.calls.filter( + (call: string[]) => call[0].includes(ACCOUNTS_PATH) && call[0].includes(UTXOS_PATH) + ); + expect(rewardAccountCalls.length).toBeGreaterThan(0); + + // Should return filtered UTXOs (filtering done automatically by payment credential filter) + expect(result.length).toBeGreaterThan(0); + }); + + it('enterprise address uses payment credential query', async () => { + const credentialUtxos = [generateUtxoForAddress(enterpriseAddress, 0)]; + + mockRequest.mockImplementation(createMockUtxoHandler(createCredentialUtxoHandler(credentialUtxos))); + + const result = await credentialProvider.utxoByAddresses({ addresses: [enterpriseAddress] }); + + // Should query by payment credential + const credentialCalls = mockRequest.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBeGreaterThan(0); + + // Should not query by reward account + const rewardAccountCalls = mockRequest.mock.calls.filter((call: string[]) => call[0].includes(ACCOUNTS_PATH)); + expect(rewardAccountCalls.length).toBe(0); + + expect(result).toHaveLength(1); + }); + + it('deduplicates UTXOs correctly', async () => { + const credentialUtxos = [ + generateUtxoForAddress(baseAddress1, 0), + generateUtxoForAddress(baseAddress1, 0), // Duplicate + generateUtxoForAddress(baseAddress1, 1) + ]; + + mockRequest.mockImplementation(createMockUtxoHandler(createCredentialUtxoHandler(credentialUtxos))); + + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1] }); + + // Should deduplicate to 2 unique UTXOs + expect(result).toHaveLength(2); + }); + + it('sorts UTXOs by txId and index', async () => { + const utxo1 = generateUtxoForAddress(baseAddress1, 0); + utxo1.tx_hash = '1f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + const utxo2 = generateUtxoForAddress(baseAddress1, 1); + utxo2.tx_hash = '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + const utxo3 = generateUtxoForAddress(baseAddress1, 0); + utxo3.tx_hash = '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + + const credentialUtxos = [utxo1, utxo2, utxo3]; + + mockRequest.mockImplementation(createMockUtxoHandler(createCredentialUtxoHandler(credentialUtxos))); + + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1] }); + + expect(result).toHaveLength(3); + // Should be sorted by txId first, then by index + expect(result[0][0].txId).toBe(Cardano.TransactionId(utxo3.tx_hash)); + expect(result[0][0].index).toBe(0); + expect(result[1][0].txId).toBe(Cardano.TransactionId(utxo2.tx_hash)); + expect(result[1][0].index).toBe(1); + expect(result[2][0].txId).toBe(Cardano.TransactionId(utxo1.tx_hash)); + expect(result[2][0].index).toBe(0); + }); + + it('handles empty results', async () => { + mockRequest.mockImplementation(createMockUtxoHandler(createCredentialUtxoHandler([]))); + + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1] }); + + expect(result).toHaveLength(0); + }); + + it('handles mixed base and enterprise addresses', async () => { + // Note: baseAddress1 and enterpriseAddress share the same payment credential + // so the minimization algorithm will use only 1 payment credential query + const allUtxos = [generateUtxoForAddress(baseAddress1, 0), generateUtxoForAddress(enterpriseAddress, 1)]; + + let paymentCredentialCallCount = 0; + + mockRequest.mockImplementation((url: string) => { + if (url.includes(ADDR_VKH_PREFIX) && url.includes(UTXOS_PATH)) { + paymentCredentialCallCount++; + // Return UTXOs for both addresses since they share the same payment credential + return Promise.resolve(allUtxos); + } + if (url.includes(CBOR_PATH)) { + return Promise.resolve(CBOR_RESPONSE); + } + return Promise.resolve([]); + }); + + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1, enterpriseAddress] }); + + // Both addresses share the same payment credential, + // so only 1 payment credential query is needed (optimal minimization) + expect(paymentCredentialCallCount).toBe(1); + + // Should return UTXOs from both addresses + expect(result).toHaveLength(2); + }); + }); + }); }); From 3375457b0f384e2c94cfce3b89c50bfa43e1da87 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Wed, 5 Nov 2025 14:15:42 +0100 Subject: [PATCH 08/10] refactor: move UTXO sorting utilities from input-selection to core Move sortTxIn and sortUtxoByTxIn from input-selection to core package to make them available for other packages without creating circular dependencies. Changes: - Add sortTxIn() and sortUtxoByTxIn() to core/src/util/utxo.ts - Add unit tests for sorting utilities in core (11 test cases) - Update input-selection to import and re-export from core - Update BlockfrostUtxoProvider to use sortUtxoByTxIn from core This allows cardano-services-client to use UTXO sorting without depending on input-selection, which is a higher-level package. --- .../UtxoProvider/BlockfrostUtxoProvider.ts | 10 +- packages/core/src/util/utxo.ts | 23 +++ packages/core/test/util/utxo.test.ts | 190 ++++++++++++++++++ packages/input-selection/src/util.ts | 24 +-- 4 files changed, 219 insertions(+), 28 deletions(-) create mode 100644 packages/core/test/util/utxo.test.ts diff --git a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts index 3820328bfbf..28e697147db 100644 --- a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts +++ b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts @@ -1,5 +1,5 @@ import { BlockfrostClient, BlockfrostProvider, BlockfrostToCore, fetchSequentially } from '../blockfrost'; -import { Cardano, Serialization, UtxoByAddressesArgs, UtxoProvider } from '@cardano-sdk/core'; +import { Cardano, Serialization, UtxoByAddressesArgs, UtxoProvider, sortUtxoByTxIn } from '@cardano-sdk/core'; import { Logger } from 'ts-log'; import { createPaymentCredentialFilter, extractCredentials, minimizeCredentialSet } from '../credentialUtils'; import uniqBy from 'lodash/uniqBy.js'; @@ -122,12 +122,8 @@ export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoPr // Deduplicate by txId + index combination const deduplicated = uniqBy(allUtxos, (utxo: Cardano.Utxo) => `${utxo[0].txId}#${utxo[0].index}`); - // Sort by txId and index for deterministic ordering - return deduplicated.sort((a, b) => { - const txIdCompare = a[0].txId.localeCompare(b[0].txId); - if (txIdCompare !== 0) return txIdCompare; - return a[0].index - b[0].index; - }); + // Sort using sortUtxoByTxIn from core + return deduplicated.sort(sortUtxoByTxIn); } private logSkippedAddresses(skippedAddresses: { diff --git a/packages/core/src/util/utxo.ts b/packages/core/src/util/utxo.ts index a7ef2732975..1a272254f56 100644 --- a/packages/core/src/util/utxo.ts +++ b/packages/core/src/util/utxo.ts @@ -1 +1,24 @@ +import type { TxIn, Utxo } from '../Cardano'; + export const createUtxoId = (txHash: string, index: number) => `${txHash}:${index}`; + +/** + * Sorts the given TxIn set first by txId and then by index. + * + * @param lhs The left-hand side of the comparison operation. + * @param rhs The right-hand side of the comparison operation. + */ +export const sortTxIn = (lhs: TxIn, rhs: TxIn) => { + const txIdComparison = lhs.txId.localeCompare(rhs.txId); + if (txIdComparison !== 0) return txIdComparison; + + return lhs.index - rhs.index; +}; + +/** + * Sorts the given Utxo set first by TxIn. + * + * @param lhs The left-hand side of the comparison operation. + * @param rhs The right-hand side of the comparison operation. + */ +export const sortUtxoByTxIn = (lhs: Utxo, rhs: Utxo) => sortTxIn(lhs[0], rhs[0]); diff --git a/packages/core/test/util/utxo.test.ts b/packages/core/test/util/utxo.test.ts new file mode 100644 index 00000000000..9f5d93c9f58 --- /dev/null +++ b/packages/core/test/util/utxo.test.ts @@ -0,0 +1,190 @@ +import { Cardano, createUtxoId, sortTxIn, sortUtxoByTxIn } from '../../src'; + +describe('util/utxo', () => { + describe('createUtxoId', () => { + it('creates a UTXO ID from hash and index', () => { + const txHash = '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + const index = 0; + + expect(createUtxoId(txHash, index)).toBe('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5:0'); + }); + + it('handles different indices', () => { + const txHash = '1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'; + + expect(createUtxoId(txHash, 0)).toBe('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477:0'); + expect(createUtxoId(txHash, 1)).toBe('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477:1'); + expect(createUtxoId(txHash, 99)).toBe('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477:99'); + }); + }); + + describe('sortTxIn', () => { + it('sorts by txId first (ascending)', () => { + const txIn1: Cardano.TxIn = { + index: 0, + txId: Cardano.TransactionId('1f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }; + const txIn2: Cardano.TxIn = { + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }; + + expect(sortTxIn(txIn1, txIn2)).toBeGreaterThan(0); + expect(sortTxIn(txIn2, txIn1)).toBeLessThan(0); + }); + + it('sorts by index when txId is the same', () => { + const txId = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const txIn1: Cardano.TxIn = { index: 1, txId }; + const txIn2: Cardano.TxIn = { index: 0, txId }; + const txIn3: Cardano.TxIn = { index: 2, txId }; + + expect(sortTxIn(txIn1, txIn2)).toBeGreaterThan(0); + expect(sortTxIn(txIn2, txIn1)).toBeLessThan(0); + expect(sortTxIn(txIn3, txIn1)).toBeGreaterThan(0); + }); + + it('returns 0 for identical TxIn', () => { + const txId = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const txIn1: Cardano.TxIn = { index: 0, txId }; + const txIn2: Cardano.TxIn = { index: 0, txId }; + + expect(sortTxIn(txIn1, txIn2)).toBe(0); + }); + + it('can be used with Array.sort to sort TxIn array', () => { + const txId1 = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const txId2 = Cardano.TransactionId('1f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + + const inputs: Cardano.TxIn[] = [ + { index: 1, txId: txId1 }, + { index: 0, txId: txId2 }, + { index: 0, txId: txId1 }, + { index: 2, txId: txId1 } + ]; + + inputs.sort(sortTxIn); + + expect(inputs[0]).toEqual({ index: 0, txId: txId1 }); + expect(inputs[1]).toEqual({ index: 1, txId: txId1 }); + expect(inputs[2]).toEqual({ index: 2, txId: txId1 }); + expect(inputs[3]).toEqual({ index: 0, txId: txId2 }); + }); + }); + + describe('sortUtxoByTxIn', () => { + const address = Cardano.PaymentAddress( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ); + + it('sorts UTXOs by txId first (ascending)', () => { + const utxo1: Cardano.Utxo = [ + { + address, + index: 0, + txId: Cardano.TransactionId('1f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }, + { address, value: { coins: 1_000_000n } } + ]; + const utxo2: Cardano.Utxo = [ + { + address, + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }, + { address, value: { coins: 2_000_000n } } + ]; + + expect(sortUtxoByTxIn(utxo1, utxo2)).toBeGreaterThan(0); + expect(sortUtxoByTxIn(utxo2, utxo1)).toBeLessThan(0); + }); + + it('sorts UTXOs by index when txId is the same', () => { + const txId = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const utxo1: Cardano.Utxo = [ + { address, index: 1, txId }, + { address, value: { coins: 1_000_000n } } + ]; + const utxo2: Cardano.Utxo = [ + { address, index: 0, txId }, + { address, value: { coins: 2_000_000n } } + ]; + const utxo3: Cardano.Utxo = [ + { address, index: 2, txId }, + { address, value: { coins: 3_000_000n } } + ]; + + expect(sortUtxoByTxIn(utxo1, utxo2)).toBeGreaterThan(0); + expect(sortUtxoByTxIn(utxo2, utxo1)).toBeLessThan(0); + expect(sortUtxoByTxIn(utxo3, utxo1)).toBeGreaterThan(0); + }); + + it('returns 0 for identical UTXOs (same TxIn)', () => { + const txId = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const utxo1: Cardano.Utxo = [ + { address, index: 0, txId }, + { address, value: { coins: 1_000_000n } } + ]; + const utxo2: Cardano.Utxo = [ + { address, index: 0, txId }, + { address, value: { coins: 2_000_000n } } + ]; + + expect(sortUtxoByTxIn(utxo1, utxo2)).toBe(0); + }); + + it('can be used with Array.sort to sort UTXO array', () => { + const txId1 = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const txId2 = Cardano.TransactionId('1f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + + const utxos: Cardano.Utxo[] = [ + [ + { address, index: 1, txId: txId1 }, + { address, value: { coins: 1_000_000n } } + ], + [ + { address, index: 0, txId: txId2 }, + { address, value: { coins: 2_000_000n } } + ], + [ + { address, index: 0, txId: txId1 }, + { address, value: { coins: 3_000_000n } } + ], + [ + { address, index: 2, txId: txId1 }, + { address, value: { coins: 4_000_000n } } + ] + ]; + + utxos.sort(sortUtxoByTxIn); + + // Should be sorted by txId first, then by index + expect(utxos[0][0]).toEqual({ address, index: 0, txId: txId1 }); + expect(utxos[1][0]).toEqual({ address, index: 1, txId: txId1 }); + expect(utxos[2][0]).toEqual({ address, index: 2, txId: txId1 }); + expect(utxos[3][0]).toEqual({ address, index: 0, txId: txId2 }); + }); + + it('handles UTXOs with different addresses but same TxIn', () => { + const txId = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const address1 = Cardano.PaymentAddress( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ); + const address2 = Cardano.PaymentAddress( + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' + ); + + const utxo1: Cardano.Utxo = [ + { address: address1, index: 0, txId }, + { address: address1, value: { coins: 1_000_000n } } + ]; + const utxo2: Cardano.Utxo = [ + { address: address2, index: 0, txId }, + { address: address2, value: { coins: 2_000_000n } } + ]; + + // Should return 0 because TxIn is the same (sorting only considers TxIn, not TxOut) + expect(sortUtxoByTxIn(utxo1, utxo2)).toBe(0); + }); + }); +}); diff --git a/packages/input-selection/src/util.ts b/packages/input-selection/src/util.ts index 4404e146aee..2ea3d6793aa 100644 --- a/packages/input-selection/src/util.ts +++ b/packages/input-selection/src/util.ts @@ -1,7 +1,7 @@ /* eslint-disable func-style, complexity, sonarjs/cognitive-complexity */ import { BigIntMath } from '@cardano-sdk/util'; -import { Cardano } from '@cardano-sdk/core'; +import { Cardano, sortTxIn, sortUtxoByTxIn } from '@cardano-sdk/core'; import { ComputeMinimumCoinQuantity, ImplicitValue, TokenBundleSizeExceedsLimit } from './types'; import { InputSelectionError, InputSelectionFailure } from './InputSelectionError'; import uniq from 'lodash/uniq.js'; @@ -12,26 +12,8 @@ export const stubMaxSizeAddress = Cardano.PaymentAddress( 'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9' ); -/** - * Sorts the given TxIn set first by txId and then by index. - * - * @param lhs The left-hand side of the comparison operation. - * @param rhs The left-hand side of the comparison operation. - */ -export const sortTxIn = (lhs: Cardano.TxIn, rhs: Cardano.TxIn) => { - const txIdComparison = lhs.txId.localeCompare(rhs.txId); - if (txIdComparison !== 0) return txIdComparison; - - return lhs.index - rhs.index; -}; - -/** - * Sorts the given Utxo set first by TxIn. - * - * @param lhs The left-hand side of the comparison operation. - * @param rhs The left-hand side of the comparison operation. - */ -export const sortUtxoByTxIn = (lhs: Cardano.Utxo, rhs: Cardano.Utxo) => sortTxIn(lhs[0], rhs[0]); +// Re-export sorting utilities from core for backward compatibility +export { sortTxIn, sortUtxoByTxIn }; export interface ImplicitTokens { spend(assetId: Cardano.AssetId): bigint; From 3a21307dd2b713ea49be4a048027326612cc8ea7 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Wed, 5 Nov 2025 17:21:17 +0100 Subject: [PATCH 09/10] style(wallet): fix prettier formatting in global.d.ts Fix formatting issues: - Add missing semicolon after export statement - Reformat method signatures to single line per prettier rules --- packages/wallet/src/global.d.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/wallet/src/global.d.ts b/packages/wallet/src/global.d.ts index 2c41c10ca9d..167c831250e 100644 --- a/packages/wallet/src/global.d.ts +++ b/packages/wallet/src/global.d.ts @@ -1,15 +1,9 @@ -export {} +export {}; declare global { interface Array { - findLastIndex( - predicate: (value: T, index: number, obj: T[]) => unknown, - thisArg?: any - ): number; + findLastIndex(predicate: (value: T, index: number, obj: T[]) => unknown, thisArg?: any): number; - findLast( - predicate: (value: T, index: number, obj: T[]) => unknown, - thisArg?: any - ): T | undefined; + findLast(predicate: (value: T, index: number, obj: T[]) => unknown, thisArg?: any): T | undefined; } } From 726bc2e06fc8b4dec76a62242c61fffe48937a70 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Wed, 5 Nov 2025 17:21:45 +0100 Subject: [PATCH 10/10] refactor(wallet): remove address chunking from UTXO and transaction queries Remove address-based chunking logic from UtxoTracker and TransactionsTracker. The chunking was originally implemented for db-sync-based providers which are now obsolete. Modern Blockfrost providers implement a different query strategy: they group queries by payment credential (which requires all addresses to be provided upfront), then execute requests sequentially for each individual credential or address. This makes the PAGE_SIZE-based chunking unnecessary and redundant. BREAKING CHANGE: Wallets with many addresses may exceed provider page size limits. If using db-sync based providers, configure the server with a large enough page size limit to accommodate all wallet addresses in a single request. --- .../src/services/TransactionsTracker.ts | 51 ++++++++----------- packages/wallet/src/services/UtxoTracker.ts | 13 +---- .../test/services/TransactionsTracker.test.ts | 3 +- 3 files changed, 24 insertions(+), 43 deletions(-) diff --git a/packages/wallet/src/services/TransactionsTracker.ts b/packages/wallet/src/services/TransactionsTracker.ts index f950a7201a3..bf65257803c 100644 --- a/packages/wallet/src/services/TransactionsTracker.ts +++ b/packages/wallet/src/services/TransactionsTracker.ts @@ -37,7 +37,6 @@ import { distinctBlock, pollProvider, signedTxsEquals, transactionsEquals, txEqu import { WitnessedTx } from '@cardano-sdk/key-management'; import { newAndStoredMulticast } from './util/newAndStoredMulticast'; -import chunk from 'lodash/chunk.js'; import sortBy from 'lodash/sortBy.js'; export interface TransactionsTrackerProps { @@ -74,9 +73,6 @@ export interface TransactionsTrackerInternalsProps { logger: Logger; } -// Temporarily hardcoded. Will be replaced with ChainHistoryProvider 'maxPageSize' value once ADP-2249 is implemented -export const PAGE_SIZE = 25; - /** * Sorts the given HydratedTx by slot. * @@ -121,33 +117,30 @@ const allTransactionsByAddresses = async ( filterBy: { type: 'blockRange'; blockRange: Range } | { type: 'tip'; limit: number }; } ): Promise => { - const addressesSubGroups = chunk(addresses, PAGE_SIZE); let response: Cardano.HydratedTx[] = []; - for (const addressGroup of addressesSubGroups) { - if (filterBy.type === 'blockRange') { - let startAt = 0; - let pageResults: Cardano.HydratedTx[] = []; - - do { - pageResults = ( - await chainHistoryProvider.transactionsByAddresses({ - addresses: addressGroup, - blockRange: filterBy.blockRange, - pagination: { limit: PAGE_SIZE, startAt } - }) - ).pageResults; - - startAt += PAGE_SIZE; - response = [...response, ...pageResults]; - } while (pageResults.length >= PAGE_SIZE); - } else { - const txes = await chainHistoryProvider.transactionsByAddresses({ - addresses: addressGroup, - pagination: { limit: filterBy.limit, order: 'desc', startAt: 0 } - }); - response = [...response, ...txes.pageResults.reverse()]; - } + if (filterBy.type === 'blockRange') { + let startAt = 0; + let pageResults: Cardano.HydratedTx[] = []; + + do { + pageResults = ( + await chainHistoryProvider.transactionsByAddresses({ + addresses, + blockRange: filterBy.blockRange, + pagination: { limit: 25, startAt } + }) + ).pageResults; + + startAt += 25; + response = [...response, ...pageResults]; + } while (pageResults.length >= 25); + } else { + const txes = await chainHistoryProvider.transactionsByAddresses({ + addresses, + pagination: { limit: filterBy.limit, order: 'desc', startAt: 0 } + }); + response = [...response, ...txes.pageResults.reverse()]; } return deduplicateSortedArray(response.sort(compareTxOrder), txEquals); diff --git a/packages/wallet/src/services/UtxoTracker.ts b/packages/wallet/src/services/UtxoTracker.ts index 4c836dbf8d4..a9dba113751 100644 --- a/packages/wallet/src/services/UtxoTracker.ts +++ b/packages/wallet/src/services/UtxoTracker.ts @@ -6,12 +6,8 @@ import { RetryBackoffConfig } from 'backoff-rxjs'; import { TxInFlight, UtxoTracker } from './types'; import { WalletStores } from '../persistence'; import { sortUtxoByTxIn } from '@cardano-sdk/input-selection'; -import chunk from 'lodash/chunk.js'; import uniqWith from 'lodash/uniqWith.js'; -// Temporarily hardcoded. Will be replaced with ChainHistoryProvider 'maxPageSize' value once ADP-2249 is implemented -const PAGE_SIZE = 25; - export interface UtxoTrackerProps { utxoProvider: UtxoProvider; addresses$: Observable; @@ -41,14 +37,7 @@ export const createUtxoProvider = ( logger, retryBackoffConfig, sample: async () => { - let utxos = new Array(); - - const addressesSubGroups = chunk(paymentAddresses, PAGE_SIZE); - - for (const addresses of addressesSubGroups) { - utxos = [...utxos, ...(await utxoProvider.utxoByAddresses({ addresses }))]; - } - + const utxos = await utxoProvider.utxoByAddresses({ addresses: paymentAddresses }); return utxos.sort(sortUtxoByTxIn); }, trigger$: history$ diff --git a/packages/wallet/test/services/TransactionsTracker.test.ts b/packages/wallet/test/services/TransactionsTracker.test.ts index 7110e8cb0e8..c67e96e1312 100644 --- a/packages/wallet/test/services/TransactionsTracker.test.ts +++ b/packages/wallet/test/services/TransactionsTracker.test.ts @@ -5,7 +5,6 @@ import { Cardano, ChainHistoryProvider, TransactionsByAddressesArgs } from '@car import { FailedTx, OutgoingTx, - PAGE_SIZE, TransactionFailure, TxInFlight, createAddressTransactionsProvider, @@ -138,7 +137,7 @@ describe('TransactionsTracker', () => { }); it('emits configured number of latest historical transactions', async () => { - const totalTxsCount = PAGE_SIZE + 5; + const totalTxsCount = 25 + 5; const allTransactions = generateTxAlonzo(totalTxsCount); chainHistoryProvider.transactionsByAddresses = jest