Skip to content

Commit

Permalink
refactor: create ProviderError and convert blockfrostProvider errors …
Browse files Browse the repository at this point in the history
…to it

refactor: change Provider.submitTx return type to Promise<void>, use ProviderError
  • Loading branch information
mkazlauskas committed Oct 13, 2021
1 parent 3b6a935 commit 333f22b
Show file tree
Hide file tree
Showing 11 changed files with 128 additions and 81 deletions.
66 changes: 54 additions & 12 deletions packages/blockfrost/src/blockfrostProvider.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,58 @@
import { CardanoProvider } from '@cardano-sdk/core';
import { BlockFrostAPI } from '@blockfrost/blockfrost-js';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { CardanoProvider, ProviderError, ProviderFailure } from '@cardano-sdk/core';
import { BlockFrostAPI, Error as BlockfrostError } from '@blockfrost/blockfrost-js';
import { Options } from '@blockfrost/blockfrost-js/lib/types';
import { BlockfrostToOgmios } from './BlockfrostToOgmios';

const formatBlockfrostError = (error: unknown) => {
const blockfrostError = error as BlockfrostError;
if (typeof blockfrostError === 'string') {
throw new ProviderError(ProviderFailure.Unknown, error, blockfrostError);
}
if (typeof blockfrostError !== 'object') {
throw new ProviderError(ProviderFailure.Unknown, error, 'failed to parse error (response type)');
}
const errorAsType1 = blockfrostError as {
status_code: number;
message: string;
error: string;
};
if (errorAsType1.status_code) {
return errorAsType1;
}
const errorAsType2 = blockfrostError as {
errno: number;
message: string;
code: string;
};
if (errorAsType2.code) {
const status_code = Number.parseInt(errorAsType2.code);
if (!status_code) {
throw new ProviderError(ProviderFailure.Unknown, error, 'failed to parse error (status code)');
}
return {
status_code,
message: errorAsType1.message,
error: errorAsType2.errno.toString()
};
}
throw new ProviderError(ProviderFailure.Unknown, error, 'failed to parse error (response json)');
};

const toProviderError = (error: unknown) => {
const { status_code } = formatBlockfrostError(error);
if (status_code === 404) {
throw new ProviderError(ProviderFailure.NotFound);
}
throw new ProviderError(ProviderFailure.Unknown, error, `status_code: ${status_code}`);
};

/**
* Connect to the [Blockfrost service](https://docs.blockfrost.io/)
*
* @param {Options} options BlockFrostAPI options
* @returns {CardanoProvider} CardanoProvider
*/

export const blockfrostProvider = (options: Options): CardanoProvider => {
const blockfrost = new BlockFrostAPI(options);

Expand Down Expand Up @@ -62,13 +105,7 @@ export const blockfrostProvider = (options: Options): CardanoProvider => {
};

const submitTx: CardanoProvider['submitTx'] = async (signedTransaction) => {
try {
const hash = await blockfrost.txSubmit(signedTransaction.to_bytes());

return !!hash;
} catch {
return false;
}
await blockfrost.txSubmit(signedTransaction.to_bytes());
};

const utxoDelegationAndRewards: CardanoProvider['utxoDelegationAndRewards'] = async (addresses, stakeKeyHash) => {
Expand Down Expand Up @@ -116,7 +153,7 @@ export const blockfrostProvider = (options: Options): CardanoProvider => {
return BlockfrostToOgmios.currentWalletProtocolParameters(response.data);
};

return {
const providerFunctions = {
ledgerTip,
networkInfo,
stakePoolStats,
Expand All @@ -125,5 +162,10 @@ export const blockfrostProvider = (options: Options): CardanoProvider => {
queryTransactionsByAddresses,
queryTransactionsByHashes,
currentWalletProtocolParameters
};
} as any;

return Object.keys(providerFunctions).reduce((provider, key) => {
provider[key] = (...args: any[]) => providerFunctions[key](...args).catch(toProviderError);
return provider;
}, {} as any) as CardanoProvider;
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CardanoProvider } from '@cardano-sdk/core';
import { CardanoProvider, ProviderError, ProviderFailure } from '@cardano-sdk/core';
import { gql, GraphQLClient } from 'graphql-request';
import { TransactionSubmitResponse } from '@cardano-graphql/client-ts';
import { Schema as Cardano } from '@cardano-ogmios/client';
Expand Down Expand Up @@ -224,9 +224,11 @@ export const cardanoGraphqlDbSyncProvider = (uri: string): CardanoProvider => {
transaction: Buffer.from(signedTransaction.to_bytes()).toString('hex')
});

return !!response.hash;
} catch {
return false;
if (!response.hash) {
throw new Error('No "hash" in graphql response');
}
} catch (error) {
throw new ProviderError(ProviderFailure.Unknown, error);
}
};

Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/Provider/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { CustomError } from 'ts-custom-error';

export enum ProviderFailure {
NotFound = 'NOT_FOUND',
Unknown = 'UNKNOWN'
}

export class ProviderError extends CustomError {
constructor(public reason: ProviderFailure, public innerError?: unknown, public detail?: string) {
super(reason + (detail ? ` (${detail})` : ''));
}
}
1 change: 1 addition & 0 deletions packages/core/src/Provider/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './types';
export * from './errors';
2 changes: 1 addition & 1 deletion packages/core/src/Provider/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export interface CardanoProvider {
networkInfo: () => Promise<NetworkInfo>;
stakePoolStats?: () => Promise<StakePoolStats>;
/** @param signedTransaction signed and serialized cbor */
submitTx: (tx: CSL.Transaction) => Promise<boolean>;
submitTx: (signedTransaction: CSL.Transaction) => Promise<void>;
utxoDelegationAndRewards: (
addresses: Cardano.Address[],
stakeKeyHash: Cardano.Hash16
Expand Down
58 changes: 11 additions & 47 deletions packages/wallet/src/InMemoryTransactionTracker.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { TransactionTracker, TransactionTrackerEvents } from './types';
import Emittery from 'emittery';
import { Hash16, Slot, Tip } from '@cardano-ogmios/schema';
import { CardanoProvider, CardanoProviderError, CardanoSerializationLib, CSL } from '@cardano-sdk/core';
import { CardanoProvider, ProviderError, CardanoSerializationLib, CSL, ProviderFailure } from '@cardano-sdk/core';
import { TransactionError, TransactionFailure } from './Transaction/TransactionError';
import { Logger } from 'ts-log';
import { dummyLogger, Logger } from 'ts-log';
import delay from 'delay';

export type Milliseconds = number;

export interface InMemoryTransactionTrackerProps {
provider: CardanoProvider;
csl: CardanoSerializationLib;
logger: Logger;
logger?: Logger;
pollInterval?: Milliseconds;
}

Expand All @@ -22,7 +22,7 @@ export class InMemoryTransactionTracker extends Emittery<TransactionTrackerEvent
readonly #logger: Logger;
readonly #pollInterval: number;

constructor({ provider, csl, logger, pollInterval = 2000 }: InMemoryTransactionTrackerProps) {
constructor({ provider, csl, logger = dummyLogger, pollInterval = 2000 }: InMemoryTransactionTrackerProps) {
super();
this.#provider = provider;
this.#csl = csl;
Expand Down Expand Up @@ -52,22 +52,21 @@ export class InMemoryTransactionTracker extends Emittery<TransactionTrackerEvent
return promise;
}

async #trackTransaction(hash: Hash16, invalidHereafter: Slot, numTipFailures = 0): Promise<void> {
async #trackTransaction(hash: Hash16, invalidHereafter: Slot): Promise<void> {
await delay(this.#pollInterval);
try {
const tx = await this.#provider.queryTransactionsByHashes([hash]);
if (tx.length > 0) return; // done
return this.#onTransactionNotFound(hash, invalidHereafter, numTipFailures);
return this.#onTransactionNotFound(hash, invalidHereafter);
} catch (error: unknown) {
const providerError = this.#formatCardanoProviderError(error);
if (providerError.status_code !== 404) {
throw new TransactionError(TransactionFailure.CannotTrack, error);
if (error instanceof ProviderError && error.reason === ProviderFailure.NotFound) {
return this.#onTransactionNotFound(hash, invalidHereafter);
}
return this.#onTransactionNotFound(hash, invalidHereafter, numTipFailures);
throw new TransactionError(TransactionFailure.CannotTrack, error);
}
}

async #onTransactionNotFound(hash: string, invalidHereafter: number, numTipFailures: number) {
async #onTransactionNotFound(hash: string, invalidHereafter: number) {
let tip: Tip | undefined;
try {
tip = await this.#provider.ledgerTip();
Expand All @@ -81,41 +80,6 @@ export class InMemoryTransactionTracker extends Emittery<TransactionTrackerEvent
if (tip && tip.slot > invalidHereafter) {
throw new TransactionError(TransactionFailure.Timeout);
}
return this.#trackTransaction(hash, invalidHereafter, numTipFailures);
}

#formatCardanoProviderError(error: unknown) {
const cardanoProviderError = error as CardanoProviderError;
if (typeof cardanoProviderError === 'string') {
throw new TransactionError(TransactionFailure.Unknown, error, cardanoProviderError);
}
if (typeof cardanoProviderError !== 'object') {
throw new TransactionError(TransactionFailure.Unknown, error, 'failed to parse error (response type)');
}
const errorAsType1 = cardanoProviderError as {
status_code: number;
message: string;
error: string;
};
if (errorAsType1.status_code) {
return errorAsType1;
}
const errorAsType2 = cardanoProviderError as {
errno: number;
message: string;
code: string;
};
if (errorAsType2.code) {
const status_code = Number.parseInt(errorAsType2.code);
if (!status_code) {
throw new TransactionError(TransactionFailure.Unknown, error, 'failed to parse error (status code)');
}
return {
status_code,
message: errorAsType1.message,
error: errorAsType2.errno.toString()
};
}
throw new TransactionError(TransactionFailure.Unknown, error, 'failed to parse error (response json)');
return this.#trackTransaction(hash, invalidHereafter);
}
}
33 changes: 29 additions & 4 deletions packages/wallet/src/SingleAddressWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,32 @@ import { UtxoRepository } from './types';
import { dummyLogger, Logger } from 'ts-log';
import { defaultSelectionConstraints } from '@cardano-sdk/cip2';
import { computeImplicitCoin, createTransactionInternals, InitializeTxProps, TxInternals } from './Transaction';
import { KeyManagement } from '.';
import { KeyManagement, TransactionTracker } from '.';

export interface SubmitTxResult {
/**
* Resolves when transaction is submitted.
* Rejects with ProviderError.
*/
submitted: Promise<void>;
/**
* Resolves when transaction is submitted and confirmed.
* Rejects with TransactionError.
*/
confirmed: Promise<void>;
}
export interface SingleAddressWallet {
address: Schema.Address;
initializeTx: (props: InitializeTxProps) => Promise<TxInternals>;
name: string;
signTx: (body: CSL.TransactionBody, hash: CSL.TransactionHash) => Promise<CSL.Transaction>;
submitTx: (tx: CSL.Transaction) => Promise<boolean>;
/**
* Submits transaction.
*
* @returns {Promise<SubmitTxResult>} promise that resolves when transaction is submitted,
* but not confirmed yet. Rejects with TransactionError { FailedToSubmit }
*/
submitTx: (tx: CSL.Transaction) => SubmitTxResult;
}

export interface SingleAddressWalletDependencies {
Expand All @@ -20,6 +38,7 @@ export interface SingleAddressWalletDependencies {
logger?: Logger;
provider: CardanoProvider;
utxoRepository: UtxoRepository;
txTracker: TransactionTracker;
}

export interface SingleAddressWalletProps {
Expand All @@ -35,7 +54,7 @@ const ensureValidityInterval = (

export const createSingleAddressWallet = async (
{ name }: SingleAddressWalletProps,
{ csl, provider, keyManager, utxoRepository, logger = dummyLogger }: SingleAddressWalletDependencies
{ csl, provider, keyManager, utxoRepository, txTracker, logger = dummyLogger }: SingleAddressWalletDependencies
): Promise<SingleAddressWallet> => {
const address = keyManager.deriveAddress(0, 0);
const protocolParameters = await provider.currentWalletProtocolParameters();
Expand Down Expand Up @@ -72,6 +91,12 @@ export const createSingleAddressWallet = async (
},
name,
signTx,
submitTx: async (tx) => provider.submitTx(tx)
submitTx: (tx) => {
const submitted = provider.submitTx(tx);
return {
submitted,
confirmed: submitted.then(() => txTracker.trackTransaction(tx))
};
}
};
};
1 change: 0 additions & 1 deletion packages/wallet/src/Transaction/TransactionError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { CustomError } from 'ts-custom-error';

export enum TransactionFailure {
CannotTrack = 'CANNOT_TRACK',
Unknown = 'UNKNOWN',
Timeout = 'TIMEOUT'
}

Expand Down
8 changes: 3 additions & 5 deletions packages/wallet/test/InMemoryTransactionTracker.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CardanoSerializationLib, CSL } from '@cardano-sdk/core';
import { CardanoSerializationLib, CSL, ProviderError, ProviderFailure } from '@cardano-sdk/core';
import { dummyLogger } from 'ts-log';
import { InMemoryTransactionTracker } from '../src/InMemoryTransactionTracker';
import { TransactionFailure } from '../src/Transaction/TransactionError';
Expand Down Expand Up @@ -68,7 +68,7 @@ describe('InMemoryTransactionTracker', () => {

it('throws CannotTrack on ledger tip fetch error', async () => {
provider.queryTransactionsByHashes.mockResolvedValueOnce([]);
provider.ledgerTip.mockRejectedValueOnce(new Error('error'));
provider.ledgerTip.mockRejectedValueOnce(new ProviderError(ProviderFailure.Unknown));
await expect(txTracker.trackTransaction(transaction)).rejects.toThrowError(TransactionFailure.CannotTrack);
expect(provider.ledgerTip).toBeCalledTimes(1);
expect(provider.queryTransactionsByHashes).toBeCalledTimes(1);
Expand All @@ -77,9 +77,7 @@ describe('InMemoryTransactionTracker', () => {
it('polls provider at "pollInterval" until it returns the transaction', async () => {
// resolve [] or reject with 404 should be treated the same
provider.queryTransactionsByHashes.mockResolvedValueOnce([]);
provider.queryTransactionsByHashes.mockRejectedValueOnce({
status_code: 404
});
provider.queryTransactionsByHashes.mockRejectedValueOnce(new ProviderError(ProviderFailure.NotFound));
await txTracker.trackTransaction(transaction);
expect(provider.queryTransactionsByHashes).toBeCalledTimes(3);
expect(mockDelay).toBeCalledTimes(3);
Expand Down
2 changes: 1 addition & 1 deletion packages/wallet/test/ProviderStub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export const providerStub = () => ({
live: 15_001_884_895_856_815n
}
}),
submitTx: async () => true,
submitTx: jest.fn().mockResolvedValue(void 0),
stakePoolStats: async () => ({
qty: {
active: 1000,
Expand Down
16 changes: 10 additions & 6 deletions packages/wallet/test/SingleAddressWallet.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable max-len */
import { loadCardanoSerializationLib, CardanoSerializationLib, Cardano, CardanoProvider } from '@cardano-sdk/core';
import { loadCardanoSerializationLib, CardanoSerializationLib, Cardano } from '@cardano-sdk/core';
import { InputSelector, roundRobinRandomImprove } from '@cardano-sdk/cip2';
import { providerStub } from './ProviderStub';
import { ProviderStub, providerStub } from './ProviderStub';
import {
createSingleAddressWallet,
InMemoryUtxoRepository,
Expand All @@ -17,7 +17,7 @@ describe('Wallet', () => {
let csl: CardanoSerializationLib;
let inputSelector: InputSelector;
let keyManager: KeyManagement.KeyManager;
let provider: CardanoProvider;
let provider: ProviderStub;
let utxoRepository: UtxoRepository;
let walletDependencies: SingleAddressWalletDependencies;

Expand All @@ -32,7 +32,7 @@ describe('Wallet', () => {
provider = providerStub();
inputSelector = roundRobinRandomImprove(csl);
utxoRepository = new InMemoryUtxoRepository({ csl, provider, keyManager, inputSelector, txTracker });
walletDependencies = { csl, keyManager, provider, utxoRepository };
walletDependencies = { csl, keyManager, provider, utxoRepository, txTracker };
});

test('createWallet', async () => {
Expand Down Expand Up @@ -75,8 +75,12 @@ describe('Wallet', () => {
test('submitTx', async () => {
const { body, hash } = await wallet.initializeTx(props);
const tx = await wallet.signTx(body, hash);
const result = await wallet.submitTx(tx);
expect(result).toBe(true);
const { confirmed } = wallet.submitTx(tx);
await confirmed;
expect(provider.submitTx).toBeCalledTimes(1);
expect(provider.submitTx).toBeCalledWith(tx);
expect(txTracker.trackTransaction).toBeCalledTimes(1);
expect(txTracker.trackTransaction).toBeCalledWith(tx);
});
});
});

0 comments on commit 333f22b

Please sign in to comment.