From 5bb937e277e3fd23991db2cff1c1ec574904e048 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Fri, 8 Oct 2021 14:59:57 +0300 Subject: [PATCH 1/9] feat(core): add cslToOgmios.txIn --- packages/core/src/Ogmios/cslToOgmios.ts | 6 ++++++ packages/core/test/Ogmios/cslToOgmios.test.ts | 11 +++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/core/src/Ogmios/cslToOgmios.ts b/packages/core/src/Ogmios/cslToOgmios.ts index dd56601692f..fbc83bed244 100644 --- a/packages/core/src/Ogmios/cslToOgmios.ts +++ b/packages/core/src/Ogmios/cslToOgmios.ts @@ -1,3 +1,4 @@ +import { TxIn } from '@cardano-ogmios/schema'; import { Asset } from '..'; import { CSL } from '../CSL'; import { OgmiosValue } from './util'; @@ -30,3 +31,8 @@ export const value = (cslValue: CSL.Value): OgmiosValue => { } return result; }; + +export const txIn = (input: CSL.TransactionInput): TxIn => ({ + txId: Buffer.from(input.transaction_id().to_bytes()).toString('hex'), + index: input.index() +}); diff --git a/packages/core/test/Ogmios/cslToOgmios.test.ts b/packages/core/test/Ogmios/cslToOgmios.test.ts index f0b00be725c..186015acb0d 100644 --- a/packages/core/test/Ogmios/cslToOgmios.test.ts +++ b/packages/core/test/Ogmios/cslToOgmios.test.ts @@ -1,5 +1,5 @@ import { CardanoSerializationLib, loadCardanoSerializationLib } from '@cardano-sdk/core'; -import { AssetId } from '@cardano-sdk/util-dev'; +import { AssetId, CslTestUtil } from '@cardano-sdk/util-dev'; import { cslToOgmios, ogmiosToCsl } from '../../src/Ogmios'; describe('util', () => { @@ -8,7 +8,7 @@ describe('util', () => { csl = await loadCardanoSerializationLib(); }); - describe('valueToValueQuantities', () => { + describe('value', () => { it('coin only', () => { const coins = 100_000n; const value = csl.Value.new(csl.BigNum.from_str(coins.toString())); @@ -25,4 +25,11 @@ describe('util', () => { expect(quantities.assets).toEqual(assets); }); }); + + it('txIn', () => { + const cslInput = CslTestUtil.createTxInput(csl); + const txIn = cslToOgmios.txIn(cslInput); + expect(typeof txIn.index).toBe('number'); + expect(typeof txIn.txId).toBe('string'); + }); }); From 19eb508af9c5364f9db604cfe4705857cd62f720 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Fri, 8 Oct 2021 17:38:48 +0300 Subject: [PATCH 2/9] feat(util-dev): add flushPromises util --- packages/util-dev/src/index.ts | 1 + packages/util-dev/src/util.ts | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 packages/util-dev/src/util.ts diff --git a/packages/util-dev/src/index.ts b/packages/util-dev/src/index.ts index 04abe22c074..a64ff4a29c6 100644 --- a/packages/util-dev/src/index.ts +++ b/packages/util-dev/src/index.ts @@ -1,3 +1,4 @@ export * as AssetId from './assetId'; export * as CslTestUtil from './cslTestUtil'; export * as SelectionConstraints from './selectionConstraints'; +export * from './util'; diff --git a/packages/util-dev/src/util.ts b/packages/util-dev/src/util.ts new file mode 100644 index 00000000000..d18b5376fa8 --- /dev/null +++ b/packages/util-dev/src/util.ts @@ -0,0 +1,2 @@ +export const flushPromises = (setImmediate = global.setImmediate) => + new Promise((resolve) => setImmediate(resolve, void 0)); From ee903cab8d1383169dc41679893ab4b7c3ad1daf Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Tue, 12 Oct 2021 11:53:12 +0300 Subject: [PATCH 3/9] chore: enable eslint no-floating-promises, disable no-var-requires --- .eslintrc.js | 6 ++++++ packages/cip2/test/jest.setup.js | 1 - test/jest.setup.js | 3 --- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 4ed0603f110..e7753e54dc1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,9 @@ module.exports = { "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig.json", + "tsconfigRootDir": __dirname + }, "extends": [ "@atixlabs/eslint-config/configurations/node", "plugin:@typescript-eslint/recommended", @@ -27,6 +31,8 @@ module.exports = { "unicorn/no-null": 0, "unicorn/no-array-reduce": 0, "unicorn/prefer-node-protocol": 0, + "@typescript-eslint/no-floating-promises": ["error"], + '@typescript-eslint/no-var-requires': 0, // covered by unicorn/prefer-module '@typescript-eslint/explicit-module-boundary-types': 0, "@typescript-eslint/ban-types": 0, '@typescript-eslint/no-non-null-assertion': 0, diff --git a/packages/cip2/test/jest.setup.js b/packages/cip2/test/jest.setup.js index 2ddfae2f65a..0088025d284 100644 --- a/packages/cip2/test/jest.setup.js +++ b/packages/cip2/test/jest.setup.js @@ -1,5 +1,4 @@ /* eslint-disable unicorn/prefer-module */ -/* eslint-disable @typescript-eslint/no-var-requires */ const { testTimeout } = require('../jest.config'); require('fast-check').configureGlobal({ interruptAfterTimeLimit: testTimeout * 0.7, diff --git a/test/jest.setup.js b/test/jest.setup.js index 9fa857d3097..a44f6728aea 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -1,6 +1,3 @@ -/* eslint-disable unicorn/prefer-module */ -/* eslint-disable @typescript-eslint/no-var-requires */ - // TODO: jest environment is not happy with 'lodash-es' exports. // I think using non-es-module 'lodash' in 'dependencies' is too heavy. // eslint-disable-next-line unicorn/prefer-module From 3b6a935a440beb961ea6b555bce753ed05a92cdd Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Fri, 8 Oct 2021 18:14:00 +0300 Subject: [PATCH 4/9] feat(wallet): implement UTxO lock/unlock functionality, fix utxo sync feat(wallet): implement InMemoryTransactionTracker --- packages/wallet/package.json | 6 +- .../wallet/src/InMemoryTransactionTracker.ts | 121 ++++++++++++++++++ packages/wallet/src/InMemoryUtxoRepository.ts | 79 ++++++++++-- .../src/Transaction/TransactionError.ts | 15 +++ packages/wallet/src/index.ts | 1 + packages/wallet/src/types.ts | 21 ++- .../test/InMemoryTransactionTracker.test.ts | 111 ++++++++++++++++ .../test/InMemoryUtxoRepository.test.ts | 111 +++++++++++++++- packages/wallet/test/ProviderStub.ts | 94 +++++++------- .../wallet/test/SingleAddressWallet.test.ts | 3 +- .../createTransactionInternals.test.ts | 3 +- .../wallet/test/mockTransactionTracker.ts | 8 ++ yarn.lock | 10 ++ 13 files changed, 514 insertions(+), 69 deletions(-) create mode 100644 packages/wallet/src/InMemoryTransactionTracker.ts create mode 100644 packages/wallet/src/Transaction/TransactionError.ts create mode 100644 packages/wallet/test/InMemoryTransactionTracker.test.ts create mode 100644 packages/wallet/test/mockTransactionTracker.ts diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 6f139d33309..3fcea237ea1 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -30,9 +30,11 @@ "@cardano-ogmios/schema": "4.1.0", "@cardano-sdk/cip2": "0.1.3", "@cardano-sdk/core": "0.1.3", - "lodash-es": "^4.17.21", - "isomorphic-bip39": "^3.0.5", "buffer": "^6.0.3", + "delay": "^5.0.0", + "emittery": "^0.10.0", + "isomorphic-bip39": "^3.0.5", + "lodash-es": "^4.17.21", "ts-custom-error": "^3.2.0", "ts-log": "^2.2.3" } diff --git a/packages/wallet/src/InMemoryTransactionTracker.ts b/packages/wallet/src/InMemoryTransactionTracker.ts new file mode 100644 index 00000000000..b55fe0ae706 --- /dev/null +++ b/packages/wallet/src/InMemoryTransactionTracker.ts @@ -0,0 +1,121 @@ +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 { TransactionError, TransactionFailure } from './Transaction/TransactionError'; +import { Logger } from 'ts-log'; +import delay from 'delay'; + +export type Milliseconds = number; + +export interface InMemoryTransactionTrackerProps { + provider: CardanoProvider; + csl: CardanoSerializationLib; + logger: Logger; + pollInterval?: Milliseconds; +} + +export class InMemoryTransactionTracker extends Emittery implements TransactionTracker { + readonly #provider: CardanoProvider; + readonly #pendingTransactions = new Map>(); + readonly #csl: CardanoSerializationLib; + readonly #logger: Logger; + readonly #pollInterval: number; + + constructor({ provider, csl, logger, pollInterval = 2000 }: InMemoryTransactionTrackerProps) { + super(); + this.#provider = provider; + this.#csl = csl; + this.#logger = logger; + this.#pollInterval = pollInterval; + } + + async trackTransaction(transaction: CSL.Transaction): Promise { + const body = transaction.body(); + const hash = Buffer.from(this.#csl.hash_transaction(body).to_bytes()).toString('hex'); + this.#logger.debug('InMemoryTransactionTracker.trackTransaction', hash); + + if (this.#pendingTransactions.has(hash)) { + return this.#pendingTransactions.get(hash)!; + } + + const invalidHereafter = body.ttl(); + if (!invalidHereafter) { + throw new TransactionError(TransactionFailure.CannotTrack, undefined, 'no TTL'); + } + + const promise = this.#trackTransaction(hash, invalidHereafter); + this.#pendingTransactions.set(hash, promise); + this.emit('transaction', { transaction, confirmed: promise }).catch(this.#logger.error); + void promise.catch(() => void 0).then(() => this.#pendingTransactions.delete(hash)); + + return promise; + } + + async #trackTransaction(hash: Hash16, invalidHereafter: Slot, numTipFailures = 0): Promise { + await delay(this.#pollInterval); + try { + const tx = await this.#provider.queryTransactionsByHashes([hash]); + if (tx.length > 0) return; // done + return this.#onTransactionNotFound(hash, invalidHereafter, numTipFailures); + } catch (error: unknown) { + const providerError = this.#formatCardanoProviderError(error); + if (providerError.status_code !== 404) { + throw new TransactionError(TransactionFailure.CannotTrack, error); + } + return this.#onTransactionNotFound(hash, invalidHereafter, numTipFailures); + } + } + + async #onTransactionNotFound(hash: string, invalidHereafter: number, numTipFailures: number) { + let tip: Tip | undefined; + try { + tip = await this.#provider.ledgerTip(); + } catch (error: unknown) { + throw new TransactionError( + TransactionFailure.CannotTrack, + error, + "can't query tip to check for transaction timeout" + ); + } + 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)'); + } +} diff --git a/packages/wallet/src/InMemoryUtxoRepository.ts b/packages/wallet/src/InMemoryUtxoRepository.ts index 6499e787800..855d2cee39f 100644 --- a/packages/wallet/src/InMemoryUtxoRepository.ts +++ b/packages/wallet/src/InMemoryUtxoRepository.ts @@ -5,8 +5,24 @@ import { CardanoProvider, Ogmios, CardanoSerializationLib, CSL } from '@cardano- import { dummyLogger, Logger } from 'ts-log'; import { ImplicitCoin, InputSelector, SelectionConstraints, SelectionResult } from '@cardano-sdk/cip2'; import { KeyManager } from './KeyManagement'; +import { OnTransactionArgs, TransactionTracker, UtxoRepositoryEvents } from '.'; +import { cslToOgmios } from '@cardano-sdk/core/src/Ogmios'; +import Emittery from 'emittery'; +import { TransactionError, TransactionFailure } from './Transaction/TransactionError'; -export class InMemoryUtxoRepository implements UtxoRepository { +export interface InMemoryUtxoRepositoryProps { + csl: CardanoSerializationLib; + provider: CardanoProvider; + keyManager: KeyManager; + inputSelector: InputSelector; + txTracker: TransactionTracker; + logger?: Logger; +} + +// Review: is comparing txIn enough to identify unique utxo? +const utxoEquals = ([txIn1]: [Schema.TxIn, Schema.TxOut], [txIn2]: [Schema.TxIn, Schema.TxOut]): boolean => + txIn1.txId === txIn2.txId && txIn1.index === txIn2.index; +export class InMemoryUtxoRepository extends Emittery implements UtxoRepository { #csl: CardanoSerializationLib; #delegationAndRewards: Schema.DelegationsAndRewards; #inputSelector: InputSelector; @@ -14,21 +30,28 @@ export class InMemoryUtxoRepository implements UtxoRepository { #logger: Logger; #provider: CardanoProvider; #utxoSet: Set<[TxIn, TxOut]>; + #lockedUtxoSet: Set<[TxIn, TxOut]> = new Set(); - constructor( - csl: CardanoSerializationLib, - provider: CardanoProvider, - keyManager: KeyManager, - inputSelector: InputSelector, - logger?: Logger - ) { + constructor({ + csl, + logger = dummyLogger, + provider, + inputSelector, + keyManager, + txTracker + }: InMemoryUtxoRepositoryProps) { + super(); this.#csl = csl; - this.#logger = logger ?? dummyLogger; + this.#logger = logger; this.#provider = provider; this.#utxoSet = new Set(); this.#delegationAndRewards = { rewards: undefined, delegate: undefined }; this.#inputSelector = inputSelector; this.#keyManager = keyManager; + txTracker.on('transaction', (args) => { + // not blocking to make it testable easier + this.#onTransaction(args).catch(this.#logger.error); + }); } public async sync(): Promise { @@ -39,11 +62,17 @@ export class InMemoryUtxoRepository implements UtxoRepository { ); this.#logger.trace(result); for (const utxo of result.utxo) { - if (!this.#utxoSet.has(utxo)) { + if (!this.allUtxos.some((oldUtxo) => utxoEquals(utxo, oldUtxo))) { this.#utxoSet.add(utxo); this.#logger.debug('New UTxO', utxo); } } + for (const utxo of this.#utxoSet) { + if (!result.utxo.some((newUtxo) => utxoEquals(utxo, newUtxo))) { + this.#utxoSet.delete(utxo); + this.#logger.debug('UTxO is gone', utxo); + } + } if (this.#delegationAndRewards.delegate !== result.delegationAndRewards.delegate) { this.#delegationAndRewards.delegate = result.delegationAndRewards.delegate; this.#logger.debug('Delegation stored', result.delegationAndRewards.delegate); @@ -75,6 +104,10 @@ export class InMemoryUtxoRepository implements UtxoRepository { return [...this.#utxoSet.values()]; } + public get availableUtxos(): Schema.Utxo { + return this.allUtxos.filter((utxo) => !this.#lockedUtxoSet.has(utxo)); + } + public get rewards(): Schema.Lovelace | null { return this.#delegationAndRewards.rewards ?? null; } @@ -82,4 +115,30 @@ export class InMemoryUtxoRepository implements UtxoRepository { public get delegation(): Schema.PoolId | null { return this.#delegationAndRewards.delegate ?? null; } + + async #onTransaction({ transaction, confirmed }: OnTransactionArgs) { + const utxoLockedByTx: Schema.Utxo = []; + const inputs = transaction.body().inputs(); + for (let inputIdx = 0; inputIdx < inputs.len(); inputIdx++) { + const { txId, index } = cslToOgmios.txIn(inputs.get(inputIdx)); + const utxo = this.allUtxos.find(([txIn]) => txIn.txId === txId && txIn.index === index)!; + this.#lockedUtxoSet.add(utxo); + utxoLockedByTx.push(utxo); + } + const unlock = (spent?: boolean) => { + for (const utxo of utxoLockedByTx) { + this.#lockedUtxoSet.delete(utxo); + spent && this.#utxoSet.delete(utxo); + } + }; + try { + await confirmed; + unlock(true); + } catch (error) { + unlock(false); + if (!(error instanceof TransactionError) || error.reason !== TransactionFailure.Timeout) { + await this.emit('transactionUntracked', transaction).catch(this.#logger.error); + } + } + } } diff --git a/packages/wallet/src/Transaction/TransactionError.ts b/packages/wallet/src/Transaction/TransactionError.ts new file mode 100644 index 00000000000..2e829e0eb3d --- /dev/null +++ b/packages/wallet/src/Transaction/TransactionError.ts @@ -0,0 +1,15 @@ +import { CustomError } from 'ts-custom-error'; + +export enum TransactionFailure { + CannotTrack = 'CANNOT_TRACK', + Unknown = 'UNKNOWN', + Timeout = 'TIMEOUT' +} + +const formatDetail = (detail?: string) => (detail ? ` (${detail})` : ''); + +export class TransactionError extends CustomError { + constructor(public reason: TransactionFailure, public innerError?: unknown, public detail?: string) { + super(`Transaction failed: ${reason}${formatDetail(detail)}`); + } +} diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index b4401d33249..0f783f79852 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1,6 +1,7 @@ export * as Address from './Address'; export * as Transaction from './Transaction'; export * from './InMemoryUtxoRepository'; +export * from './InMemoryTransactionTracker'; export * as KeyManagement from './KeyManagement'; export * from './SingleAddressWallet'; export * from './types'; diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 2a46e91be3b..dc5ad85e28f 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -1,9 +1,12 @@ import Schema from '@cardano-ogmios/schema'; import { ImplicitCoin, SelectionConstraints, SelectionResult } from '@cardano-sdk/cip2'; import { CSL } from '@cardano-sdk/core'; +import Emittery from 'emittery'; -export interface UtxoRepository { +export type UtxoRepositoryEvents = { transactionUntracked: CSL.Transaction }; +export interface UtxoRepository extends Emittery { allUtxos: Schema.Utxo; + availableUtxos: Schema.Utxo; rewards: Schema.Lovelace | null; delegation: Schema.PoolId | null; sync: () => Promise; @@ -13,3 +16,19 @@ export interface UtxoRepository { implicitCoin?: ImplicitCoin ) => Promise; } + +export interface OnTransactionArgs { + transaction: CSL.Transaction; + /** + * Resolves when transaction is confirmed. + * Rejects if transaction fails to submit or validate. + */ + confirmed: Promise; +} +export type TransactionTrackerEvents = { transaction: OnTransactionArgs }; +export interface TransactionTracker extends Emittery { + /** + * Track a new transaction + */ + trackTransaction(transaction: CSL.Transaction): Promise; +} diff --git a/packages/wallet/test/InMemoryTransactionTracker.test.ts b/packages/wallet/test/InMemoryTransactionTracker.test.ts new file mode 100644 index 00000000000..e111417ac61 --- /dev/null +++ b/packages/wallet/test/InMemoryTransactionTracker.test.ts @@ -0,0 +1,111 @@ +import { CardanoSerializationLib, CSL } from '@cardano-sdk/core'; +import { dummyLogger } from 'ts-log'; +import { InMemoryTransactionTracker } from '../src/InMemoryTransactionTracker'; +import { TransactionFailure } from '../src/Transaction/TransactionError'; +import { ledgerTip, providerStub, ProviderStub, queryTransactionsResult } from './ProviderStub'; +import mockDelay from 'delay'; + +jest.mock('delay', () => jest.fn().mockResolvedValue(void 0)); + +describe('InMemoryTransactionTracker', () => { + const POLL_INTERVAL = 1000; + let ledgerTipSlot: number; + let provider: ProviderStub; + let txTracker: InMemoryTransactionTracker; + let hash_transaction: jest.Mock; + + const mockHashTransactionReturn = (resultHash: string) => { + hash_transaction.mockReturnValue({ + to_bytes() { + return Buffer.from(resultHash); + } + }); + }; + + beforeEach(() => { + provider = providerStub(); + provider.queryTransactionsByHashes.mockReturnValue([queryTransactionsResult[0]]); + hash_transaction = jest.fn(); + mockHashTransactionReturn('some-hash'); + txTracker = new InMemoryTransactionTracker({ + provider, + csl: { hash_transaction } as unknown as CardanoSerializationLib, + logger: dummyLogger, + pollInterval: POLL_INTERVAL + }); + ledgerTipSlot = ledgerTip.slot; + (mockDelay as unknown as jest.Mock).mockReset(); + }); + + describe('trackTransaction', () => { + let onTransaction: jest.Mock; + + beforeEach(() => { + onTransaction = jest.fn(); + txTracker.on('transaction', onTransaction); + }); + + it('invalid transaction (no ttl)', async () => { + await expect(() => + txTracker.trackTransaction({ + body: () => ({ + ttl: () => void 0 + }) + } as unknown as CSL.Transaction) + ).rejects.toThrowError(TransactionFailure.CannotTrack); + }); + + describe('valid transaction', () => { + let transaction: CSL.Transaction; + + beforeEach(async () => { + transaction = { + body: () => ({ + ttl: () => ledgerTipSlot + }) + } as unknown as CSL.Transaction; + }); + + it('throws CannotTrack on ledger tip fetch error', async () => { + provider.queryTransactionsByHashes.mockResolvedValueOnce([]); + provider.ledgerTip.mockRejectedValueOnce(new Error('error')); + await expect(txTracker.trackTransaction(transaction)).rejects.toThrowError(TransactionFailure.CannotTrack); + expect(provider.ledgerTip).toBeCalledTimes(1); + expect(provider.queryTransactionsByHashes).toBeCalledTimes(1); + }); + + 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 + }); + await txTracker.trackTransaction(transaction); + expect(provider.queryTransactionsByHashes).toBeCalledTimes(3); + expect(mockDelay).toBeCalledTimes(3); + expect(mockDelay).toBeCalledWith(POLL_INTERVAL); + }); + + it('throws after timeout', async () => { + provider.queryTransactionsByHashes.mockResolvedValueOnce([]); + provider.ledgerTip.mockResolvedValueOnce({ slot: ledgerTipSlot + 1 }); + await expect(txTracker.trackTransaction(transaction)).rejects.toThrowError(TransactionFailure.Timeout); + }); + + it('emits "transaction" event for tracked transactions, returns promise unique per pending tx', async () => { + const promise1 = txTracker.trackTransaction(transaction); + const promise2 = txTracker.trackTransaction(transaction); + await promise1; + await promise2; + mockHashTransactionReturn('other-hash'); + await txTracker.trackTransaction(transaction); + expect(provider.queryTransactionsByHashes).toBeCalledTimes(2); + expect(onTransaction).toBeCalledTimes(2); + // assert it clears cache + await txTracker.trackTransaction(transaction); + expect(provider.queryTransactionsByHashes).toBeCalledTimes(3); + expect(onTransaction).toBeCalledTimes(3); + }); + }); + }); +}); diff --git a/packages/wallet/test/InMemoryUtxoRepository.test.ts b/packages/wallet/test/InMemoryUtxoRepository.test.ts index 2de3b32632d..c0d41fc972d 100644 --- a/packages/wallet/test/InMemoryUtxoRepository.test.ts +++ b/packages/wallet/test/InMemoryUtxoRepository.test.ts @@ -1,8 +1,13 @@ +/* eslint-disable promise/param-names */ import { roundRobinRandomImprove, InputSelector } from '@cardano-sdk/cip2'; -import { loadCardanoSerializationLib, CardanoSerializationLib, CSL, CardanoProvider, Ogmios } from '@cardano-sdk/core'; -import { SelectionConstraints } from '@cardano-sdk/util-dev'; -import { providerStub, delegate, rewards } from './ProviderStub'; +import { loadCardanoSerializationLib, CardanoSerializationLib, CSL, Ogmios } from '@cardano-sdk/core'; +import { flushPromises, SelectionConstraints } from '@cardano-sdk/util-dev'; +import { providerStub, delegate, rewards, ProviderStub, utxo, delegationAndRewards } from './ProviderStub'; import { InMemoryUtxoRepository, KeyManagement, UtxoRepository } from '../src'; +import { MockTransactionTracker } from './mockTransactionTracker'; +import { ogmiosToCsl } from '@cardano-sdk/core/src/Ogmios'; +import { TxIn, TxOut } from '@cardano-ogmios/schema'; +import { TransactionError, TransactionFailure } from '../src/Transaction/TransactionError'; const addresses = [ 'addr_test1qq585l3hyxgj3nas2v3xymd23vvartfhceme6gv98aaeg9muzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q2g7k3g' @@ -10,10 +15,11 @@ const addresses = [ describe('InMemoryUtxoRepository', () => { let utxoRepository: UtxoRepository; - let provider: CardanoProvider; + let provider: ProviderStub; let inputSelector: InputSelector; let csl: CardanoSerializationLib; let outputs: Set; + let txTracker: MockTransactionTracker; beforeEach(async () => { provider = providerStub(); @@ -35,7 +41,8 @@ describe('InMemoryUtxoRepository', () => { value: { coins: 2_000_000 } }) ]); - utxoRepository = new InMemoryUtxoRepository(csl, provider, keyManager, inputSelector); + txTracker = new MockTransactionTracker(); + utxoRepository = new InMemoryUtxoRepository({ csl, provider, keyManager, inputSelector, txTracker }); }); test('constructed state', async () => { @@ -49,6 +56,16 @@ describe('InMemoryUtxoRepository', () => { await expect(utxoRepository.allUtxos.length).toBe(3); await expect(utxoRepository.rewards).toBe(rewards); await expect(utxoRepository.delegation).toBe(delegate); + const identicalUtxo = [{ ...utxo[1][0] }, { ...utxo[1][1] }] as const; // clone UTxO + provider.utxoDelegationAndRewards.mockResolvedValueOnce({ + utxo: [utxo[0], identicalUtxo], + delegationAndRewards + }); + await utxoRepository.sync(); + await expect(utxoRepository.allUtxos.length).toBe(2); + // Verify we're not replacing the object with an identical one in the UTxO set + await expect(utxoRepository.allUtxos).not.toContain(identicalUtxo); + await expect(utxoRepository.allUtxos).toContain(utxo[1]); }); describe('selectInputs', () => { @@ -62,4 +79,88 @@ describe('InMemoryUtxoRepository', () => { await expect(result.selection.change.size).toBe(2); }); }); + + describe('availableUtxos', () => { + let transactionUtxo: [TxIn, TxOut]; + let transaction: CSL.Transaction; + let numUtxoPreTransaction: number; + let onTransactionUntracked: jest.Mock; + + const trackTransaction = async (confirmed: Promise) => { + await txTracker.emit('transaction', { + transaction, + confirmed + }); + // transaction not yet confirmed + expect(utxoRepository.availableUtxos).toHaveLength(utxoRepository.allUtxos.length - 1); + expect(utxoRepository.availableUtxos).not.toContain(transactionUtxo); + }; + + beforeEach(async () => { + transactionUtxo = utxo[0]; + transaction = { + body: () => ({ + inputs: () => ({ + len: () => 1, + get: () => ogmiosToCsl(csl).txIn(transactionUtxo[0]) + }) + }) + } as unknown as CSL.Transaction; + await utxoRepository.sync(); + numUtxoPreTransaction = utxoRepository.allUtxos.length; + onTransactionUntracked = jest.fn(); + utxoRepository.on('transactionUntracked', onTransactionUntracked); + }); + + it('preconditions', () => { + expect(utxoRepository.availableUtxos).toHaveLength(utxoRepository.allUtxos.length); + expect(utxoRepository.availableUtxos).toContain(transactionUtxo); + }); + + it('transaction confirmed', async () => { + let completeConfirmation: Function; + const confirmed = new Promise((resolve) => (completeConfirmation = resolve)); + await trackTransaction(confirmed); + + // transaction confirmed + await completeConfirmation!(); + expect(utxoRepository.availableUtxos).toHaveLength(numUtxoPreTransaction - 1); + expect(utxoRepository.availableUtxos).toHaveLength(utxoRepository.allUtxos.length); + expect(utxoRepository.availableUtxos).not.toContain(transactionUtxo); + }); + + it('transaction timed out', async () => { + // setup for transaction to timeout + let completeConfirmation: Function; + const confirmed = new Promise( + (_, reject) => (completeConfirmation = () => reject(new TransactionError(TransactionFailure.Timeout))) + ); + await trackTransaction(confirmed); + + // transaction rejected + await completeConfirmation!(); + expect(onTransactionUntracked).not.toBeCalled(); + expect(utxoRepository.availableUtxos).toHaveLength(numUtxoPreTransaction); + expect(utxoRepository.availableUtxos).toHaveLength(utxoRepository.allUtxos.length); + expect(utxoRepository.availableUtxos).toContain(transactionUtxo); + }); + + it('emits transactionUntracked on any other transaction tracker error', async () => { + // setup for transaction to timeout + let completeConfirmation: Function; + const confirmed = new Promise( + (_, reject) => (completeConfirmation = () => reject(new TransactionError(TransactionFailure.CannotTrack))) + ); + await trackTransaction(confirmed); + + // Assuming UTxO is still available, SDK user should call TransactionTracker.trackTransaction to lock it again. + await completeConfirmation!(); + await flushPromises(); + expect(onTransactionUntracked).toBeCalledTimes(1); + expect(onTransactionUntracked).toBeCalledWith(transaction); + expect(utxoRepository.availableUtxos).toHaveLength(numUtxoPreTransaction); + expect(utxoRepository.availableUtxos).toHaveLength(utxoRepository.allUtxos.length); + expect(utxoRepository.availableUtxos).toContain(transactionUtxo); + }); + }); }); diff --git a/packages/wallet/test/ProviderStub.ts b/packages/wallet/test/ProviderStub.ts index 8e1ea2e22f2..0579d892854 100644 --- a/packages/wallet/test/ProviderStub.ts +++ b/packages/wallet/test/ProviderStub.ts @@ -1,5 +1,4 @@ /* eslint-disable max-len */ -import { CardanoProvider } from '@cardano-sdk/core'; import * as Schema from '@cardano-ogmios/schema'; export const stakeKeyHash = 'stake_test1up7pvfq8zn4quy45r2g572290p9vf99mr9tn7r9xrgy2l2qdsf58d'; @@ -51,19 +50,51 @@ export const utxo: Schema.Utxo = [ export const delegate = 'pool185g59xpqzt7gf0ljr8v8f3akl95qnmardf2f8auwr3ffx7atjj5'; export const rewards = 33_333; +export const delegationAndRewards = { delegate, rewards }; + +export const queryTransactionsResult = [ + { + hash: 'ea1517b8c36fea3148df9aa1f49bbee66ff59a5092331a67bd8b3c427e1d79d7', + inputs: [ + { + txId: 'bb217abaca60fc0ca68c1555eca6a96d2478547818ae76ce6836133f3cc546e0', + index: 0 + } + ], + outputs: [ + { + address: + 'addr_test1qpfhhfy2qgls50r9u4yh0l7z67xpg0a5rrhkmvzcuqrd0znuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q9gw0lz', + value: { coins: 5_000_000 } + }, + { + address: + 'addr_test1qplfzem2xsc29wxysf8wkdqrm4s4mmncd40qnjq9sk84l3tuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q52ukj5', + value: { coins: 5_000_000 } + }, + { + address: + 'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9', + value: { coins: 9_825_963 } + } + ] + } +]; +const queryTransactions = () => jest.fn().mockResolvedValue(queryTransactionsResult); + +export const ledgerTip = { + blockNo: 1_111_111, + hash: '10d64cc11e9b20e15b6c46aa7b1fed11246f437e62225655a30ea47bf8cc22d0', + slot: 37_834_496 +}; /** * Provider stub for testing * - * @returns {CardanoProvider} CardanoProvider + * returns CardanoProvider-compatible object */ - -export const providerStub = (): CardanoProvider => ({ - ledgerTip: async () => ({ - blockNo: 1_111_111, - hash: '10d64cc11e9b20e15b6c46aa7b1fed11246f437e62225655a30ea47bf8cc22d0', - slot: 37_834_496 - }), +export const providerStub = () => ({ + ledgerTip: jest.fn().mockResolvedValue(ledgerTip), networkInfo: async () => ({ currentEpoch: { number: 158, @@ -92,46 +123,9 @@ export const providerStub = (): CardanoProvider => ({ retiring: 5 } }), - utxoDelegationAndRewards: async () => { - const delegationAndRewards = { - delegate, - rewards - }; - - return { utxo, delegationAndRewards }; - }, - queryTransactionsByAddresses: async () => - Promise.resolve([ - { - hash: 'ea1517b8c36fea3148df9aa1f49bbee66ff59a5092331a67bd8b3c427e1d79d7', - inputs: [ - { - txId: 'bb217abaca60fc0ca68c1555eca6a96d2478547818ae76ce6836133f3cc546e0', - index: 0 - } - ], - outputs: [ - { - address: - 'addr_test1qpfhhfy2qgls50r9u4yh0l7z67xpg0a5rrhkmvzcuqrd0znuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q9gw0lz', - value: { coins: 5_000_000 } - }, - { - address: - 'addr_test1qplfzem2xsc29wxysf8wkdqrm4s4mmncd40qnjq9sk84l3tuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q52ukj5', - value: { coins: 5_000_000 } - }, - { - address: - 'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9', - value: { coins: 9_825_963 } - } - ] - } - ]), - queryTransactionsByHashes: async () => { - throw new Error('Not implemented yet'); - }, + utxoDelegationAndRewards: jest.fn().mockResolvedValue({ utxo, delegationAndRewards }), + queryTransactionsByAddresses: queryTransactions(), + queryTransactionsByHashes: queryTransactions(), currentWalletProtocolParameters: async () => ({ minFeeCoefficient: 44, minFeeConstant: 155_381, @@ -145,3 +139,5 @@ export const providerStub = (): CardanoProvider => ({ coinsPerUtxoWord: 34_482 }) }); + +export type ProviderStub = ReturnType; diff --git a/packages/wallet/test/SingleAddressWallet.test.ts b/packages/wallet/test/SingleAddressWallet.test.ts index 9f68c7f7dea..1458de311b3 100644 --- a/packages/wallet/test/SingleAddressWallet.test.ts +++ b/packages/wallet/test/SingleAddressWallet.test.ts @@ -10,6 +10,7 @@ import { SingleAddressWalletDependencies, UtxoRepository } from '../src'; +import { txTracker } from './mockTransactionTracker'; describe('Wallet', () => { const name = 'Test Wallet'; @@ -30,7 +31,7 @@ describe('Wallet', () => { }); provider = providerStub(); inputSelector = roundRobinRandomImprove(csl); - utxoRepository = new InMemoryUtxoRepository(csl, provider, keyManager, inputSelector); + utxoRepository = new InMemoryUtxoRepository({ csl, provider, keyManager, inputSelector, txTracker }); walletDependencies = { csl, keyManager, provider, utxoRepository }; }); diff --git a/packages/wallet/test/Transaction/createTransactionInternals.test.ts b/packages/wallet/test/Transaction/createTransactionInternals.test.ts index ef9b00ad6ed..a7eb76cd6f5 100644 --- a/packages/wallet/test/Transaction/createTransactionInternals.test.ts +++ b/packages/wallet/test/Transaction/createTransactionInternals.test.ts @@ -12,6 +12,7 @@ import { KeyManager } from '../../src/KeyManagement'; import { testKeyManager } from '../testKeyManager'; import { UtxoRepository } from '../../src/types'; import { InMemoryUtxoRepository } from '../../src/InMemoryUtxoRepository'; +import { txTracker } from '../mockTransactionTracker'; const address = 'addr_test1qq585l3hyxgj3nas2v3xymd23vvartfhceme6gv98aaeg9muzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q2g7k3g'; @@ -53,7 +54,7 @@ describe('Transaction.createTransactionInternals', () => { value: { coins: 2_000_000 } }) ]); - utxoRepository = new InMemoryUtxoRepository(csl, provider, keyManager, inputSelector); + utxoRepository = new InMemoryUtxoRepository({ csl, provider, keyManager, inputSelector, txTracker }); }); test('simple transaction', async () => { diff --git a/packages/wallet/test/mockTransactionTracker.ts b/packages/wallet/test/mockTransactionTracker.ts new file mode 100644 index 00000000000..1fe5f59c2f2 --- /dev/null +++ b/packages/wallet/test/mockTransactionTracker.ts @@ -0,0 +1,8 @@ +import Emittery from 'emittery'; +import { TransactionTrackerEvents } from '../src'; + +export class MockTransactionTracker extends Emittery { + trackTransaction = jest.fn(); +} + +export const txTracker = new MockTransactionTracker(); diff --git a/yarn.lock b/yarn.lock index c5d7b2fb9fb..afdd0ba2974 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2973,6 +2973,11 @@ del@^6.0.0: rimraf "^3.0.2" slash "^3.0.0" +delay@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/delay/-/delay-5.0.0.tgz#137045ef1b96e5071060dd5be60bf9334436bd1d" + integrity sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw== + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -3119,6 +3124,11 @@ email-validator@^2.0.4: resolved "https://registry.yarnpkg.com/email-validator/-/email-validator-2.0.4.tgz#b8dfaa5d0dae28f1b03c95881d904d4e40bfe7ed" integrity sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ== +emittery@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.10.0.tgz#bb373c660a9d421bb44706ec4967ed50c02a8026" + integrity sha512-AGvFfs+d0JKCJQ4o01ASQLGPmSCxgfU9RFXvzPvZdjKK8oscynksuJhWrSTSw7j7Ep/sZct5b5ZhYCi8S/t0HQ== + emittery@^0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.8.1.tgz#bb23cc86d03b30aa75a7f734819dee2e1ba70860" From 333f22bb84384b8826889673c88753bfc2fd3a3f Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Mon, 11 Oct 2021 11:25:37 +0300 Subject: [PATCH 5/9] refactor: create ProviderError and convert blockfrostProvider errors to it refactor: change Provider.submitTx return type to Promise, use ProviderError --- packages/blockfrost/src/blockfrostProvider.ts | 66 +++++++++++++++---- .../src/cardanoGraphqlDbSyncProvider.ts | 10 +-- packages/core/src/Provider/errors.ts | 12 ++++ packages/core/src/Provider/index.ts | 1 + packages/core/src/Provider/types.ts | 2 +- .../wallet/src/InMemoryTransactionTracker.ts | 58 ++++------------ packages/wallet/src/SingleAddressWallet.ts | 33 ++++++++-- .../src/Transaction/TransactionError.ts | 1 - .../test/InMemoryTransactionTracker.test.ts | 8 +-- packages/wallet/test/ProviderStub.ts | 2 +- .../wallet/test/SingleAddressWallet.test.ts | 16 +++-- 11 files changed, 128 insertions(+), 81 deletions(-) create mode 100644 packages/core/src/Provider/errors.ts diff --git a/packages/blockfrost/src/blockfrostProvider.ts b/packages/blockfrost/src/blockfrostProvider.ts index 1bb1ec53381..f86a4b179eb 100644 --- a/packages/blockfrost/src/blockfrostProvider.ts +++ b/packages/blockfrost/src/blockfrostProvider.ts @@ -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); @@ -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) => { @@ -116,7 +153,7 @@ export const blockfrostProvider = (options: Options): CardanoProvider => { return BlockfrostToOgmios.currentWalletProtocolParameters(response.data); }; - return { + const providerFunctions = { ledgerTip, networkInfo, stakePoolStats, @@ -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; }; diff --git a/packages/cardano-graphql-db-sync/src/cardanoGraphqlDbSyncProvider.ts b/packages/cardano-graphql-db-sync/src/cardanoGraphqlDbSyncProvider.ts index 1ea377298d2..cd09a9b1b16 100644 --- a/packages/cardano-graphql-db-sync/src/cardanoGraphqlDbSyncProvider.ts +++ b/packages/cardano-graphql-db-sync/src/cardanoGraphqlDbSyncProvider.ts @@ -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'; @@ -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); } }; diff --git a/packages/core/src/Provider/errors.ts b/packages/core/src/Provider/errors.ts new file mode 100644 index 00000000000..cf8cbe3ac82 --- /dev/null +++ b/packages/core/src/Provider/errors.ts @@ -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})` : '')); + } +} diff --git a/packages/core/src/Provider/index.ts b/packages/core/src/Provider/index.ts index fcb073fefcd..bfde7f83839 100644 --- a/packages/core/src/Provider/index.ts +++ b/packages/core/src/Provider/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './errors'; diff --git a/packages/core/src/Provider/types.ts b/packages/core/src/Provider/types.ts index bfcf5224625..f0e74c8c62e 100644 --- a/packages/core/src/Provider/types.ts +++ b/packages/core/src/Provider/types.ts @@ -59,7 +59,7 @@ export interface CardanoProvider { networkInfo: () => Promise; stakePoolStats?: () => Promise; /** @param signedTransaction signed and serialized cbor */ - submitTx: (tx: CSL.Transaction) => Promise; + submitTx: (signedTransaction: CSL.Transaction) => Promise; utxoDelegationAndRewards: ( addresses: Cardano.Address[], stakeKeyHash: Cardano.Hash16 diff --git a/packages/wallet/src/InMemoryTransactionTracker.ts b/packages/wallet/src/InMemoryTransactionTracker.ts index b55fe0ae706..424b9dee4b6 100644 --- a/packages/wallet/src/InMemoryTransactionTracker.ts +++ b/packages/wallet/src/InMemoryTransactionTracker.ts @@ -1,9 +1,9 @@ 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; @@ -11,7 +11,7 @@ export type Milliseconds = number; export interface InMemoryTransactionTrackerProps { provider: CardanoProvider; csl: CardanoSerializationLib; - logger: Logger; + logger?: Logger; pollInterval?: Milliseconds; } @@ -22,7 +22,7 @@ export class InMemoryTransactionTracker extends Emittery { + async #trackTransaction(hash: Hash16, invalidHereafter: Slot): Promise { 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(); @@ -81,41 +80,6 @@ export class InMemoryTransactionTracker extends Emittery 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); } } diff --git a/packages/wallet/src/SingleAddressWallet.ts b/packages/wallet/src/SingleAddressWallet.ts index 80604ca48b7..72eb86ac688 100644 --- a/packages/wallet/src/SingleAddressWallet.ts +++ b/packages/wallet/src/SingleAddressWallet.ts @@ -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; + /** + * Resolves when transaction is submitted and confirmed. + * Rejects with TransactionError. + */ + confirmed: Promise; +} export interface SingleAddressWallet { address: Schema.Address; initializeTx: (props: InitializeTxProps) => Promise; name: string; signTx: (body: CSL.TransactionBody, hash: CSL.TransactionHash) => Promise; - submitTx: (tx: CSL.Transaction) => Promise; + /** + * Submits transaction. + * + * @returns {Promise} promise that resolves when transaction is submitted, + * but not confirmed yet. Rejects with TransactionError { FailedToSubmit } + */ + submitTx: (tx: CSL.Transaction) => SubmitTxResult; } export interface SingleAddressWalletDependencies { @@ -20,6 +38,7 @@ export interface SingleAddressWalletDependencies { logger?: Logger; provider: CardanoProvider; utxoRepository: UtxoRepository; + txTracker: TransactionTracker; } export interface SingleAddressWalletProps { @@ -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 => { const address = keyManager.deriveAddress(0, 0); const protocolParameters = await provider.currentWalletProtocolParameters(); @@ -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)) + }; + } }; }; diff --git a/packages/wallet/src/Transaction/TransactionError.ts b/packages/wallet/src/Transaction/TransactionError.ts index 2e829e0eb3d..9a20c714a11 100644 --- a/packages/wallet/src/Transaction/TransactionError.ts +++ b/packages/wallet/src/Transaction/TransactionError.ts @@ -2,7 +2,6 @@ import { CustomError } from 'ts-custom-error'; export enum TransactionFailure { CannotTrack = 'CANNOT_TRACK', - Unknown = 'UNKNOWN', Timeout = 'TIMEOUT' } diff --git a/packages/wallet/test/InMemoryTransactionTracker.test.ts b/packages/wallet/test/InMemoryTransactionTracker.test.ts index e111417ac61..5ba706bbf64 100644 --- a/packages/wallet/test/InMemoryTransactionTracker.test.ts +++ b/packages/wallet/test/InMemoryTransactionTracker.test.ts @@ -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'; @@ -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); @@ -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); diff --git a/packages/wallet/test/ProviderStub.ts b/packages/wallet/test/ProviderStub.ts index 0579d892854..b82efe5bf3c 100644 --- a/packages/wallet/test/ProviderStub.ts +++ b/packages/wallet/test/ProviderStub.ts @@ -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, diff --git a/packages/wallet/test/SingleAddressWallet.test.ts b/packages/wallet/test/SingleAddressWallet.test.ts index 1458de311b3..471316ca095 100644 --- a/packages/wallet/test/SingleAddressWallet.test.ts +++ b/packages/wallet/test/SingleAddressWallet.test.ts @@ -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, @@ -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; @@ -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 () => { @@ -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); }); }); }); From 6373e7e85c7f0d42c100aa8223eb8e197c525a45 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Tue, 12 Oct 2021 15:35:39 +0300 Subject: [PATCH 6/9] refactor(wallet): move TransactionError to pkg root, create examples/withdrawal.ts, enable coverage --- packages/wallet/.gitignore | 1 + packages/wallet/README.md | 29 ++------- packages/wallet/examples/withdrawal.ts | 62 +++++++++++++++++++ packages/wallet/package.json | 2 +- .../wallet/src/InMemoryTransactionTracker.ts | 2 +- packages/wallet/src/InMemoryUtxoRepository.ts | 2 +- .../src/{Transaction => }/TransactionError.ts | 0 packages/wallet/src/index.ts | 1 + .../test/InMemoryTransactionTracker.test.ts | 2 +- .../test/InMemoryUtxoRepository.test.ts | 2 +- 10 files changed, 74 insertions(+), 29 deletions(-) create mode 100644 packages/wallet/examples/withdrawal.ts rename packages/wallet/src/{Transaction => }/TransactionError.ts (100%) diff --git a/packages/wallet/.gitignore b/packages/wallet/.gitignore index f06235c460c..5a19e8ace41 100644 --- a/packages/wallet/.gitignore +++ b/packages/wallet/.gitignore @@ -1,2 +1,3 @@ node_modules dist +coverage \ No newline at end of file diff --git a/packages/wallet/README.md b/packages/wallet/README.md index 3cde99a3afc..07c8f4ee288 100644 --- a/packages/wallet/README.md +++ b/packages/wallet/README.md @@ -1,29 +1,10 @@ # Cardano JS SDK | Wallet -# Examples +See [examples] -## Delegation +## Tests -```typescript -import { loadCardanoSerializationLib } from '@cardano-sdk/core'; -import { createSingleAddressWallet, KeyManagement, Transaction, SingleAddressWalletDependencies } from '@cardano-sdk/wallet'; +See [code coverage report] -async () => { - const csl = await loadCardanoSerializationLib(); - const keyManager = KeyManagement.createInMemoryKeyManager({ csl, ... }); - const wallet = await createSingleAddressWallet({ name: 'some-wallet' }, { csl, keyManager, ... }); - - const certs = new Transaction.CertificateFactory(keyManager); - const { body, hash } = await wallet.initializeTx({ - certificates: [certs.stakeKeyDeregistration()], - withdrawals: [Transaction.withdrawal(csl, keyManager, 5_000_000n)], - ... - }); - - // Calculated fee is returned by invoking body.fee() - - const tx = await wallet.signTx(body, hash); - - await wallet.submitTx(tx); -} -``` +[examples]: https://github.com/input-output-hk/cardano-js-sdk/tree/master/packages/wallet/examples +[code coverage report]: https://input-output-hk.github.io/cardano-js-sdk/coverage/wallet diff --git a/packages/wallet/examples/withdrawal.ts b/packages/wallet/examples/withdrawal.ts new file mode 100644 index 00000000000..7cb400a60bb --- /dev/null +++ b/packages/wallet/examples/withdrawal.ts @@ -0,0 +1,62 @@ +import { TxOut } from '@cardano-ogmios/schema'; +import { roundRobinRandomImprove } from '@cardano-sdk/cip2'; +import { Cardano, loadCardanoSerializationLib } from '@cardano-sdk/core'; +import { blockfrostProvider, Options } from '@cardano-sdk/blockfrost'; +import { + createSingleAddressWallet, + InMemoryTransactionTracker, + InMemoryUtxoRepository, + KeyManagement, + SingleAddressWalletProps, + Transaction, + TransactionError, + TransactionFailure +} from '@cardano-sdk/wallet'; + +const walletProps: SingleAddressWalletProps = { name: 'some-wallet' }; +const networkId = Cardano.NetworkId.mainnet; +const mnemonicWords = ['your', 'mnemonic']; +const password = 'your_password'; +const blockfrostOptions: Options = { projectId: 'your-project-id' }; + +export const withdrawAll = async (outputs: Set) => { + const csl = await loadCardanoSerializationLib(); + const keyManager = KeyManagement.createInMemoryKeyManager({ csl, mnemonicWords, password, networkId }); + const provider = blockfrostProvider(blockfrostOptions); + const txTracker = new InMemoryTransactionTracker({ csl, provider }); + const inputSelector = roundRobinRandomImprove(csl); + const utxoRepository = new InMemoryUtxoRepository({ csl, provider, txTracker, inputSelector, keyManager }); + const wallet = await createSingleAddressWallet(walletProps, { csl, keyManager, provider, utxoRepository, txTracker }); + + utxoRepository.on('transactionUntracked', (tx) => { + // UtxoRepository is not sure whether it's UTxO can be spent due to failing to track transaction confirmation. + // SubmitTxResult.confirmed has rejected. Calling trackTransaction will lock UTxO again: + txTracker.trackTransaction(tx).catch((error) => { + /* eslint-disable-next-line sonarjs/no-all-duplicated-branches */ + if (error instanceof TransactionError && error.reason === TransactionFailure.Timeout) { + // Transaction has expired and will not be confirmed. Therefore it's safe to spend the UTxO again. + } else { + // Probably wait a little bit and retry + } + }); + }); + + // Call this to sync available balance + await utxoRepository.sync(); + + const certFactory = new Transaction.CertificateFactory(csl, keyManager); + + const { body, hash } = await wallet.initializeTx({ + certificates: [certFactory.stakeKeyDeregistration()], + withdrawals: [Transaction.withdrawal(csl, keyManager, utxoRepository.rewards || 0)], + outputs + }); + // Calculated fee is returned by invoking body.fee() + const tx = await wallet.signTx(body, hash); + + const { submitted, confirmed } = wallet.submitTx(tx); + // Transaction is submitting. UTxO is locked. + await submitted; + // Transaction is successfully submitted, but not confirmed yet + await confirmed; +}; diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 3fcea237ea1..6869b9ad2d2 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -17,7 +17,7 @@ "build": "tsc --build ./src", "tscNoEmit": "shx echo typescript --noEmit command not implemented yet", "cleanup": "shx rm -rf dist node_modules", - "coverage": "shx echo No coverage report for this package", + "coverage": "yarn test --coverage", "lint": "eslint --ignore-path ../../.eslintignore \"**/*.ts\"", "test": "jest -c ./jest.config.js", "test:debug": "DEBUG=true yarn test" diff --git a/packages/wallet/src/InMemoryTransactionTracker.ts b/packages/wallet/src/InMemoryTransactionTracker.ts index 424b9dee4b6..e73a800a6c2 100644 --- a/packages/wallet/src/InMemoryTransactionTracker.ts +++ b/packages/wallet/src/InMemoryTransactionTracker.ts @@ -2,7 +2,7 @@ import { TransactionTracker, TransactionTrackerEvents } from './types'; import Emittery from 'emittery'; import { Hash16, Slot, Tip } from '@cardano-ogmios/schema'; import { CardanoProvider, ProviderError, CardanoSerializationLib, CSL, ProviderFailure } from '@cardano-sdk/core'; -import { TransactionError, TransactionFailure } from './Transaction/TransactionError'; +import { TransactionError, TransactionFailure } from './TransactionError'; import { dummyLogger, Logger } from 'ts-log'; import delay from 'delay'; diff --git a/packages/wallet/src/InMemoryUtxoRepository.ts b/packages/wallet/src/InMemoryUtxoRepository.ts index 855d2cee39f..c3fae208dd9 100644 --- a/packages/wallet/src/InMemoryUtxoRepository.ts +++ b/packages/wallet/src/InMemoryUtxoRepository.ts @@ -8,7 +8,7 @@ import { KeyManager } from './KeyManagement'; import { OnTransactionArgs, TransactionTracker, UtxoRepositoryEvents } from '.'; import { cslToOgmios } from '@cardano-sdk/core/src/Ogmios'; import Emittery from 'emittery'; -import { TransactionError, TransactionFailure } from './Transaction/TransactionError'; +import { TransactionError, TransactionFailure } from './TransactionError'; export interface InMemoryUtxoRepositoryProps { csl: CardanoSerializationLib; diff --git a/packages/wallet/src/Transaction/TransactionError.ts b/packages/wallet/src/TransactionError.ts similarity index 100% rename from packages/wallet/src/Transaction/TransactionError.ts rename to packages/wallet/src/TransactionError.ts diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 0f783f79852..666e54962b1 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -5,3 +5,4 @@ export * from './InMemoryTransactionTracker'; export * as KeyManagement from './KeyManagement'; export * from './SingleAddressWallet'; export * from './types'; +export * from './TransactionError'; diff --git a/packages/wallet/test/InMemoryTransactionTracker.test.ts b/packages/wallet/test/InMemoryTransactionTracker.test.ts index 5ba706bbf64..43fcc4a11a5 100644 --- a/packages/wallet/test/InMemoryTransactionTracker.test.ts +++ b/packages/wallet/test/InMemoryTransactionTracker.test.ts @@ -1,7 +1,7 @@ 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'; +import { TransactionFailure } from '../src/TransactionError'; import { ledgerTip, providerStub, ProviderStub, queryTransactionsResult } from './ProviderStub'; import mockDelay from 'delay'; diff --git a/packages/wallet/test/InMemoryUtxoRepository.test.ts b/packages/wallet/test/InMemoryUtxoRepository.test.ts index c0d41fc972d..33540a0083c 100644 --- a/packages/wallet/test/InMemoryUtxoRepository.test.ts +++ b/packages/wallet/test/InMemoryUtxoRepository.test.ts @@ -7,7 +7,7 @@ import { InMemoryUtxoRepository, KeyManagement, UtxoRepository } from '../src'; import { MockTransactionTracker } from './mockTransactionTracker'; import { ogmiosToCsl } from '@cardano-sdk/core/src/Ogmios'; import { TxIn, TxOut } from '@cardano-ogmios/schema'; -import { TransactionError, TransactionFailure } from '../src/Transaction/TransactionError'; +import { TransactionError, TransactionFailure } from '../src/TransactionError'; const addresses = [ 'addr_test1qq585l3hyxgj3nas2v3xymd23vvartfhceme6gv98aaeg9muzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q2g7k3g' From 0008368293f9dac705fdcbd7e240e0e88046f7e8 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Tue, 12 Oct 2021 15:45:33 +0300 Subject: [PATCH 7/9] fix(wallet): lock utxo right after submitting, run input selection with availableUtxo set --- .../wallet/src/InMemoryTransactionTracker.ts | 3 ++- packages/wallet/src/InMemoryUtxoRepository.ts | 3 +-- packages/wallet/src/SingleAddressWallet.ts | 24 +++++++++---------- packages/wallet/src/TransactionError.ts | 2 ++ packages/wallet/src/types.ts | 2 +- .../wallet/test/SingleAddressWallet.test.ts | 4 ++-- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/wallet/src/InMemoryTransactionTracker.ts b/packages/wallet/src/InMemoryTransactionTracker.ts index e73a800a6c2..a9d37c76696 100644 --- a/packages/wallet/src/InMemoryTransactionTracker.ts +++ b/packages/wallet/src/InMemoryTransactionTracker.ts @@ -30,7 +30,8 @@ export class InMemoryTransactionTracker extends Emittery { + async trackTransaction(transaction: CSL.Transaction, submitted: Promise = Promise.resolve()): Promise { + await submitted; const body = transaction.body(); const hash = Buffer.from(this.#csl.hash_transaction(body).to_bytes()).toString('hex'); this.#logger.debug('InMemoryTransactionTracker.trackTransaction', hash); diff --git a/packages/wallet/src/InMemoryUtxoRepository.ts b/packages/wallet/src/InMemoryUtxoRepository.ts index c3fae208dd9..69da6228ac3 100644 --- a/packages/wallet/src/InMemoryUtxoRepository.ts +++ b/packages/wallet/src/InMemoryUtxoRepository.ts @@ -19,7 +19,6 @@ export interface InMemoryUtxoRepositoryProps { logger?: Logger; } -// Review: is comparing txIn enough to identify unique utxo? const utxoEquals = ([txIn1]: [Schema.TxIn, Schema.TxOut], [txIn2]: [Schema.TxIn, Schema.TxOut]): boolean => txIn1.txId === txIn2.txId && txIn1.index === txIn2.index; export class InMemoryUtxoRepository extends Emittery implements UtxoRepository { @@ -93,7 +92,7 @@ export class InMemoryUtxoRepository extends Emittery imple await this.sync(); } return this.#inputSelector.select({ - utxo: new Set(Ogmios.ogmiosToCsl(this.#csl).utxo(this.allUtxos)), + utxo: new Set(Ogmios.ogmiosToCsl(this.#csl).utxo(this.availableUtxos)), outputs, constraints, implicitCoin diff --git a/packages/wallet/src/SingleAddressWallet.ts b/packages/wallet/src/SingleAddressWallet.ts index 72eb86ac688..d83df5eda74 100644 --- a/packages/wallet/src/SingleAddressWallet.ts +++ b/packages/wallet/src/SingleAddressWallet.ts @@ -1,20 +1,20 @@ import Schema from '@cardano-ogmios/schema'; -import { CardanoProvider, Ogmios, Transaction, CardanoSerializationLib, CSL } from '@cardano-sdk/core'; +import { CardanoProvider, Ogmios, Transaction, CardanoSerializationLib, CSL, ProviderError } from '@cardano-sdk/core'; 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, TransactionTracker } from '.'; +import { KeyManagement, TransactionError, TransactionFailure, TransactionTracker } from '.'; export interface SubmitTxResult { /** * Resolves when transaction is submitted. - * Rejects with ProviderError. + * Rejects with {TransactionError}. */ submitted: Promise; /** * Resolves when transaction is submitted and confirmed. - * Rejects with TransactionError. + * Rejects with {TransactionError}. */ confirmed: Promise; } @@ -23,12 +23,6 @@ export interface SingleAddressWallet { initializeTx: (props: InitializeTxProps) => Promise; name: string; signTx: (body: CSL.TransactionBody, hash: CSL.TransactionHash) => Promise; - /** - * Submits transaction. - * - * @returns {Promise} promise that resolves when transaction is submitted, - * but not confirmed yet. Rejects with TransactionError { FailedToSubmit } - */ submitTx: (tx: CSL.Transaction) => SubmitTxResult; } @@ -92,10 +86,16 @@ export const createSingleAddressWallet = async ( name, signTx, submitTx: (tx) => { - const submitted = provider.submitTx(tx); + const submitted = provider.submitTx(tx).catch((error) => { + if (error instanceof ProviderError) { + throw new TransactionError(TransactionFailure.FailedToSubmit, error, error.detail); + } + throw new TransactionError(TransactionFailure.FailedToSubmit, error); + }); + const confirmed = txTracker.trackTransaction(tx, submitted); return { submitted, - confirmed: submitted.then(() => txTracker.trackTransaction(tx)) + confirmed }; } }; diff --git a/packages/wallet/src/TransactionError.ts b/packages/wallet/src/TransactionError.ts index 9a20c714a11..7a1146dbfc9 100644 --- a/packages/wallet/src/TransactionError.ts +++ b/packages/wallet/src/TransactionError.ts @@ -1,6 +1,8 @@ import { CustomError } from 'ts-custom-error'; export enum TransactionFailure { + FailedToSubmit = 'FAILED_TO_SUBMIT', + Unknown = 'UNKNOWN', CannotTrack = 'CANNOT_TRACK', Timeout = 'TIMEOUT' } diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index dc5ad85e28f..7ab75318e44 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -30,5 +30,5 @@ export interface TransactionTracker extends Emittery { /** * Track a new transaction */ - trackTransaction(transaction: CSL.Transaction): Promise; + trackTransaction(transaction: CSL.Transaction, submitted?: Promise): Promise; } diff --git a/packages/wallet/test/SingleAddressWallet.test.ts b/packages/wallet/test/SingleAddressWallet.test.ts index 471316ca095..8388dc30360 100644 --- a/packages/wallet/test/SingleAddressWallet.test.ts +++ b/packages/wallet/test/SingleAddressWallet.test.ts @@ -75,12 +75,12 @@ describe('Wallet', () => { test('submitTx', async () => { const { body, hash } = await wallet.initializeTx(props); const tx = await wallet.signTx(body, hash); - const { confirmed } = wallet.submitTx(tx); + const { submitted, 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); + expect(txTracker.trackTransaction).toBeCalledWith(tx, submitted); }); }); }); From 63e270c04eb035a06a6288c63de0c783331a6b04 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Wed, 13 Oct 2021 09:39:13 +0300 Subject: [PATCH 8/9] test(wallet): move withdrawal example to an integration test --- packages/wallet/README.md | 4 +- packages/wallet/examples/withdrawal.ts | 62 -------------- .../test/integration/withdrawal.test.ts | 82 +++++++++++++++++++ 3 files changed, 84 insertions(+), 64 deletions(-) delete mode 100644 packages/wallet/examples/withdrawal.ts create mode 100644 packages/wallet/test/integration/withdrawal.test.ts diff --git a/packages/wallet/README.md b/packages/wallet/README.md index 07c8f4ee288..41fd3d6bb4c 100644 --- a/packages/wallet/README.md +++ b/packages/wallet/README.md @@ -1,10 +1,10 @@ # Cardano JS SDK | Wallet -See [examples] +See [integration tests] for usage examples ## Tests See [code coverage report] -[examples]: https://github.com/input-output-hk/cardano-js-sdk/tree/master/packages/wallet/examples +[integration tests]: https://github.com/input-output-hk/cardano-js-sdk/tree/master/packages/wallet/test/integration [code coverage report]: https://input-output-hk.github.io/cardano-js-sdk/coverage/wallet diff --git a/packages/wallet/examples/withdrawal.ts b/packages/wallet/examples/withdrawal.ts deleted file mode 100644 index 7cb400a60bb..00000000000 --- a/packages/wallet/examples/withdrawal.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { TxOut } from '@cardano-ogmios/schema'; -import { roundRobinRandomImprove } from '@cardano-sdk/cip2'; -import { Cardano, loadCardanoSerializationLib } from '@cardano-sdk/core'; -import { blockfrostProvider, Options } from '@cardano-sdk/blockfrost'; -import { - createSingleAddressWallet, - InMemoryTransactionTracker, - InMemoryUtxoRepository, - KeyManagement, - SingleAddressWalletProps, - Transaction, - TransactionError, - TransactionFailure -} from '@cardano-sdk/wallet'; - -const walletProps: SingleAddressWalletProps = { name: 'some-wallet' }; -const networkId = Cardano.NetworkId.mainnet; -const mnemonicWords = ['your', 'mnemonic']; -const password = 'your_password'; -const blockfrostOptions: Options = { projectId: 'your-project-id' }; - -export const withdrawAll = async (outputs: Set) => { - const csl = await loadCardanoSerializationLib(); - const keyManager = KeyManagement.createInMemoryKeyManager({ csl, mnemonicWords, password, networkId }); - const provider = blockfrostProvider(blockfrostOptions); - const txTracker = new InMemoryTransactionTracker({ csl, provider }); - const inputSelector = roundRobinRandomImprove(csl); - const utxoRepository = new InMemoryUtxoRepository({ csl, provider, txTracker, inputSelector, keyManager }); - const wallet = await createSingleAddressWallet(walletProps, { csl, keyManager, provider, utxoRepository, txTracker }); - - utxoRepository.on('transactionUntracked', (tx) => { - // UtxoRepository is not sure whether it's UTxO can be spent due to failing to track transaction confirmation. - // SubmitTxResult.confirmed has rejected. Calling trackTransaction will lock UTxO again: - txTracker.trackTransaction(tx).catch((error) => { - /* eslint-disable-next-line sonarjs/no-all-duplicated-branches */ - if (error instanceof TransactionError && error.reason === TransactionFailure.Timeout) { - // Transaction has expired and will not be confirmed. Therefore it's safe to spend the UTxO again. - } else { - // Probably wait a little bit and retry - } - }); - }); - - // Call this to sync available balance - await utxoRepository.sync(); - - const certFactory = new Transaction.CertificateFactory(csl, keyManager); - - const { body, hash } = await wallet.initializeTx({ - certificates: [certFactory.stakeKeyDeregistration()], - withdrawals: [Transaction.withdrawal(csl, keyManager, utxoRepository.rewards || 0)], - outputs - }); - // Calculated fee is returned by invoking body.fee() - const tx = await wallet.signTx(body, hash); - - const { submitted, confirmed } = wallet.submitTx(tx); - // Transaction is submitting. UTxO is locked. - await submitted; - // Transaction is successfully submitted, but not confirmed yet - await confirmed; -}; diff --git a/packages/wallet/test/integration/withdrawal.test.ts b/packages/wallet/test/integration/withdrawal.test.ts new file mode 100644 index 00000000000..76a89d9fbd2 --- /dev/null +++ b/packages/wallet/test/integration/withdrawal.test.ts @@ -0,0 +1,82 @@ +import { roundRobinRandomImprove } from '@cardano-sdk/cip2'; +import { Cardano, CardanoSerializationLib, loadCardanoSerializationLib } from '@cardano-sdk/core'; +import { + createSingleAddressWallet, + InMemoryTransactionTracker, + InMemoryUtxoRepository, + KeyManagement, + SingleAddressWallet, + SingleAddressWalletProps, + Transaction, + TransactionError, + TransactionFailure, + TransactionTracker, + UtxoRepository +} from '@cardano-sdk/wallet'; +import { providerStub } from '../ProviderStub'; +// Not testing with a real provider +// import { blockfrostProvider } from '@cardano-sdk/blockfrost'; + +const walletProps: SingleAddressWalletProps = { name: 'some-wallet' }; +const networkId = Cardano.NetworkId.mainnet; +const mnemonicWords = KeyManagement.util.generateMnemonicWords(); +const password = 'your_password'; + +describe('integration/withdrawal', () => { + let csl: CardanoSerializationLib; + let keyManager: KeyManagement.KeyManager; + let txTracker: TransactionTracker; + let utxoRepository: UtxoRepository; + let wallet: SingleAddressWallet; + + beforeAll(async () => { + csl = await loadCardanoSerializationLib(); + keyManager = KeyManagement.createInMemoryKeyManager({ csl, mnemonicWords, password, networkId }); + const provider = providerStub(); + const inputSelector = roundRobinRandomImprove(csl); + txTracker = new InMemoryTransactionTracker({ csl, provider }); + utxoRepository = new InMemoryUtxoRepository({ csl, provider, txTracker, inputSelector, keyManager }); + wallet = await createSingleAddressWallet(walletProps, { + csl, + keyManager, + provider, + utxoRepository, + txTracker + }); + + // Call this to sync available balance + await utxoRepository.sync(); + }); + + it('does not throw', async () => { + // This is not testing anything, just a usage example + utxoRepository.on('transactionUntracked', (tx) => { + // UtxoRepository is not sure whether it's UTxO can be spent due to failing to track transaction confirmation. + // SubmitTxResult.confirmed has rejected. Calling trackTransaction will lock UTxO again: + txTracker.trackTransaction(tx).catch((error) => { + /* eslint-disable-next-line sonarjs/no-all-duplicated-branches */ + if (error instanceof TransactionError && error.reason === TransactionFailure.Timeout) { + // Transaction has expired and will not be confirmed. Therefore it's safe to spend the UTxO again. + } else { + // Probably wait a little bit and retry + } + }); + }); + + const certFactory = new Transaction.CertificateFactory(csl, keyManager); + + const { body, hash } = await wallet.initializeTx({ + certificates: [certFactory.stakeKeyDeregistration()], + withdrawals: [Transaction.withdrawal(csl, keyManager, utxoRepository.rewards || 0)], + outputs: new Set() // In a real transaction you would probably want to have some outputs + }); + // Calculated fee is returned by invoking body.fee() + const tx = await wallet.signTx(body, hash); + + const { submitted, confirmed } = wallet.submitTx(tx); + // Transaction is submitting. UTxO is locked. + await submitted; + // Transaction is successfully submitted, but not confirmed yet + await confirmed; + }); +}); From a5bc91f5d411155a597662690ec7f8105f20617c Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Wed, 13 Oct 2021 09:52:41 +0300 Subject: [PATCH 9/9] refactor(wallet): create enums for event names refactor(wallet): change event-naming, rename trackTransaction->track, a few small improvements --- .../wallet/src/InMemoryTransactionTracker.ts | 11 ++++---- packages/wallet/src/InMemoryUtxoRepository.ts | 13 +++++++--- packages/wallet/src/SingleAddressWallet.ts | 2 +- packages/wallet/src/types.ts | 20 ++++++++++++--- .../test/InMemoryTransactionTracker.test.ts | 25 +++++++++---------- .../test/InMemoryUtxoRepository.test.ts | 12 ++++++--- .../wallet/test/SingleAddressWallet.test.ts | 4 +-- .../test/integration/withdrawal.test.ts | 12 ++++----- .../wallet/test/mockTransactionTracker.ts | 2 +- 9 files changed, 63 insertions(+), 38 deletions(-) diff --git a/packages/wallet/src/InMemoryTransactionTracker.ts b/packages/wallet/src/InMemoryTransactionTracker.ts index a9d37c76696..daf22e74786 100644 --- a/packages/wallet/src/InMemoryTransactionTracker.ts +++ b/packages/wallet/src/InMemoryTransactionTracker.ts @@ -5,6 +5,7 @@ import { CardanoProvider, ProviderError, CardanoSerializationLib, CSL, ProviderF import { TransactionError, TransactionFailure } from './TransactionError'; import { dummyLogger, Logger } from 'ts-log'; import delay from 'delay'; +import { TransactionTrackerEvent } from '.'; export type Milliseconds = number; @@ -30,7 +31,7 @@ export class InMemoryTransactionTracker extends Emittery = Promise.resolve()): Promise { + async track(transaction: CSL.Transaction, submitted: Promise = Promise.resolve()): Promise { await submitted; const body = transaction.body(); const hash = Buffer.from(this.#csl.hash_transaction(body).to_bytes()).toString('hex'); @@ -45,15 +46,15 @@ export class InMemoryTransactionTracker extends Emittery void 0).then(() => this.#pendingTransactions.delete(hash)); return promise; } - async #trackTransaction(hash: Hash16, invalidHereafter: Slot): Promise { + async #checkTransactionViaProvider(hash: Hash16, invalidHereafter: Slot): Promise { await delay(this.#pollInterval); try { const tx = await this.#provider.queryTransactionsByHashes([hash]); @@ -81,6 +82,6 @@ export class InMemoryTransactionTracker extends Emittery invalidHereafter) { throw new TransactionError(TransactionFailure.Timeout); } - return this.#trackTransaction(hash, invalidHereafter); + return this.#checkTransactionViaProvider(hash, invalidHereafter); } } diff --git a/packages/wallet/src/InMemoryUtxoRepository.ts b/packages/wallet/src/InMemoryUtxoRepository.ts index 69da6228ac3..7af82464d22 100644 --- a/packages/wallet/src/InMemoryUtxoRepository.ts +++ b/packages/wallet/src/InMemoryUtxoRepository.ts @@ -5,7 +5,13 @@ import { CardanoProvider, Ogmios, CardanoSerializationLib, CSL } from '@cardano- import { dummyLogger, Logger } from 'ts-log'; import { ImplicitCoin, InputSelector, SelectionConstraints, SelectionResult } from '@cardano-sdk/cip2'; import { KeyManager } from './KeyManagement'; -import { OnTransactionArgs, TransactionTracker, UtxoRepositoryEvents } from '.'; +import { + OnTransactionArgs, + TransactionTracker, + TransactionTrackerEvent, + UtxoRepositoryEvent, + UtxoRepositoryEvents +} from '.'; import { cslToOgmios } from '@cardano-sdk/core/src/Ogmios'; import Emittery from 'emittery'; import { TransactionError, TransactionFailure } from './TransactionError'; @@ -21,6 +27,7 @@ export interface InMemoryUtxoRepositoryProps { const utxoEquals = ([txIn1]: [Schema.TxIn, Schema.TxOut], [txIn2]: [Schema.TxIn, Schema.TxOut]): boolean => txIn1.txId === txIn2.txId && txIn1.index === txIn2.index; + export class InMemoryUtxoRepository extends Emittery implements UtxoRepository { #csl: CardanoSerializationLib; #delegationAndRewards: Schema.DelegationsAndRewards; @@ -47,7 +54,7 @@ export class InMemoryUtxoRepository extends Emittery imple this.#delegationAndRewards = { rewards: undefined, delegate: undefined }; this.#inputSelector = inputSelector; this.#keyManager = keyManager; - txTracker.on('transaction', (args) => { + txTracker.on(TransactionTrackerEvent.NewTransaction, (args) => { // not blocking to make it testable easier this.#onTransaction(args).catch(this.#logger.error); }); @@ -136,7 +143,7 @@ export class InMemoryUtxoRepository extends Emittery imple } catch (error) { unlock(false); if (!(error instanceof TransactionError) || error.reason !== TransactionFailure.Timeout) { - await this.emit('transactionUntracked', transaction).catch(this.#logger.error); + await this.emit(UtxoRepositoryEvent.TransactionUntracked, transaction).catch(this.#logger.error); } } } diff --git a/packages/wallet/src/SingleAddressWallet.ts b/packages/wallet/src/SingleAddressWallet.ts index d83df5eda74..0e0ebe9e385 100644 --- a/packages/wallet/src/SingleAddressWallet.ts +++ b/packages/wallet/src/SingleAddressWallet.ts @@ -92,7 +92,7 @@ export const createSingleAddressWallet = async ( } throw new TransactionError(TransactionFailure.FailedToSubmit, error); }); - const confirmed = txTracker.trackTransaction(tx, submitted); + const confirmed = txTracker.track(tx, submitted); return { submitted, confirmed diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 7ab75318e44..676c9e9db04 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -3,7 +3,11 @@ import { ImplicitCoin, SelectionConstraints, SelectionResult } from '@cardano-sd import { CSL } from '@cardano-sdk/core'; import Emittery from 'emittery'; -export type UtxoRepositoryEvents = { transactionUntracked: CSL.Transaction }; +export enum UtxoRepositoryEvent { + TransactionUntracked = 'transaction-untracked' +} + +export type UtxoRepositoryEvents = { 'transaction-untracked': CSL.Transaction }; export interface UtxoRepository extends Emittery { allUtxos: Schema.Utxo; availableUtxos: Schema.Utxo; @@ -25,10 +29,18 @@ export interface OnTransactionArgs { */ confirmed: Promise; } -export type TransactionTrackerEvents = { transaction: OnTransactionArgs }; + +export enum TransactionTrackerEvent { + NewTransaction = 'new-transaction' +} + +export type TransactionTrackerEvents = { 'new-transaction': OnTransactionArgs }; export interface TransactionTracker extends Emittery { /** - * Track a new transaction + * Track a new transaction. + * + * @param {CSL.Transaction} transaction transaction to track. + * @param {Promise} submitted defer checking for transaction confirmation until this resolves. */ - trackTransaction(transaction: CSL.Transaction, submitted?: Promise): Promise; + track(transaction: CSL.Transaction, submitted?: Promise): Promise; } diff --git a/packages/wallet/test/InMemoryTransactionTracker.test.ts b/packages/wallet/test/InMemoryTransactionTracker.test.ts index 43fcc4a11a5..916e1f423d6 100644 --- a/packages/wallet/test/InMemoryTransactionTracker.test.ts +++ b/packages/wallet/test/InMemoryTransactionTracker.test.ts @@ -1,9 +1,8 @@ import { CardanoSerializationLib, CSL, ProviderError, ProviderFailure } from '@cardano-sdk/core'; import { dummyLogger } from 'ts-log'; -import { InMemoryTransactionTracker } from '../src/InMemoryTransactionTracker'; -import { TransactionFailure } from '../src/TransactionError'; import { ledgerTip, providerStub, ProviderStub, queryTransactionsResult } from './ProviderStub'; import mockDelay from 'delay'; +import { TransactionTrackerEvent, InMemoryTransactionTracker, TransactionFailure } from '../src'; jest.mock('delay', () => jest.fn().mockResolvedValue(void 0)); @@ -37,17 +36,17 @@ describe('InMemoryTransactionTracker', () => { (mockDelay as unknown as jest.Mock).mockReset(); }); - describe('trackTransaction', () => { + describe('track', () => { let onTransaction: jest.Mock; beforeEach(() => { onTransaction = jest.fn(); - txTracker.on('transaction', onTransaction); + txTracker.on(TransactionTrackerEvent.NewTransaction, onTransaction); }); - it('invalid transaction (no ttl)', async () => { + it('cannot track transactions that have no validity interval', async () => { await expect(() => - txTracker.trackTransaction({ + txTracker.track({ body: () => ({ ttl: () => void 0 }) @@ -69,7 +68,7 @@ describe('InMemoryTransactionTracker', () => { it('throws CannotTrack on ledger tip fetch error', async () => { provider.queryTransactionsByHashes.mockResolvedValueOnce([]); provider.ledgerTip.mockRejectedValueOnce(new ProviderError(ProviderFailure.Unknown)); - await expect(txTracker.trackTransaction(transaction)).rejects.toThrowError(TransactionFailure.CannotTrack); + await expect(txTracker.track(transaction)).rejects.toThrowError(TransactionFailure.CannotTrack); expect(provider.ledgerTip).toBeCalledTimes(1); expect(provider.queryTransactionsByHashes).toBeCalledTimes(1); }); @@ -78,7 +77,7 @@ describe('InMemoryTransactionTracker', () => { // resolve [] or reject with 404 should be treated the same provider.queryTransactionsByHashes.mockResolvedValueOnce([]); provider.queryTransactionsByHashes.mockRejectedValueOnce(new ProviderError(ProviderFailure.NotFound)); - await txTracker.trackTransaction(transaction); + await txTracker.track(transaction); expect(provider.queryTransactionsByHashes).toBeCalledTimes(3); expect(mockDelay).toBeCalledTimes(3); expect(mockDelay).toBeCalledWith(POLL_INTERVAL); @@ -87,20 +86,20 @@ describe('InMemoryTransactionTracker', () => { it('throws after timeout', async () => { provider.queryTransactionsByHashes.mockResolvedValueOnce([]); provider.ledgerTip.mockResolvedValueOnce({ slot: ledgerTipSlot + 1 }); - await expect(txTracker.trackTransaction(transaction)).rejects.toThrowError(TransactionFailure.Timeout); + await expect(txTracker.track(transaction)).rejects.toThrowError(TransactionFailure.Timeout); }); it('emits "transaction" event for tracked transactions, returns promise unique per pending tx', async () => { - const promise1 = txTracker.trackTransaction(transaction); - const promise2 = txTracker.trackTransaction(transaction); + const promise1 = txTracker.track(transaction); + const promise2 = txTracker.track(transaction); await promise1; await promise2; mockHashTransactionReturn('other-hash'); - await txTracker.trackTransaction(transaction); + await txTracker.track(transaction); expect(provider.queryTransactionsByHashes).toBeCalledTimes(2); expect(onTransaction).toBeCalledTimes(2); // assert it clears cache - await txTracker.trackTransaction(transaction); + await txTracker.track(transaction); expect(provider.queryTransactionsByHashes).toBeCalledTimes(3); expect(onTransaction).toBeCalledTimes(3); }); diff --git a/packages/wallet/test/InMemoryUtxoRepository.test.ts b/packages/wallet/test/InMemoryUtxoRepository.test.ts index 33540a0083c..ae00f686e33 100644 --- a/packages/wallet/test/InMemoryUtxoRepository.test.ts +++ b/packages/wallet/test/InMemoryUtxoRepository.test.ts @@ -3,7 +3,13 @@ import { roundRobinRandomImprove, InputSelector } from '@cardano-sdk/cip2'; import { loadCardanoSerializationLib, CardanoSerializationLib, CSL, Ogmios } from '@cardano-sdk/core'; import { flushPromises, SelectionConstraints } from '@cardano-sdk/util-dev'; import { providerStub, delegate, rewards, ProviderStub, utxo, delegationAndRewards } from './ProviderStub'; -import { InMemoryUtxoRepository, KeyManagement, UtxoRepository } from '../src'; +import { + InMemoryUtxoRepository, + KeyManagement, + TransactionTrackerEvent, + UtxoRepository, + UtxoRepositoryEvent +} from '../src'; import { MockTransactionTracker } from './mockTransactionTracker'; import { ogmiosToCsl } from '@cardano-sdk/core/src/Ogmios'; import { TxIn, TxOut } from '@cardano-ogmios/schema'; @@ -87,7 +93,7 @@ describe('InMemoryUtxoRepository', () => { let onTransactionUntracked: jest.Mock; const trackTransaction = async (confirmed: Promise) => { - await txTracker.emit('transaction', { + await txTracker.emit(TransactionTrackerEvent.NewTransaction, { transaction, confirmed }); @@ -109,7 +115,7 @@ describe('InMemoryUtxoRepository', () => { await utxoRepository.sync(); numUtxoPreTransaction = utxoRepository.allUtxos.length; onTransactionUntracked = jest.fn(); - utxoRepository.on('transactionUntracked', onTransactionUntracked); + utxoRepository.on(UtxoRepositoryEvent.TransactionUntracked, onTransactionUntracked); }); it('preconditions', () => { diff --git a/packages/wallet/test/SingleAddressWallet.test.ts b/packages/wallet/test/SingleAddressWallet.test.ts index 8388dc30360..2556a45a306 100644 --- a/packages/wallet/test/SingleAddressWallet.test.ts +++ b/packages/wallet/test/SingleAddressWallet.test.ts @@ -79,8 +79,8 @@ describe('Wallet', () => { await confirmed; expect(provider.submitTx).toBeCalledTimes(1); expect(provider.submitTx).toBeCalledWith(tx); - expect(txTracker.trackTransaction).toBeCalledTimes(1); - expect(txTracker.trackTransaction).toBeCalledWith(tx, submitted); + expect(txTracker.track).toBeCalledTimes(1); + expect(txTracker.track).toBeCalledWith(tx, submitted); }); }); }); diff --git a/packages/wallet/test/integration/withdrawal.test.ts b/packages/wallet/test/integration/withdrawal.test.ts index 76a89d9fbd2..cfac5f8b069 100644 --- a/packages/wallet/test/integration/withdrawal.test.ts +++ b/packages/wallet/test/integration/withdrawal.test.ts @@ -11,11 +11,11 @@ import { TransactionError, TransactionFailure, TransactionTracker, - UtxoRepository + UtxoRepository, + UtxoRepositoryEvent } from '@cardano-sdk/wallet'; -import { providerStub } from '../ProviderStub'; // Not testing with a real provider -// import { blockfrostProvider } from '@cardano-sdk/blockfrost'; +import { providerStub } from '../ProviderStub'; const walletProps: SingleAddressWalletProps = { name: 'some-wallet' }; const networkId = Cardano.NetworkId.mainnet; @@ -50,10 +50,10 @@ describe('integration/withdrawal', () => { it('does not throw', async () => { // This is not testing anything, just a usage example - utxoRepository.on('transactionUntracked', (tx) => { + utxoRepository.on(UtxoRepositoryEvent.TransactionUntracked, (tx) => { // UtxoRepository is not sure whether it's UTxO can be spent due to failing to track transaction confirmation. - // SubmitTxResult.confirmed has rejected. Calling trackTransaction will lock UTxO again: - txTracker.trackTransaction(tx).catch((error) => { + // SubmitTxResult.confirmed has rejected. Calling track() will lock UTxO again: + txTracker.track(tx).catch((error) => { /* eslint-disable-next-line sonarjs/no-all-duplicated-branches */ if (error instanceof TransactionError && error.reason === TransactionFailure.Timeout) { // Transaction has expired and will not be confirmed. Therefore it's safe to spend the UTxO again. diff --git a/packages/wallet/test/mockTransactionTracker.ts b/packages/wallet/test/mockTransactionTracker.ts index 1fe5f59c2f2..eb1d381df0c 100644 --- a/packages/wallet/test/mockTransactionTracker.ts +++ b/packages/wallet/test/mockTransactionTracker.ts @@ -2,7 +2,7 @@ import Emittery from 'emittery'; import { TransactionTrackerEvents } from '../src'; export class MockTransactionTracker extends Emittery { - trackTransaction = jest.fn(); + track = jest.fn(); } export const txTracker = new MockTransactionTracker();