Skip to content

Commit

Permalink
Merge pull request #240 from input-output-hk/feat/utxo-data
Browse files Browse the repository at this point in the history
UTxO data
  • Loading branch information
rhyslbw committed May 24, 2022
2 parents f410679 + eaf4812 commit 8d556b8
Show file tree
Hide file tree
Showing 60 changed files with 1,281 additions and 177 deletions.
41 changes: 41 additions & 0 deletions packages/blockfrost/src/blockfrostUtxoProvider.ts
@@ -0,0 +1,41 @@
import { BlockFrostAPI, Responses } from '@blockfrost/blockfrost-js';
import { BlockfrostToCore, BlockfrostUtxo } from './BlockfrostToCore';
import { Cardano, ProviderError, ProviderFailure, UtxoProvider } from '@cardano-sdk/core';
import { fetchByAddressSequentially } from './util';

/**
* Connect to the [Blockfrost service](https://docs.blockfrost.io/)
*
* @param {BlockFrostAPI} blockfrost BlockFrostAPI instance
* @returns {UtxoProvider} UtxoProvider
* @throws {ProviderError}
*/
export const blockfrostUtxoProvider = (blockfrost: BlockFrostAPI): UtxoProvider => {
const healthCheck: UtxoProvider['healthCheck'] = async () => {
try {
const result = await blockfrost.health();
return { ok: result.is_healthy };
} catch (error) {
throw new ProviderError(ProviderFailure.Unknown, error);
}
};

const utxoByAddresses: UtxoProvider['utxoByAddresses'] = async (addresses) => {
const utxoResults = await Promise.all(
addresses.map(async (address) =>
fetchByAddressSequentially<Cardano.Utxo, BlockfrostUtxo>({
address,
request: (addr: Cardano.Address, pagination) => blockfrost.addressesUtxos(addr.toString(), pagination),
responseTranslator: (addr: Cardano.Address, response: Responses['address_utxo_content']) =>
BlockfrostToCore.addressUtxoContent(addr.toString(), response)
})
)
);
return utxoResults.flat(1);
};

return {
healthCheck,
utxoByAddresses
};
};
19 changes: 2 additions & 17 deletions packages/blockfrost/src/blockfrostWalletProvider.ts
@@ -1,5 +1,5 @@
import { BlockFrostAPI, Responses } from '@blockfrost/blockfrost-js';
import { BlockfrostToCore, BlockfrostTransactionContent, BlockfrostUtxo } from './BlockfrostToCore';
import { BlockfrostToCore, BlockfrostTransactionContent } from './BlockfrostToCore';
import {
Cardano,
EpochRange,
Expand Down Expand Up @@ -72,20 +72,6 @@ export const blockfrostWalletProvider = (blockfrost: BlockFrostAPI, logger = dum
};
};

const utxoByAddresses: WalletProvider['utxoByAddresses'] = async (addresses) => {
const utxoResults = await Promise.all(
addresses.map(async (address) =>
fetchByAddressSequentially<Cardano.Utxo, BlockfrostUtxo>({
address,
request: (addr: Cardano.Address, pagination) => blockfrost.addressesUtxos(addr.toString(), pagination),
responseTranslator: (addr: Cardano.Address, response: Responses['address_utxo_content']) =>
BlockfrostToCore.addressUtxoContent(addr.toString(), response)
})
)
);
return utxoResults.flat(1);
};

