Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(wallet): wip implementation of InMemoryTransactionTracker
- Loading branch information
1 parent
d48107e
commit 65a05ee
Showing
6 changed files
with
318 additions
and
34 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
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'; | ||
|
||
export type Milliseconds = number; | ||
|
||
export interface InMemoryTransactionTrackerProps { | ||
provider: CardanoProvider; | ||
csl: CardanoSerializationLib; | ||
logger: Logger; | ||
maxTipFailures?: number; | ||
pollInterval?: Milliseconds; | ||
} | ||
|
||
const delay = (time: Milliseconds) => new Promise<void>((resolve) => setTimeout(resolve, time)); | ||
|
||
export class InMemoryTransactionTracker extends Emittery<TransactionTrackerEvents> implements TransactionTracker { | ||
readonly #provider: CardanoProvider; | ||
readonly #pendingTransactions = new Map<string, Promise<void>>(); | ||
readonly #csl: CardanoSerializationLib; | ||
readonly #logger: Logger; | ||
readonly #maxTipFailures: number; | ||
readonly #pollInterval: number; | ||
|
||
constructor({ provider, csl, logger, maxTipFailures = 3, pollInterval = 2000 }: InMemoryTransactionTrackerProps) { | ||
super(); | ||
this.#provider = provider; | ||
this.#csl = csl; | ||
this.#logger = logger; | ||
this.#maxTipFailures = maxTipFailures; | ||
this.#pollInterval = pollInterval; | ||
} | ||
|
||
async trackTransaction(transaction: CSL.Transaction): Promise<void> { | ||
const body = transaction.body(); | ||
const hash = Buffer.from(this.#csl.hash_transaction(body).to_bytes()).toString('hex'); | ||
|
||
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 }); | ||
// eslint-disable-next-line promise/catch-or-return | ||
promise.catch(() => void 0).then(() => this.#pendingTransactions.delete(hash)); | ||
|
||
return promise; | ||
} | ||
|
||
async #trackTransaction(hash: Hash16, invalidHereafter: Slot, numTipFailures = 0): Promise<void> { | ||
await delay(this.#pollInterval); | ||
try { | ||
const tx = await this.#provider.queryTransactionsByHashes([hash]); | ||
if (tx.length > 0) return; // done | ||
return this.#onTransactionNotFound(hash, invalidHereafter, numTipFailures); | ||
} 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) { | ||
if (++numTipFailures > this.#maxTipFailures) { | ||
throw new TransactionError( | ||
TransactionFailure.CannotTrack, | ||
error, | ||
"can't query tip to check for transaction timeout" | ||
); | ||
} | ||
this.#logger.debug( | ||
`Failed to query ledgerTip, transaction ${hash} might already be timed out ` + | ||
`(attempt ${numTipFailures} out of ${this.#maxTipFailures}).`, | ||
error | ||
); | ||
} | ||
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)'); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(reason: TransactionFailure, public innerError?: unknown, public detail?: string) { | ||
super(`Transaction failed: ${reason}${formatDetail(detail)}`); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
136 changes: 136 additions & 0 deletions
136
packages/wallet/test/InMemoryTransactionTracker.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
import { CardanoSerializationLib, CSL } from '@cardano-sdk/core'; | ||
import { flushPromises } from '@cardano-sdk/util-dev'; | ||
import { dummyLogger } from 'ts-log'; | ||
import { InMemoryTransactionTracker } from '../src/InMemoryTransactionTracker'; | ||
import { TransactionFailure } from '../src/Transaction/TransactionError'; | ||
import { providerStub, ProviderStub, queryTransactionsResult } from './ProviderStub'; | ||
|
||
const realSetImmediate = global.setImmediate; | ||
|
||
jest.useFakeTimers(); | ||
|
||
describe('InMemoryTransactionTracker', () => { | ||
const POLL_INTERVAL = 1000; | ||
const MAX_TIP_FAILURES = 2; | ||
let ledgerTipSlot: number; | ||
let provider: ProviderStub; | ||
let txTracker: InMemoryTransactionTracker; | ||
let hash_transaction: jest.Mock; | ||
|
||
const flushRequests = async (times: number) => { | ||
for (let i = 0; i < times; i++) { | ||
await jest.advanceTimersByTime(POLL_INTERVAL); | ||
await flushPromises(realSetImmediate); | ||
} | ||
}; | ||
|
||
const mockHashTransactionReturn = (resultHash: string) => { | ||
hash_transaction.mockReturnValue({ | ||
to_bytes() { | ||
return Buffer.from(resultHash); | ||
} | ||
}); | ||
}; | ||
|
||
beforeEach(async () => { | ||
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, | ||
maxTipFailures: MAX_TIP_FAILURES | ||
}); | ||
ledgerTipSlot = (await provider.ledgerTip()).slot; | ||
}); | ||
|
||
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; | ||
}); | ||
|
||
describe('ledger tip fetch error', () => { | ||
it('Ignores up to maxTipFailures (2) tip fetch errors', async () => { | ||
provider.queryTransactionsByHashes.mockResolvedValueOnce([]); | ||
provider.queryTransactionsByHashes.mockResolvedValueOnce([]); | ||
provider.ledgerTip.mockRejectedValueOnce(new Error('error1')); | ||
provider.ledgerTip.mockRejectedValueOnce(new Error('error2')); | ||
const promise = txTracker.trackTransaction(transaction); | ||
await flushRequests(3); | ||
await promise; | ||
expect(provider.ledgerTip).toBeCalledTimes(3); | ||
expect(provider.queryTransactionsByHashes).toBeCalledTimes(3); | ||
}); | ||
// it('Throws on >maxTipFailures (3) tip fetch errors', async () => { | ||
// provider.queryTransactionsByHashes.mockResolvedValueOnce([]); | ||
// provider.queryTransactionsByHashes.mockResolvedValueOnce([]); | ||
// provider.queryTransactionsByHashes.mockResolvedValueOnce([]); | ||
// provider.ledgerTip.mockRejectedValueOnce(new Error('error1')); | ||
// provider.ledgerTip.mockRejectedValueOnce(new Error('error2')); | ||
// provider.ledgerTip.mockRejectedValueOnce(new Error('error3')); | ||
// await expect(async () => { | ||
// const promise = txTracker.trackTransaction(transaction); | ||
// await flushRequests(3); | ||
// return promise; | ||
// }).rejects.toThrowError(TransactionFailure.CannotTrack); | ||
// }); | ||
}); | ||
|
||
it('polls provider at "pollInterval" until it returns the transaction', async () => { | ||
provider.queryTransactionsByHashes.mockResolvedValueOnce([]); | ||
provider.queryTransactionsByHashes.mockResolvedValueOnce([]); | ||
const promise = txTracker.trackTransaction(transaction); | ||
await flushRequests(3 + 1); // 3 actually needed + 1 to assert that we're no longer polling | ||
|
||
await promise; | ||
expect(provider.queryTransactionsByHashes).toBeCalledTimes(3); | ||
}); | ||
|
||
it('emits "transaction" event for tracked transactions, returns promise unique per pending tx', async () => { | ||
const promise1 = txTracker.trackTransaction(transaction); | ||
const promise2 = txTracker.trackTransaction(transaction); | ||
mockHashTransactionReturn('other-hash'); | ||
const promise3 = txTracker.trackTransaction(transaction); | ||
jest.runAllTimers(); | ||
await promise1; | ||
await promise2; | ||
await promise3; | ||
expect(provider.queryTransactionsByHashes).toBeCalledTimes(2); | ||
expect(onTransaction).toBeCalledTimes(2); | ||
// assert it clears cache | ||
const promise4 = txTracker.trackTransaction(transaction); | ||
jest.runAllTimers(); | ||
await promise4; | ||
expect(provider.queryTransactionsByHashes).toBeCalledTimes(3); | ||
expect(onTransaction).toBeCalledTimes(3); | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters