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/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/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/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/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/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'); + }); }); 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)); 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..41fd3d6bb4c 100644 --- a/packages/wallet/README.md +++ b/packages/wallet/README.md @@ -1,29 +1,10 @@ # Cardano JS SDK | Wallet -# Examples +See [integration tests] for usage 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); -} -``` +[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/package.json b/packages/wallet/package.json index 6f139d33309..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" @@ -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..daf22e74786 --- /dev/null +++ b/packages/wallet/src/InMemoryTransactionTracker.ts @@ -0,0 +1,87 @@ +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 './TransactionError'; +import { dummyLogger, Logger } from 'ts-log'; +import delay from 'delay'; +import { TransactionTrackerEvent } from '.'; + +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 = dummyLogger, pollInterval = 2000 }: InMemoryTransactionTrackerProps) { + super(); + this.#provider = provider; + this.#csl = csl; + this.#logger = logger; + this.#pollInterval = pollInterval; + } + + 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'); + 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.#checkTransactionViaProvider(hash, invalidHereafter); + this.#pendingTransactions.set(hash, promise); + this.emit(TransactionTrackerEvent.NewTransaction, { transaction, confirmed: promise }).catch(this.#logger.error); + void promise.catch(() => void 0).then(() => this.#pendingTransactions.delete(hash)); + + return promise; + } + + async #checkTransactionViaProvider(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); + } catch (error: unknown) { + if (error instanceof ProviderError && error.reason === ProviderFailure.NotFound) { + return this.#onTransactionNotFound(hash, invalidHereafter); + } + throw new TransactionError(TransactionFailure.CannotTrack, error); + } + } + + async #onTransactionNotFound(hash: string, invalidHereafter: 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.#checkTransactionViaProvider(hash, invalidHereafter); + } +} diff --git a/packages/wallet/src/InMemoryUtxoRepository.ts b/packages/wallet/src/InMemoryUtxoRepository.ts index 6499e787800..7af82464d22 100644 --- a/packages/wallet/src/InMemoryUtxoRepository.ts +++ b/packages/wallet/src/InMemoryUtxoRepository.ts @@ -5,8 +5,30 @@ 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, + TransactionTrackerEvent, + UtxoRepositoryEvent, + UtxoRepositoryEvents +} from '.'; +import { cslToOgmios } from '@cardano-sdk/core/src/Ogmios'; +import Emittery from 'emittery'; +import { TransactionError, TransactionFailure } from './TransactionError'; -export class InMemoryUtxoRepository implements UtxoRepository { +export interface InMemoryUtxoRepositoryProps { + csl: CardanoSerializationLib; + provider: CardanoProvider; + keyManager: KeyManager; + inputSelector: InputSelector; + txTracker: TransactionTracker; + logger?: Logger; +} + +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 +36,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(TransactionTrackerEvent.NewTransaction, (args) => { + // not blocking to make it testable easier + this.#onTransaction(args).catch(this.#logger.error); + }); } public async sync(): Promise { @@ -39,11 +68,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); @@ -64,7 +99,7 @@ export class InMemoryUtxoRepository implements UtxoRepository { 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 @@ -75,6 +110,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 +121,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(UtxoRepositoryEvent.TransactionUntracked, transaction).catch(this.#logger.error); + } + } + } } diff --git a/packages/wallet/src/SingleAddressWallet.ts b/packages/wallet/src/SingleAddressWallet.ts index 80604ca48b7..0e0ebe9e385 100644 --- a/packages/wallet/src/SingleAddressWallet.ts +++ b/packages/wallet/src/SingleAddressWallet.ts @@ -1,17 +1,29 @@ 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 } from '.'; +import { KeyManagement, TransactionError, TransactionFailure, TransactionTracker } from '.'; +export interface SubmitTxResult { + /** + * Resolves when transaction is submitted. + * Rejects with {TransactionError}. + */ + 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; + submitTx: (tx: CSL.Transaction) => SubmitTxResult; } export interface SingleAddressWalletDependencies { @@ -20,6 +32,7 @@ export interface SingleAddressWalletDependencies { logger?: Logger; provider: CardanoProvider; utxoRepository: UtxoRepository; + txTracker: TransactionTracker; } export interface SingleAddressWalletProps { @@ -35,7 +48,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 +85,18 @@ export const createSingleAddressWallet = async ( }, name, signTx, - submitTx: async (tx) => provider.submitTx(tx) + 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.track(tx, submitted); + return { + submitted, + confirmed + }; + } }; }; diff --git a/packages/wallet/src/TransactionError.ts b/packages/wallet/src/TransactionError.ts new file mode 100644 index 00000000000..7a1146dbfc9 --- /dev/null +++ b/packages/wallet/src/TransactionError.ts @@ -0,0 +1,16 @@ +import { CustomError } from 'ts-custom-error'; + +export enum TransactionFailure { + FailedToSubmit = 'FAILED_TO_SUBMIT', + Unknown = 'UNKNOWN', + CannotTrack = 'CANNOT_TRACK', + 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..666e54962b1 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1,6 +1,8 @@ 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'; +export * from './TransactionError'; diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 2a46e91be3b..676c9e9db04 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -1,9 +1,16 @@ 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 enum UtxoRepositoryEvent { + TransactionUntracked = 'transaction-untracked' +} + +export type UtxoRepositoryEvents = { 'transaction-untracked': 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 +20,27 @@ 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 enum TransactionTrackerEvent { + NewTransaction = 'new-transaction' +} + +export type TransactionTrackerEvents = { 'new-transaction': OnTransactionArgs }; +export interface TransactionTracker extends Emittery { + /** + * Track a new transaction. + * + * @param {CSL.Transaction} transaction transaction to track. + * @param {Promise} submitted defer checking for transaction confirmation until this resolves. + */ + track(transaction: CSL.Transaction, submitted?: Promise): Promise; +} diff --git a/packages/wallet/test/InMemoryTransactionTracker.test.ts b/packages/wallet/test/InMemoryTransactionTracker.test.ts new file mode 100644 index 00000000000..916e1f423d6 --- /dev/null +++ b/packages/wallet/test/InMemoryTransactionTracker.test.ts @@ -0,0 +1,108 @@ +import { CardanoSerializationLib, CSL, ProviderError, ProviderFailure } from '@cardano-sdk/core'; +import { dummyLogger } from 'ts-log'; +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)); + +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('track', () => { + let onTransaction: jest.Mock; + + beforeEach(() => { + onTransaction = jest.fn(); + txTracker.on(TransactionTrackerEvent.NewTransaction, onTransaction); + }); + + it('cannot track transactions that have no validity interval', async () => { + await expect(() => + txTracker.track({ + 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 ProviderError(ProviderFailure.Unknown)); + await expect(txTracker.track(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(new ProviderError(ProviderFailure.NotFound)); + await txTracker.track(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.track(transaction)).rejects.toThrowError(TransactionFailure.Timeout); + }); + + it('emits "transaction" event for tracked transactions, returns promise unique per pending tx', async () => { + const promise1 = txTracker.track(transaction); + const promise2 = txTracker.track(transaction); + await promise1; + await promise2; + mockHashTransactionReturn('other-hash'); + await txTracker.track(transaction); + expect(provider.queryTransactionsByHashes).toBeCalledTimes(2); + expect(onTransaction).toBeCalledTimes(2); + // assert it clears cache + 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 2de3b32632d..ae00f686e33 100644 --- a/packages/wallet/test/InMemoryUtxoRepository.test.ts +++ b/packages/wallet/test/InMemoryUtxoRepository.test.ts @@ -1,8 +1,19 @@ +/* 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 { InMemoryUtxoRepository, KeyManagement, UtxoRepository } from '../src'; +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, + TransactionTrackerEvent, + UtxoRepository, + UtxoRepositoryEvent +} 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/TransactionError'; const addresses = [ 'addr_test1qq585l3hyxgj3nas2v3xymd23vvartfhceme6gv98aaeg9muzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q2g7k3g' @@ -10,10 +21,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 +47,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 +62,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 +85,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(TransactionTrackerEvent.NewTransaction, { + 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(UtxoRepositoryEvent.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..b82efe5bf3c 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, @@ -84,7 +115,7 @@ export const providerStub = (): CardanoProvider => ({ live: 15_001_884_895_856_815n } }), - submitTx: async () => true, + submitTx: jest.fn().mockResolvedValue(void 0), stakePoolStats: async () => ({ qty: { active: 1000, @@ -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..2556a45a306 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, @@ -10,13 +10,14 @@ import { SingleAddressWalletDependencies, UtxoRepository } from '../src'; +import { txTracker } from './mockTransactionTracker'; describe('Wallet', () => { const name = 'Test Wallet'; let csl: CardanoSerializationLib; let inputSelector: InputSelector; let keyManager: KeyManagement.KeyManager; - let provider: CardanoProvider; + let provider: ProviderStub; let utxoRepository: UtxoRepository; let walletDependencies: SingleAddressWalletDependencies; @@ -30,8 +31,8 @@ describe('Wallet', () => { }); provider = providerStub(); inputSelector = roundRobinRandomImprove(csl); - utxoRepository = new InMemoryUtxoRepository(csl, provider, keyManager, inputSelector); - walletDependencies = { csl, keyManager, provider, utxoRepository }; + utxoRepository = new InMemoryUtxoRepository({ csl, provider, keyManager, inputSelector, txTracker }); + walletDependencies = { csl, keyManager, provider, utxoRepository, txTracker }; }); test('createWallet', async () => { @@ -74,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 { submitted, confirmed } = wallet.submitTx(tx); + await confirmed; + expect(provider.submitTx).toBeCalledTimes(1); + expect(provider.submitTx).toBeCalledWith(tx); + expect(txTracker.track).toBeCalledTimes(1); + expect(txTracker.track).toBeCalledWith(tx, submitted); }); }); }); 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/integration/withdrawal.test.ts b/packages/wallet/test/integration/withdrawal.test.ts new file mode 100644 index 00000000000..cfac5f8b069 --- /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, + UtxoRepositoryEvent +} from '@cardano-sdk/wallet'; +// Not testing with a real provider +import { providerStub } from '../ProviderStub'; + +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(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 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. + } 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; + }); +}); diff --git a/packages/wallet/test/mockTransactionTracker.ts b/packages/wallet/test/mockTransactionTracker.ts new file mode 100644 index 00000000000..eb1d381df0c --- /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 { + track = jest.fn(); +} + +export const txTracker = new MockTransactionTracker(); 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 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"