const rewards: WalletProvider['rewardAccountBalance'] = async (rewardAccount: Cardano.RewardAccount) => {
try {
const accountResponse = await blockfrost.accounts(rewardAccount.toString());
Expand Down Expand Up @@ -411,8 +397,7 @@ export const blockfrostWalletProvider = (blockfrost: BlockFrostAPI, logger = dum
rewardsHistory,
stakePoolStats,
transactionsByAddresses,
transactionsByHashes,
utxoByAddresses
transactionsByHashes
};

return ProviderUtil.withProviderErrors(providerFunctions, toProviderError);
Expand Down
1 change: 1 addition & 0 deletions packages/blockfrost/src/index.ts
Expand Up @@ -3,5 +3,6 @@ export * from './blockfrostWalletProvider';
export * from './blockfrostAssetProvider';
export * from './blockfrostTxSubmitProvider';
export * from './blockfrostNetworkInfoProvider';
export * from './blockfrostUtxoProvider';
export { Options } from '@blockfrost/blockfrost-js/lib/types';
export { BlockFrostAPI } from '@blockfrost/blockfrost-js';
20 changes: 20 additions & 0 deletions packages/blockfrost/src/util.ts
Expand Up @@ -129,3 +129,23 @@ export const blockfrostMetadataToTxMetadata = (
map.set(BigInt(label), jsonToMetadatum(json_metadata));
return map;
}, new Map<bigint, Cardano.Metadatum>());

export const fetchByAddressSequentially = async <Item, Response>(props: {
address: Cardano.Address;
request: (address: Cardano.Address, pagination: PaginationOptions) => Promise<Response[]>;
responseTranslator?: (address: Cardano.Address, response: Response[]) => Item[];
/**
* @returns true to indicatate that current result set should be returned
*/
haveEnoughItems?: (items: Item[]) => boolean;
paginationOptions?: PaginationOptions;
}): Promise<Item[]> =>
fetchSequentially({
arg: props.address,
haveEnoughItems: props.haveEnoughItems,
paginationOptions: props.paginationOptions,
request: props.request,
responseTranslator: props.responseTranslator
? (response, arg) => props.responseTranslator!(arg, response)
: undefined
});
130 changes: 130 additions & 0 deletions packages/blockfrost/test/blockfrostUtxoProvider.test.ts
@@ -0,0 +1,130 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable max-len */

import { BlockFrostAPI, Responses } from '@blockfrost/blockfrost-js';
import { Cardano, ProviderError, ProviderFailure } from '@cardano-sdk/core';
import { blockfrostUtxoProvider } from '../src';
jest.mock('@blockfrost/blockfrost-js');

const generateUtxoResponseMock = (qty: number) =>
[...Array.from({ length: qty }).keys()].map((num) => ({
amount: [
{
quantity: String(50_928_877 + num),
unit: 'lovelace'
},
{
quantity: num + 1,
unit: 'b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'
}
],
block: 'b1b23210b9de8f3edef233f21f7d6e1fb93fe124ba126ba924edec3043e75b46',
output_index: num,
tx_hash: '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5',
tx_index: num
})) as Responses['address_utxo_content'];

describe('blockfrostUtxoProvider', () => {
const apiKey = 'someapikey';

describe('healthCheck', () => {
it('returns ok if the service reports a healthy state', async () => {
BlockFrostAPI.prototype.health = jest.fn().mockResolvedValue({ is_healthy: true });
const blockfrost = new BlockFrostAPI({ isTestnet: true, projectId: apiKey });
const provider = blockfrostUtxoProvider(blockfrost);
expect(await provider.healthCheck()).toEqual({ ok: true });
});
it('returns not ok if the service reports an unhealthy state', async () => {
BlockFrostAPI.prototype.health = jest.fn().mockResolvedValue({ is_healthy: false });
const blockfrost = new BlockFrostAPI({ isTestnet: true, projectId: apiKey });
const provider = blockfrostUtxoProvider(blockfrost);
expect(await provider.healthCheck()).toEqual({ ok: false });
});
it('throws a typed error if caught during the service interaction', async () => {
BlockFrostAPI.prototype.health = jest
.fn()
.mockRejectedValue(new ProviderError(ProviderFailure.Unknown, new Error('Some error')));
const blockfrost = new BlockFrostAPI({ isTestnet: true, projectId: apiKey });
const provider = blockfrostUtxoProvider(blockfrost);
await expect(provider.healthCheck()).rejects.toThrowError(ProviderError);
});
});

describe('utxoByAddresses', () => {
test('used addresses', async () => {
BlockFrostAPI.prototype.addressesUtxos = jest
.fn()
.mockResolvedValueOnce(generateUtxoResponseMock(100))
.mockResolvedValueOnce(generateUtxoResponseMock(100))
.mockResolvedValueOnce(generateUtxoResponseMock(0));

const blockfrost = new BlockFrostAPI({ isTestnet: true, projectId: apiKey });
const client = blockfrostUtxoProvider(blockfrost);
const response = await client.utxoByAddresses(
[
'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'
].map(Cardano.Address)
);

expect(response).toBeTruthy();
expect(response[0]).toHaveLength(2);
expect(response[0][0]).toMatchObject<Cardano.TxIn>({
address: Cardano.Address(
'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'
),
index: 0,
txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5')
});
expect(response[0][1]).toMatchObject<Cardano.TxOut>({
address: Cardano.Address(
'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'
),
value: {
assets: new Map([
[Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 1n]
]),
coins: 50_928_877n
}
});

expect(response[1]).toHaveLength(2);
expect(response[1][0]).toMatchObject<Cardano.TxIn>({
address: Cardano.Address(
'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'
),
index: 1,
txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5')
});
expect(response[1][1]).toMatchObject<Cardano.TxOut>({
address: Cardano.Address(
'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'
),
value: {
assets: new Map([
[Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 2n]
]),
coins: 50_928_878n
}
});
});

test('unused addresses', async () => {
const notFoundBody = {
error: 'Not Found',
message: 'The requested component has not been found.',
status_code: 404
};
BlockFrostAPI.prototype.addressesUtxos = jest.fn().mockRejectedValue(notFoundBody);

const blockfrost = new BlockFrostAPI({ isTestnet: true, projectId: apiKey });
const client = blockfrostUtxoProvider(blockfrost);
const response = await client.utxoByAddresses(
[
'addr_test1qz44wna7xvs8n2ukxw0qat3vktymndgk8nerey6mlxr97s47n48hk78hcuyku03lj7qplmfqscm87j9wv3amxqaur2hs055pjt'
].map(Cardano.Address)
);
expect(response).toBeTruthy();
expect(response.length).toBe(0);
});
});
});
97 changes: 1 addition & 96 deletions packages/blockfrost/test/blockfrostWalletProvider.test.ts
Expand Up @@ -4,29 +4,12 @@
import { BlockFrostAPI, Responses } from '@blockfrost/blockfrost-js';
import { Cardano, StakePoolStats, WalletProvider } from '@cardano-sdk/core';
import { blockfrostWalletProvider } from '../src';

jest.mock('@blockfrost/blockfrost-js');

const generatePoolsResponseMock = (qty: number) =>
[...Array.from({ length: qty }).keys()].map((num) => String(Math.random() * num)) as Responses['pool_list'];

const generateUtxoResponseMock = (qty: number) =>
[...Array.from({ length: qty }).keys()].map((num) => ({
amount: [
{
quantity: String(50_928_877 + num),
unit: 'lovelace'
},
{
quantity: num + 1,
unit: 'b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'
}
],
block: 'b1b23210b9de8f3edef233f21f7d6e1fb93fe124ba126ba924edec3043e75b46',
output_index: num,
tx_hash: '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5',
tx_index: num
})) as Responses['address_utxo_content'];

const blockResponse = {
block_vrf: 'vrf_vk19j362pkr4t9y0m3qxgmrv0365vd7c4ze03ny4jh84q8agjy4ep4s99zvg8',
confirmations: 0,
Expand Down Expand Up @@ -82,84 +65,6 @@ describe('blockfrostWalletProvider', () => {
});
});

