Skip to content

Commit

Permalink
feat: add utxo http provider
Browse files Browse the repository at this point in the history
- UtxoProvider
- DBSyncUtxoProvider
- UtxoHttpService
- bash copy openApi script
- UtxoHttpProvider
  • Loading branch information
Juan Cruz committed May 19, 2022
1 parent 6f3a5d6 commit a55fcdb
Show file tree
Hide file tree
Showing 27 changed files with 794 additions and 4 deletions.
1 change: 1 addition & 0 deletions packages/cardano-services-client/src/UtxoProvider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './utxoHttpProvider';
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { HttpProviderConfigPaths, createHttpProvider } from '../HttpProvider';
import { ProviderError, ProviderFailure, UtxoProvider } from '@cardano-sdk/core';

export const defaultUtxoProviderPaths: HttpProviderConfigPaths<UtxoProvider> = {
healthCheck: '/health',
utxoByAddresses: '/utxo-by-addresses'
};

/**
* Connect to a Cardano Services HttpServer instance with the service available
*
* @param {string} baseUrl server root url, w/o trailing /
*/
export const utxoHttpProvider = (baseUrl: string, paths = defaultUtxoProviderPaths): UtxoProvider =>
createHttpProvider<UtxoProvider>({
baseUrl,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mapError: (error: any, method) => {
if (method === 'healthCheck' && !error) {
return { ok: false };
}
throw new ProviderError(ProviderFailure.Unknown, error);
},
paths
});
1 change: 1 addition & 0 deletions packages/cardano-services-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { TxSubmitProvider } from '@cardano-sdk/core';
export * from './HttpProvider';
export * from './TxSubmitProvider';
export * from './StakePoolSearchProvider';
export * from './UtxoProvider';
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/* eslint-disable max-len */
import { Cardano } from '@cardano-sdk/core';
import { utxoHttpProvider } from '../../src';
import MockAdapter from 'axios-mock-adapter';
import axios from 'axios';

const url = 'http://some-hostname:3000/utxo';

describe('utxoHttpProvider', () => {
let axiosMock: MockAdapter;

beforeAll(() => {
axiosMock = new MockAdapter(axios);
});
afterEach(() => {
axiosMock.reset();
});

afterAll(() => {
axiosMock.restore();
});
describe('healtCheck', () => {
it('is not ok if cannot connect', async () => {
const provider = utxoHttpProvider(url);
await expect(provider.healthCheck()).resolves.toEqual({ ok: false });
});
describe('mocked', () => {
it('is ok if 200 response body is { ok: true }', async () => {
axiosMock.onPost().replyOnce(200, { ok: true });
const provider = utxoHttpProvider(url);
await expect(provider.healthCheck()).resolves.toEqual({ ok: true });
});

it('is not ok if 200 response body is { ok: false }', async () => {
axiosMock.onPost().replyOnce(200, { ok: false });
const provider = utxoHttpProvider(url);
await expect(provider.healthCheck()).resolves.toEqual({ ok: false });
});
});
});
describe('utxoByAddresses', () => {
test('utxoByAddresses doesnt throw', async () => {
axiosMock.onPost().replyOnce(200, []);
const provider = utxoHttpProvider(url);
await expect(
provider.utxoByAddresses([
Cardano.Address(
'addr_test1qretqkqqvc4dax3482tpjdazrfl8exey274m3mzch3dv8lu476aeq3kd8q8splpsswcfmv4y370e8r76rc8lnnhte49qqyjmtc'
)
])
).resolves.toEqual([]);
});
});
});
5 changes: 5 additions & 0 deletions packages/cardano-services/copy-openApi.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env bash

cp ./src/StakePoolSearch/openApi.json ./dist/StakePoolSearch/openApi.json
cp ./src/TxSubmit/openApi.json ./dist/TxSubmit/openApi.json
cp ./src/Utxo/openApi.json ./dist/Utxo/openApi.json
3 changes: 1 addition & 2 deletions packages/cardano-services/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@
],
"license": "MPL-2.0",
"scripts": {
"build": "tsc --build ./src && yarn copy-openApi",
"copy-openApi": "cp ./src/StakePoolSearch/openApi.json ./dist/StakePoolSearch/openApi.json && cp ./src/TxSubmit/openApi.json ./dist/TxSubmit/openApi.json",
"build": "tsc --build ./src && bash ./copy-openApi.sh",
"tscNoEmit": "shx echo typescript --noEmit command not implemented yet",
"cleanup": "shx rm -rf dist node_modules",
"lint": "eslint --ignore-path ../../.eslintignore \"**/*.ts\"",
Expand Down
3 changes: 2 additions & 1 deletion packages/cardano-services/src/Program/ServiceNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
*/
export enum ServiceNames {
StakePoolSearch = 'stake-pool-search',
TxSubmit = 'tx-submit'
TxSubmit = 'tx-submit',
Utxo = 'utxo'
}
17 changes: 16 additions & 1 deletion packages/cardano-services/src/Program/loadHttpServer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { DbSyncStakePoolSearchProvider, StakePoolSearchHttpService } from '../StakePoolSearch';
import { DbSyncUtxoProvider, UtxoHttpService } from '../Utxo';
import { HttpServer, HttpServerConfig, HttpService } from '../Http';
import { LogLevel, createLogger } from 'bunyan';
import { MissingProgramOption, UnknownServiceName } from './errors';
Expand All @@ -11,7 +13,7 @@ import { ogmiosTxSubmitProvider } from '@cardano-sdk/ogmios';

export interface ProgramArgs {
apiUrl: URL;
serviceNames: (ServiceNames.StakePoolSearch | ServiceNames.TxSubmit)[];
serviceNames: (ServiceNames.StakePoolSearch | ServiceNames.TxSubmit | ServiceNames.Utxo)[];
options?: {
dbConnectionString?: string;
loggerMinSeverity?: LogLevel;
Expand Down Expand Up @@ -59,6 +61,19 @@ export const loadHttpServer = async (args: ProgramArgs): Promise<HttpServer> =>
})
);
break;
case ServiceNames.Utxo:
if (args.options?.dbConnectionString === undefined)
throw new MissingProgramOption(ServiceNames.Utxo, ProgramOptionDescriptions.DbConnection);
services.push(
await UtxoHttpService.create({
logger,
utxoProvider: new DbSyncUtxoProvider(
new Pool({ connectionString: args.options.dbConnectionString }),
logger
)
})
);
break;
default:
throw new UnknownServiceName(serviceName);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Cardano, UtxoProvider } from '@cardano-sdk/core';
import { DbSyncProvider } from '../../DbSyncProvider';
import { Logger, dummyLogger } from 'ts-log';
import { Pool } from 'pg';
import { UtxoBuilder } from './UtxoBuilder';

export class DbSyncUtxoProvider extends DbSyncProvider implements UtxoProvider {
#logger: Logger;
#builder: UtxoBuilder;
constructor(db: Pool, logger = dummyLogger) {
super(db);
this.#logger = logger;
this.#builder = new UtxoBuilder(db, logger);
}

public async utxoByAddresses(addresses: Cardano.Address[]): Promise<Cardano.Utxo[]> {
this.#logger.debug('About to call utxoByAddress of Utxo Query Builder');
return this.#builder.utxoByAddresses(addresses);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Cardano } from '@cardano-sdk/core';
import { Logger, dummyLogger } from 'ts-log';
import { Pool, QueryResult } from 'pg';
import { UtxoModel } from './types';
import { findUtxosByAddresses } from './queries';
import { utxosToCore } from './mappers';

export class UtxoBuilder {
#db: Pool;
#logger: Logger;
constructor(db: Pool, logger = dummyLogger) {
this.#db = db;
this.#logger = logger;
}
public async utxoByAddresses(addresses: Cardano.Address[]): Promise<Cardano.Utxo[]> {
const mappedAddresses = addresses.map((a) => a.toString());
this.#logger.debug('About to find utxos of addresses ', mappedAddresses);
const result: QueryResult<UtxoModel> = await this.#db.query(findUtxosByAddresses, [mappedAddresses]);
return result.rows.length > 0 ? utxosToCore(result.rows) : [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './DbSyncUtxoProvider';
export * from './mappers';
export * from './types';
50 changes: 50 additions & 0 deletions packages/cardano-services/src/Utxo/DbSyncUtxoProvider/mappers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Cardano, createUtxoId } from '@cardano-sdk/core';
import { UtxoModel } from './types';
import { generateAssetId } from './util';

/**
* Transform DB results into indexed core UTxO
*
* @param {UtxoModel[]} utxosModels UTxO query rows
* @returns {Cardano.Utxo[]} an array of core UTxO objects
*/
export const utxosToCore = (utxosModels: UtxoModel[]): Cardano.Utxo[] => {
const utxosMap = utxosModels.reduce((utxos, current) => {
const utxoId = createUtxoId(current.tx_id, current.index);
const utxo = utxos.get(utxoId);
if (utxo) {
const txIn = utxo[0];
const txOut = utxo[1];
if (current.asset_name && current.asset_policy && current.asset_quantity) {
const newAssets = txOut.value.assets || new Map<Cardano.AssetId, bigint>();
newAssets.set(generateAssetId(current.asset_policy, current.asset_name), BigInt(current.asset_quantity));
txOut.value.assets = newAssets;
}
utxos.set(utxoId, [txIn, txOut]);
} else {
const address = Cardano.Address(current.address);
const txOut: Cardano.TxOut = {
address,
value: {
coins: BigInt(current.coins)
}
};
if (current.data_hash) txOut.datum = Cardano.Hash32ByteBase16(current.data_hash);
if (current.asset_name && current.asset_policy && current.asset_quantity) {
txOut.value.assets = new Map<Cardano.AssetId, bigint>([
[generateAssetId(current.asset_policy, current.asset_name), BigInt(current.asset_quantity)]
]);
}
utxos.set(utxoId, [
{
address,
index: current.index,
txId: Cardano.TransactionId(current.tx_id)
},
txOut
]);
}
return utxos;
}, new Map<string, Cardano.Utxo>());
return [...utxosMap.values()];
};
25 changes: 25 additions & 0 deletions packages/cardano-services/src/Utxo/DbSyncUtxoProvider/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const findUtxosByAddresses = `
SELECT
tx_outer.address,
tx_outer.value AS coins,
tx_outer.index,
ENCODE(tx.hash, 'hex') AS tx_id,
ma_tx_out.quantity AS asset_quantity,
ENCODE(asset.name,'hex') AS asset_name,
ENCODE(asset.policy,'hex') AS asset_policy,
ENCODE(tx_outer.data_hash,'hex') AS data_hash
FROM tx_out AS tx_outer
JOIN tx ON tx.id = tx_outer.tx_id
LEFT JOIN ma_tx_out
ON ma_tx_out.tx_out_id = tx_outer.id
LEFT JOIN multi_asset as asset
ON asset.id = ma_tx_out.ident
WHERE NOT EXISTS
( SELECT tx_out.id
FROM tx_out
JOIN tx_in on
tx_out.tx_id = tx_in.tx_out_id AND
tx_out.index = tx_in.tx_out_index
WHERE tx_outer.id = tx_out.id
) AND address = ANY($1)
`;
13 changes: 13 additions & 0 deletions packages/cardano-services/src/Utxo/DbSyncUtxoProvider/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* There will be as much rows as tokens are in Value object
*/
export interface UtxoModel {
address: string;
coins: string;
index: number;
tx_id: string;
asset_quantity?: string;
asset_name?: string;
asset_policy?: string;
data_hash?: string;
}
3 changes: 3 additions & 0 deletions packages/cardano-services/src/Utxo/DbSyncUtxoProvider/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Cardano } from '@cardano-sdk/core';

export const generateAssetId = (policy: string, name: string) => Cardano.AssetId(policy + name);
52 changes: 52 additions & 0 deletions packages/cardano-services/src/Utxo/UtxoHttpService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as OpenApiValidator from 'express-openapi-validator';
import { Cardano, ProviderError, ProviderFailure } from '@cardano-sdk/core';
import { DbSyncUtxoProvider } from './DbSyncUtxoProvider';
import { HttpServer, HttpService } from '../Http';
import { Logger, dummyLogger } from 'ts-log';
import { ServiceNames } from '../Program';
import { providerHandler } from '../util';
import express from 'express';
import path from 'path';

export interface UtxoServiceDependencies {
logger?: Logger;
utxoProvider: DbSyncUtxoProvider;
}

export class UtxoHttpService extends HttpService {
#utxoProvider: DbSyncUtxoProvider;

private constructor({ utxoProvider, logger = dummyLogger }: UtxoServiceDependencies, router: express.Router) {
super(ServiceNames.Utxo, router, logger);
this.#utxoProvider = utxoProvider;
}

async healthCheck() {
return this.#utxoProvider.healthCheck();
}

static create({ logger = dummyLogger, utxoProvider }: UtxoServiceDependencies) {
const router = express.Router();
const apiSpec = path.join(__dirname, 'openApi.json');
router.use(
OpenApiValidator.middleware({
apiSpec,
ignoreUndocumented: true, // otherwhise /metrics endpoint should be included in spec
validateRequests: true,
validateResponses: true
})
);
router.post(
'/utxo-by-addresses',
providerHandler<[Cardano.Address[]], Cardano.Utxo[]>(async ([addresses], _, res) => {
try {
return HttpServer.sendJSON(res, await utxoProvider.utxoByAddresses(addresses));
} catch (error) {
logger.error(error);
return HttpServer.sendJSON(res, new ProviderError(ProviderFailure.Unhealthy, error), 500);
}
}, logger)
);
return new UtxoHttpService({ logger, utxoProvider }, router);
}
}
2 changes: 2 additions & 0 deletions packages/cardano-services/src/Utxo/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './UtxoHttpService';
export * from './DbSyncUtxoProvider';
Loading

0 comments on commit a55fcdb

Please sign in to comment.