describe('utxoByAddresses', () => {
test('used addresses', async () => {
BlockFrostAPI.prototype.addressesUtxos = jest
.fn()
.mockResolvedValueOnce(generateUtxoResponseMock(100))
.mockResolvedValueOnce(generateUtxoResponseMock(100))
.mockResolvedValueOnce(generateUtxoResponseMock(0));

const blockfrost = new BlockFrostAPI({ isTestnet: true, projectId: apiKey });
const client = blockfrostWalletProvider(blockfrost);
const response = await client.utxoByAddresses(
[
'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'
].map(Cardano.Address)
);

expect(response).toBeTruthy();
expect(response[0]).toHaveLength(2);
expect(response[0][0]).toMatchObject<Cardano.TxIn>({
address: Cardano.Address(
'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'
),
index: 0,
txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5')
});
expect(response[0][1]).toMatchObject<Cardano.TxOut>({
address: Cardano.Address(
'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'
),
value: {
assets: new Map([
[Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 1n]
]),
coins: 50_928_877n
}
});

expect(response[1]).toHaveLength(2);
expect(response[1][0]).toMatchObject<Cardano.TxIn>({
address: Cardano.Address(
'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'
),
index: 1,
txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5')
});
expect(response[1][1]).toMatchObject<Cardano.TxOut>({
address: Cardano.Address(
'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'
),
value: {
assets: new Map([
[Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 2n]
]),
coins: 50_928_878n
}
});
});

test('unused addresses', async () => {
const notFoundBody = {
error: 'Not Found',
message: 'The requested component has not been found.',
status_code: 404
};
BlockFrostAPI.prototype.addressesUtxos = jest.fn().mockRejectedValue(notFoundBody);

const blockfrost = new BlockFrostAPI({ isTestnet: true, projectId: apiKey });
const client = blockfrostWalletProvider(blockfrost);
const response = await client.utxoByAddresses(
[
'addr_test1qz44wna7xvs8n2ukxw0qat3vktymndgk8nerey6mlxr97s47n48hk78hcuyku03lj7qplmfqscm87j9wv3amxqaur2hs055pjt'
].map(Cardano.Address)
);
expect(response).toBeTruthy();
expect(response.length).toBe(0);
});
});

describe('rewardAccountBalance', () => {
test('used reward account', async () => {
const accountsMockResponse = {
Expand Down
4 changes: 2 additions & 2 deletions packages/cardano-services-client/src/HttpProvider.ts
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ProviderError, ProviderFailure, util } from '@cardano-sdk/core';
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import axios, { AxiosRequestConfig } from 'axios';

export type HttpProviderConfigPaths<T> = { [methodName in keyof T]: string };

Expand Down Expand Up @@ -73,7 +73,7 @@ export const createHttpProvider = <T extends object>({
}));
return (await axiosInstance.request(req)).data || undefined;
} catch (error) {
if (error instanceof AxiosError) {
if (axios.isAxiosError(error)) {
if (error.response) {
const typedError = util.fromSerializableObject(error.response.data, () => ProviderError.prototype);
if (mapError) return mapError(typedError, method);
Expand Down
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Cardano, ProviderError, ProviderFailure, TxSubmitProvider } from '@cardano-sdk/core';
import { HttpProviderConfigPaths, createHttpProvider } from '../HttpProvider';
import { mapHealthCheckError } from '../mapHealthCheckError';

export const defaultTxSubmitProviderPaths: HttpProviderConfigPaths<TxSubmitProvider> = {
healthCheck: '/health',
Expand Down Expand Up @@ -35,10 +36,7 @@ export const txSubmitHttpProvider = (baseUrl: string, paths = defaultTxSubmitPro
mapError: (error: any, method) => {
switch (method) {
case 'healthCheck': {
if (!error) {
return { ok: false };
}
break;
return mapHealthCheckError(error);
}
case 'submitTx': {
if (typeof error === 'object' && typeof error.innerError === 'object') {
Expand Down
1 change: 1 addition & 0 deletions packages/cardano-services-client/src/UtxoProvider/index.ts
@@ -0,0 +1 @@
export * from './utxoHttpProvider';

0 comments on commit 8d556b8

Please sign in to comment.