From 06203788274d14ddaa110aa8a39b191d999565ac Mon Sep 17 00:00:00 2001 From: Pedro Ferreira Date: Fri, 27 Feb 2026 09:30:45 -0300 Subject: [PATCH 1/6] refactor: split prepare tx and sign tx, and make sendtx classes the same, so it can be used in the rpc lib --- .../integration/walletservice_facade.test.ts | 2 +- .../sendTransactionWalletService.test.ts | 4 +- src/new/sendTransaction.ts | 80 ++++++++++++++++--- src/utils/transaction.ts | 2 +- src/wallet/sendTransactionWalletService.ts | 30 ++++--- src/wallet/types.ts | 8 +- 6 files changed, 97 insertions(+), 29 deletions(-) diff --git a/__tests__/integration/walletservice_facade.test.ts b/__tests__/integration/walletservice_facade.test.ts index 0c554ce58..839abe856 100644 --- a/__tests__/integration/walletservice_facade.test.ts +++ b/__tests__/integration/walletservice_facade.test.ts @@ -2307,7 +2307,7 @@ describe('Fee-based tokens', () => { expect((txData.headers![0] as FeeHeader).entries[0].amount).toBe(2n); // Sign the transaction and run from mining - await sendTx.signTx(sendTx.utxosAddressPath); + await sendTx.signTx(); const tx = await sendTx.runFromMining(); await pollForTx(feeWallet, tx.hash!); diff --git a/__tests__/wallet/sendTransactionWalletService.test.ts b/__tests__/wallet/sendTransactionWalletService.test.ts index 264d958eb..6a1a9a48a 100644 --- a/__tests__/wallet/sendTransactionWalletService.test.ts +++ b/__tests__/wallet/sendTransactionWalletService.test.ts @@ -2158,7 +2158,7 @@ describe('prepareTx - Fee Tokens', () => { ]; sendTransaction = new SendTransactionWalletService(wallet, { inputs, outputs }); - const { transaction } = await sendTransaction.prepareTx(); + const transaction = await sendTransaction.prepareTx(); // Verify inputs: 1 pre-selected fee token + 1 pre-selected HTR expect(transaction.inputs).toHaveLength(2); @@ -2254,7 +2254,7 @@ describe('prepareTx - Fee Tokens', () => { ]; sendTransaction = new SendTransactionWalletService(wallet, { outputs }); - const { transaction } = await sendTransaction.prepareTx(); + const transaction = await sendTransaction.prepareTx(); // Verify transaction was created expect(transaction).toBeDefined(); diff --git a/src/new/sendTransaction.ts b/src/new/sendTransaction.ts index fd3f004e2..e1a464c93 100644 --- a/src/new/sendTransaction.ts +++ b/src/new/sendTransaction.ts @@ -32,7 +32,7 @@ import tokens from '../utils/tokens'; import transactionUtils from '../utils/transaction'; import { bestUtxoSelection } from '../utils/utxo'; import MineTransaction from '../wallet/mineTransaction'; -import { OutputType } from '../wallet/types'; +import { ISendTransaction as ISendTransactionInterface, OutputType } from '../wallet/types'; import HathorWallet from './wallet'; import Header from '../headers/base'; import FeeHeader from '../headers/fee'; @@ -79,7 +79,7 @@ export type ISendOutput = ISendDataOutput | ISendTokenOutput; * 'send-error': if an error happens; * 'unexpected-error': if an unexpected error happens; * */ -export default class SendTransaction extends EventEmitter { +export default class SendTransaction extends EventEmitter implements ISendTransactionInterface { wallet: HathorWallet | null; storage: IStorage | null; @@ -98,6 +98,8 @@ export default class SendTransaction extends EventEmitter { mineTransaction: MineTransaction | null = null; + private _currentStep: 'idle' | 'prepared' | 'signed' = 'idle'; + /** * * @param {HathorWallet} wallet Wallet instance @@ -314,19 +316,19 @@ export default class SendTransaction extends EventEmitter { } /** - * Prepare transaction data from inputs and outputs - * Fill the inputs if needed, create output change if needed and sign inputs + * Prepare transaction without signing it. + * Fill the inputs if needed, create output change if needed. * - * @param {string | null} pin Pin to use in this method (overwrites this.pin) + * @param {string | null} pin Pin to use (accepted for interface compatibility, not used during preparation) * * @throws SendTxError * - * @return {Transaction} Transaction object prepared to be mined + * @return {Transaction} Transaction object prepared to be signed * * @memberof SendTransaction * @inner */ - async prepareTx(pin = null): Promise { + async prepareTx(pin: string | null = null): Promise { if (!this.storage) { throw new SendTxError('Storage is not set.'); } @@ -337,7 +339,9 @@ export default class SendTransaction extends EventEmitter { if (!pinToUse) { throw new Error('Pin is not set.'); } - this.transaction = await transactionUtils.prepareTransaction(txData, pinToUse, this.storage); + this.transaction = await transactionUtils.prepareTransaction(txData, pinToUse, this.storage, { + signTx: false, + }); // This will validate if the transaction has more than the max number of inputs and outputs. this.transaction.validate(); return this.transaction; @@ -347,6 +351,42 @@ export default class SendTransaction extends EventEmitter { } } + /** + * Sign the transaction and prepare the tx to be mined + * + * @param {string | null} pin Pin to use in this method (overwrites this.pin) + * + * @throws SendTxError + * + * @return {Transaction} Transaction object prepared to be mined + * + * @memberof SendTransaction + * @inner + */ + async signTx(pin: string | null = null): Promise { + if (!this.storage) { + throw new SendTxError('Storage is not set.'); + } + + if (!this.transaction) { + throw new SendTxError('Transaction is not set.'); + } + + const pinToUse = pin ?? this.pin ?? ''; + try { + if (!pinToUse) { + throw new SendTxError('Pin is not set.'); + } + + await transactionUtils.signTransaction(this.transaction, this.storage, pinToUse); + this.transaction.prepareToSend(); + return this.transaction; + } catch (e) { + const message = helpers.handlePrepareDataError(e); + throw new SendTxError(message); + } + } + /** * Prepare transaction to be mined from signatures * @@ -536,7 +576,7 @@ export default class SendTransaction extends EventEmitter { * @memberof SendTransaction * @inner */ - async runFromMining(until = null): Promise { + async runFromMining(until: string | null = null): Promise { try { if (this.transaction === null) { throw new WalletError(ErrorMessages.TRANSACTION_IS_NULL); @@ -581,16 +621,30 @@ export default class SendTransaction extends EventEmitter { * Run sendTransaction from preparing, i.e. prepare, sign, mine and push the tx * * 'until' parameter can be 'prepare-tx' (it will stop before signing the tx), + * 'sign-tx' (it will stop before mining the tx), * or 'mine-tx' (it will stop before send tx proposal, i.e. propagating the tx) * + * Can be called incrementally: run('prepare-tx') then run(null) to continue. + * * @memberof SendTransaction * @inner */ - async run(until = null, pin = null) { + async run(until: string | null = null, pin: string | null = null): Promise { try { - await this.prepareTx(pin); - if (until === 'prepare-tx') { - return this.transaction; + if (this._currentStep === 'idle') { + await this.prepareTx(pin); + this._currentStep = 'prepared'; + if (until === 'prepare-tx') { + return this.transaction!; + } + } + + if (this._currentStep === 'prepared') { + await this.signTx(pin); + this._currentStep = 'signed'; + if (until === 'sign-tx') { + return this.transaction!; + } } const tx = await this.runFromMining(until); diff --git a/src/utils/transaction.ts b/src/utils/transaction.ts index e69ba92bb..043dcc986 100644 --- a/src/utils/transaction.ts +++ b/src/utils/transaction.ts @@ -783,8 +783,8 @@ const transaction = { const tx = this.createTransactionFromData(txData, network); if (newOptions.signTx) { await this.signTransaction(tx, storage, pinCode); + tx.prepareToSend(); } - tx.prepareToSend(); return tx; }, diff --git a/src/wallet/sendTransactionWalletService.ts b/src/wallet/sendTransactionWalletService.ts index 4ff966bb6..47944a0f7 100644 --- a/src/wallet/sendTransactionWalletService.ts +++ b/src/wallet/sendTransactionWalletService.ts @@ -93,6 +93,8 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact // Fee amount to prepare the transaction private _feeAmount: bigint; + private _currentStep: 'idle' | 'prepared' | 'signed' = 'idle'; + constructor(wallet: HathorWalletServiceWallet, options: optionsType = {}) { super(); @@ -375,7 +377,7 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact * @memberof SendTransactionWalletService * @inner */ - async prepareTx(): Promise<{ transaction: Transaction; utxosAddressPath: string[] }> { + async prepareTx(pin?: string | null): Promise { this.emit('prepare-tx-start'); // We get the full outputs amount for each token // This is useful for (i) getting the utxos for each one @@ -474,8 +476,9 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact this.transaction.headers.push(new FeeHeader([{ tokenIndex: 0, amount: this._feeAmount }])); } + this.utxosAddressPath = utxosAddressPath; this.emit('prepare-tx-end', this.transaction); - return { transaction: this.transaction, utxosAddressPath }; + return this.transaction; } /** @@ -771,7 +774,7 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact * @memberof SendTransactionWalletService * @inner */ - async signTx(utxosAddressPath: string[], pin: string | null = null) { + async signTx(pin?: string | null): Promise { if (this.transaction === null) { throw new WalletError("Can't sign transaction if it's null."); } @@ -785,7 +788,7 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact xprivkey, dataToSignHash, // the wallet service returns the full BIP44 path, but we only need the address path: - HathorWalletServiceWallet.getAddressIndexFromFullPath(utxosAddressPath[idx]) + HathorWalletServiceWallet.getAddressIndexFromFullPath(this.utxosAddressPath[idx]) ); inputObj.setData(inputData); } @@ -795,6 +798,7 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact this.transaction.prepareToSend(); this.emit('sign-tx-end', this.transaction); + return this.transaction; } /** @@ -935,14 +939,20 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact */ async run(until: string | null = null, pin: string | null = null): Promise { try { - const preparedData = await this.prepareTx(); - if (until === 'prepare-tx') { - return this.transaction!; + if (this._currentStep === 'idle') { + await this.prepareTx(pin); + this._currentStep = 'prepared'; + if (until === 'prepare-tx') { + return this.transaction!; + } } - await this.signTx(preparedData.utxosAddressPath, pin); - if (until === 'sign-tx') { - return this.transaction!; + if (this._currentStep === 'prepared') { + await this.signTx(pin); + this._currentStep = 'signed'; + if (until === 'sign-tx') { + return this.transaction!; + } } const tx = await this.runFromMining(until); diff --git a/src/wallet/types.ts b/src/wallet/types.ts index 34e90771f..da12d41fe 100644 --- a/src/wallet/types.ts +++ b/src/wallet/types.ts @@ -6,7 +6,7 @@ */ import bitcore from 'bitcore-lib'; -import { TokenVersion, IStorage, OutputValueType, IHistoryTx } from '../types'; +import { TokenVersion, IStorage, OutputValueType, IHistoryTx, IDataTx } from '../types'; import Transaction from '../models/transaction'; import CreateTokenTransaction from '../models/create_token_transaction'; import SendTransactionWalletService from './sendTransactionWalletService'; @@ -487,8 +487,12 @@ export interface IHathorWallet { } export interface ISendTransaction { - run(until: string | null): Promise; + run(until: string | null, pin?: string | null): Promise; runFromMining(until: string | null): Promise; + prepareTx(pin?: string | null): Promise; + signTx(pin?: string | null): Promise; + readonly transaction: Transaction | null; + readonly fullTxData: IDataTx | null; } export interface MineTxSuccessData { From 8f233eb2c2b8e82113a051f210df32e85bcff344 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira Date: Mon, 2 Mar 2026 21:47:48 -0300 Subject: [PATCH 2/6] tests: fix tests --- __tests__/integration/storage/storage.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/__tests__/integration/storage/storage.test.ts b/__tests__/integration/storage/storage.test.ts index d42a9d36d..f78814b1e 100644 --- a/__tests__/integration/storage/storage.test.ts +++ b/__tests__/integration/storage/storage.test.ts @@ -83,12 +83,15 @@ describe('locked utxos', () => { ], pin: 'xxx', // instance with wrong pin to validate pin as parameter }); - await expect(sendTx.prepareTx()).rejects.toThrow(InvalidPasswdError); + // prepareTx creates an unsigned transaction (pin is not validated here) + await sendTx.prepareTx(); + // signTx with the wrong pin should fail + await expect(sendTx.signTx()).rejects.toThrow(InvalidPasswdError); // Will work with the correct PIN as parameter - await sendTx.prepareTx(DEFAULT_PIN_CODE); + await sendTx.signTx(DEFAULT_PIN_CODE); await sendTx.updateOutputSelected(true); // This shouldn't fail since if we did not have tokens the prepareTx should have failed - const input = sendTx.transaction.inputs[0]; + const input = sendTx.transaction!.inputs[0]; const utxoId = { txId: input.hash, index: input.index }; await expect(hwallet.storage.isUtxoSelectedAsInput(utxoId)).resolves.toBe(true); // Send a transaction spending the only utxo on the wallet. From 8be1ae0b2e091c09e6c306aded1811d384b49c05 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira Date: Mon, 2 Mar 2026 22:02:06 -0300 Subject: [PATCH 3/6] refactor: remove unecessary pin from prepareTx and move step to method called --- __tests__/new/sendTransaction.test.ts | 7 ++++-- src/new/sendTransaction.ts | 28 ++++++++++------------ src/wallet/sendTransactionWalletService.ts | 22 +++++++++-------- src/wallet/types.ts | 2 +- 4 files changed, 31 insertions(+), 28 deletions(-) diff --git a/__tests__/new/sendTransaction.test.ts b/__tests__/new/sendTransaction.test.ts index a15021bf9..01c4f323f 100644 --- a/__tests__/new/sendTransaction.test.ts +++ b/__tests__/new/sendTransaction.test.ts @@ -169,10 +169,13 @@ test('prepareTxData', async () => { tokens: ['01'], }); - await expect(sendTransaction.prepareTx()).rejects.toThrow('Pin is not set.'); - sendTransaction.pin = '000000'; + // prepareTx does not require a PIN (creates unsigned transaction) await expect(sendTransaction.prepareTx()).resolves.toBe(preparedTx); + // signTx requires a PIN + await expect(sendTransaction.signTx()).rejects.toThrow('Pin is not set.'); + sendTransaction.pin = '000000'; + prepareSpy.mockRestore(); spyGetToken.mockRestore(); }); diff --git a/src/new/sendTransaction.ts b/src/new/sendTransaction.ts index e1a464c93..512ae2c26 100644 --- a/src/new/sendTransaction.ts +++ b/src/new/sendTransaction.ts @@ -328,22 +328,19 @@ export default class SendTransaction extends EventEmitter implements ISendTransa * @memberof SendTransaction * @inner */ - async prepareTx(pin: string | null = null): Promise { + async prepareTx(): Promise { if (!this.storage) { throw new SendTxError('Storage is not set.'); } - const pinToUse = pin ?? this.pin ?? ''; const txData = this.fullTxData || (await this.prepareTxData()); try { - if (!pinToUse) { - throw new Error('Pin is not set.'); - } - this.transaction = await transactionUtils.prepareTransaction(txData, pinToUse, this.storage, { + this.transaction = await transactionUtils.prepareTransaction(txData, '', this.storage, { signTx: false, }); // This will validate if the transaction has more than the max number of inputs and outputs. this.transaction.validate(); + this._currentStep = 'prepared'; return this.transaction; } catch (e) { const message = helpers.handlePrepareDataError(e); @@ -380,6 +377,7 @@ export default class SendTransaction extends EventEmitter implements ISendTransa await transactionUtils.signTransaction(this.transaction, this.storage, pinToUse); this.transaction.prepareToSend(); + this._currentStep = 'signed'; return this.transaction; } catch (e) { const message = helpers.handlePrepareDataError(e); @@ -632,19 +630,19 @@ export default class SendTransaction extends EventEmitter implements ISendTransa async run(until: string | null = null, pin: string | null = null): Promise { try { if (this._currentStep === 'idle') { - await this.prepareTx(pin); - this._currentStep = 'prepared'; - if (until === 'prepare-tx') { - return this.transaction!; - } + await this.prepareTx(); + } + + if (until === 'prepare-tx') { + return this.transaction!; } if (this._currentStep === 'prepared') { await this.signTx(pin); - this._currentStep = 'signed'; - if (until === 'sign-tx') { - return this.transaction!; - } + } + + if (until === 'sign-tx') { + return this.transaction!; } const tx = await this.runFromMining(until); diff --git a/src/wallet/sendTransactionWalletService.ts b/src/wallet/sendTransactionWalletService.ts index 47944a0f7..d64dd5ab9 100644 --- a/src/wallet/sendTransactionWalletService.ts +++ b/src/wallet/sendTransactionWalletService.ts @@ -377,7 +377,7 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact * @memberof SendTransactionWalletService * @inner */ - async prepareTx(pin?: string | null): Promise { + async prepareTx(): Promise { this.emit('prepare-tx-start'); // We get the full outputs amount for each token // This is useful for (i) getting the utxos for each one @@ -477,6 +477,7 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact } this.utxosAddressPath = utxosAddressPath; + this._currentStep = 'prepared'; this.emit('prepare-tx-end', this.transaction); return this.transaction; } @@ -797,6 +798,7 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact // we can add the timestamp and calculate the weight this.transaction.prepareToSend(); + this._currentStep = 'signed'; this.emit('sign-tx-end', this.transaction); return this.transaction; } @@ -940,19 +942,19 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact async run(until: string | null = null, pin: string | null = null): Promise { try { if (this._currentStep === 'idle') { - await this.prepareTx(pin); - this._currentStep = 'prepared'; - if (until === 'prepare-tx') { - return this.transaction!; - } + await this.prepareTx(); + } + + if (until === 'prepare-tx') { + return this.transaction!; } if (this._currentStep === 'prepared') { await this.signTx(pin); - this._currentStep = 'signed'; - if (until === 'sign-tx') { - return this.transaction!; - } + } + + if (until === 'sign-tx') { + return this.transaction!; } const tx = await this.runFromMining(until); diff --git a/src/wallet/types.ts b/src/wallet/types.ts index da12d41fe..2f055cd55 100644 --- a/src/wallet/types.ts +++ b/src/wallet/types.ts @@ -489,7 +489,7 @@ export interface IHathorWallet { export interface ISendTransaction { run(until: string | null, pin?: string | null): Promise; runFromMining(until: string | null): Promise; - prepareTx(pin?: string | null): Promise; + prepareTx(): Promise; signTx(pin?: string | null): Promise; readonly transaction: Transaction | null; readonly fullTxData: IDataTx | null; From f9141c96c6030b84236a95b5f2ce29de8eacc674 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira Date: Mon, 2 Mar 2026 22:18:55 -0300 Subject: [PATCH 4/6] refactor: add method protections and fix docs --- __tests__/wallet/wallet.test.ts | 12 +++++++----- src/new/sendTransaction.ts | 2 -- src/wallet/sendTransactionWalletService.ts | 10 +++++++++- src/wallet/types.ts | 4 ++-- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/__tests__/wallet/wallet.test.ts b/__tests__/wallet/wallet.test.ts index bce6440e3..9436a79fa 100644 --- a/__tests__/wallet/wallet.test.ts +++ b/__tests__/wallet/wallet.test.ts @@ -56,15 +56,17 @@ jest.mock('../../src/wallet/sendTransactionWalletService', () => { const instance = new ActualSendTransactionWalletService(...args); // Mock all methods except run with appropriate return values - instance.prepareTx = jest.fn().mockResolvedValue({ - utxosAddressPath: [], // Mock utxos address path array - }); - // Set up the transaction mock for signTx to use - instance.transaction = { + const mockTransaction = { getDataToSignHash: jest.fn().mockReturnValue(Buffer.from('mock-hash')), inputs: [], // Mock empty inputs so the for loop doesn't execute prepareToSend: jest.fn(), }; + instance.prepareTx = jest.fn().mockImplementation(() => { + instance.transaction = mockTransaction; + instance.utxosAddressPath = []; + instance._currentStep = 'prepared'; + return Promise.resolve(mockTransaction); + }); instance.runFromMining = jest.fn().mockResolvedValue({}); return instance; diff --git a/src/new/sendTransaction.ts b/src/new/sendTransaction.ts index 512ae2c26..5a943a798 100644 --- a/src/new/sendTransaction.ts +++ b/src/new/sendTransaction.ts @@ -319,8 +319,6 @@ export default class SendTransaction extends EventEmitter implements ISendTransa * Prepare transaction without signing it. * Fill the inputs if needed, create output change if needed. * - * @param {string | null} pin Pin to use (accepted for interface compatibility, not used during preparation) - * * @throws SendTxError * * @return {Transaction} Transaction object prepared to be signed diff --git a/src/wallet/sendTransactionWalletService.ts b/src/wallet/sendTransactionWalletService.ts index d64dd5ab9..1a3621081 100644 --- a/src/wallet/sendTransactionWalletService.ts +++ b/src/wallet/sendTransactionWalletService.ts @@ -779,9 +779,17 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact if (this.transaction === null) { throw new WalletError("Can't sign transaction if it's null."); } + const pinToUse = pin ?? this.pin ?? ''; + if (!pinToUse) { + throw new SendTxError('Pin is not set.'); + } + if (this.utxosAddressPath.length !== this.transaction.inputs.length) { + throw new SendTxError( + 'utxosAddressPath length does not match transaction inputs. Call prepareTx() first.' + ); + } this.emit('sign-tx-start'); const dataToSignHash = this.transaction.getDataToSignHash(); - const pinToUse = pin ?? this.pin ?? ''; const xprivkey = await this.wallet.storage.getMainXPrivKey(pinToUse); for (const [idx, inputObj] of this.transaction.inputs.entries()) { diff --git a/src/wallet/types.ts b/src/wallet/types.ts index 2f055cd55..515df715f 100644 --- a/src/wallet/types.ts +++ b/src/wallet/types.ts @@ -487,8 +487,8 @@ export interface IHathorWallet { } export interface ISendTransaction { - run(until: string | null, pin?: string | null): Promise; - runFromMining(until: string | null): Promise; + run(until?: string | null, pin?: string | null): Promise; + runFromMining(until?: string | null): Promise; prepareTx(): Promise; signTx(pin?: string | null): Promise; readonly transaction: Transaction | null; From 0d36aca15eb3280b7daf01587d27c19300a2bedf Mon Sep 17 00:00:00 2001 From: Pedro Ferreira Date: Tue, 3 Mar 2026 12:14:42 -0300 Subject: [PATCH 5/6] feat: specify possible options for until parameter --- src/new/sendTransaction.ts | 7 +++++-- src/wallet/sendTransactionWalletService.ts | 7 +++++-- src/wallet/types.ts | 7 +++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/new/sendTransaction.ts b/src/new/sendTransaction.ts index 5a943a798..624f2b1f8 100644 --- a/src/new/sendTransaction.ts +++ b/src/new/sendTransaction.ts @@ -572,7 +572,7 @@ export default class SendTransaction extends EventEmitter implements ISendTransa * @memberof SendTransaction * @inner */ - async runFromMining(until: string | null = null): Promise { + async runFromMining(until: 'mine-tx' | null = null): Promise { try { if (this.transaction === null) { throw new WalletError(ErrorMessages.TRANSACTION_IS_NULL); @@ -625,7 +625,10 @@ export default class SendTransaction extends EventEmitter implements ISendTransa * @memberof SendTransaction * @inner */ - async run(until: string | null = null, pin: string | null = null): Promise { + async run( + until: 'prepare-tx' | 'sign-tx' | 'mine-tx' | null = null, + pin: string | null = null + ): Promise { try { if (this._currentStep === 'idle') { await this.prepareTx(); diff --git a/src/wallet/sendTransactionWalletService.ts b/src/wallet/sendTransactionWalletService.ts index 1a3621081..5d545bb93 100644 --- a/src/wallet/sendTransactionWalletService.ts +++ b/src/wallet/sendTransactionWalletService.ts @@ -913,7 +913,7 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact * @memberof SendTransactionWalletService * @inner */ - async runFromMining(until: string | null = null): Promise { + async runFromMining(until: 'mine-tx' | null = null): Promise { try { // This will await until mine tx is fully completed // mineTx method returns a promise that resolves when @@ -947,7 +947,10 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact * @memberof SendTransactionWalletService * @inner */ - async run(until: string | null = null, pin: string | null = null): Promise { + async run( + until: 'prepare-tx' | 'sign-tx' | 'mine-tx' | null = null, + pin: string | null = null + ): Promise { try { if (this._currentStep === 'idle') { await this.prepareTx(); diff --git a/src/wallet/types.ts b/src/wallet/types.ts index 515df715f..0ae87b3f6 100644 --- a/src/wallet/types.ts +++ b/src/wallet/types.ts @@ -487,8 +487,11 @@ export interface IHathorWallet { } export interface ISendTransaction { - run(until?: string | null, pin?: string | null): Promise; - runFromMining(until?: string | null): Promise; + run( + until?: 'prepare-tx' | 'sign-tx' | 'mine-tx' | null, + pin?: string | null + ): Promise; + runFromMining(until?: 'mine-tx' | null): Promise; prepareTx(): Promise; signTx(pin?: string | null): Promise; readonly transaction: Transaction | null; From 151cc1d58c8e82c5e71436e850ba7d7b25a9d3f8 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira Date: Tue, 3 Mar 2026 12:16:23 -0300 Subject: [PATCH 6/6] tests: add more tests to cover new features --- __tests__/integration/storage/storage.test.ts | 130 +++++++++++++++++- .../sendTransactionWalletService.test.ts | 51 ++++++- 2 files changed, 179 insertions(+), 2 deletions(-) diff --git a/__tests__/integration/storage/storage.test.ts b/__tests__/integration/storage/storage.test.ts index f78814b1e..08e8dbc54 100644 --- a/__tests__/integration/storage/storage.test.ts +++ b/__tests__/integration/storage/storage.test.ts @@ -9,7 +9,7 @@ import { waitForWalletReady, waitForTxReceived, } from '../helpers/wallet.helper'; -import { InvalidPasswdError } from '../../../src/errors'; +import { InvalidPasswdError, SendTxError } from '../../../src/errors'; import HathorWallet from '../../../src/new/wallet'; import { loggers } from '../utils/logger.util'; import SendTransaction from '../../../src/new/sendTransaction'; @@ -107,6 +107,134 @@ describe('locked utxos', () => { const storageMem = new Storage(storeMem); await testUnlockWhenSpent(storageMem, walletDataMem); }); + + it('should wrap prepareTx errors into SendTxError', async () => { + const walletData = precalculationHelpers.test.getPrecalculatedWallet(); + const store = new MemoryStore(); + const storage = new Storage(store); + const hwallet = await startWallet(storage, walletData); + const address = await hwallet.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(hwallet, address, 1n); + + const sendTx = new SendTransaction({ + storage: hwallet.storage, + outputs: [ + { + type: 'p2pkh', + address: await hwallet.getAddressAtIndex(1), + // Sending more than available should cause prepareTx to fail + value: 9999n, + token: NATIVE_TOKEN_UID, + }, + ], + }); + + await expect(sendTx.prepareTx()).rejects.toThrow(SendTxError); + }); +}); + +describe('run(until) state machine', () => { + afterEach(async () => { + await stopWallets(); + await stopAllWallets(); + await GenesisWalletHelper.clearListeners(); + }); + + it('should stop at prepare-tx and resume to completion', async () => { + const walletData = precalculationHelpers.test.getPrecalculatedWallet(); + const store = new MemoryStore(); + const storage = new Storage(store); + const hwallet = await startWallet(storage, walletData); + const address = await hwallet.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(hwallet, address, 2n); + + const sendTx = new SendTransaction({ + storage: hwallet.storage, + pin: DEFAULT_PIN_CODE, + outputs: [ + { + type: 'p2pkh', + address: await hwallet.getAddressAtIndex(1), + value: 1n, + token: NATIVE_TOKEN_UID, + }, + ], + }); + + // Stop after prepare — transaction should exist but be unsigned (no input data) + const preparedTx = await sendTx.run('prepare-tx'); + expect(preparedTx).toBeDefined(); + expect(preparedTx.inputs.length).toBeGreaterThan(0); + for (const input of preparedTx.inputs) { + expect(input.data).toBeNull(); + } + + // Resume from where we left off — should sign, mine and push + const finalTx = await sendTx.run(null); + expect(finalTx.hash).toBeDefined(); + await waitForTxReceived(hwallet, finalTx.hash); + }); + + it('should stop at sign-tx and resume to completion', async () => { + const walletData = precalculationHelpers.test.getPrecalculatedWallet(); + const store = new MemoryStore(); + const storage = new Storage(store); + const hwallet = await startWallet(storage, walletData); + const address = await hwallet.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(hwallet, address, 2n); + + const sendTx = new SendTransaction({ + storage: hwallet.storage, + pin: DEFAULT_PIN_CODE, + outputs: [ + { + type: 'p2pkh', + address: await hwallet.getAddressAtIndex(1), + value: 1n, + token: NATIVE_TOKEN_UID, + }, + ], + }); + + // Stop after sign — transaction should be signed (input data populated) + const signedTx = await sendTx.run('sign-tx'); + expect(signedTx).toBeDefined(); + for (const input of signedTx.inputs) { + expect(input.data).not.toBeNull(); + } + + // Resume — should mine and push + const finalTx = await sendTx.run(null); + expect(finalTx.hash).toBeDefined(); + await waitForTxReceived(hwallet, finalTx.hash); + }); + + it('should complete the full flow with run(null)', async () => { + const walletData = precalculationHelpers.test.getPrecalculatedWallet(); + const store = new MemoryStore(); + const storage = new Storage(store); + const hwallet = await startWallet(storage, walletData); + const address = await hwallet.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(hwallet, address, 2n); + + const sendTx = new SendTransaction({ + storage: hwallet.storage, + pin: DEFAULT_PIN_CODE, + outputs: [ + { + type: 'p2pkh', + address: await hwallet.getAddressAtIndex(1), + value: 1n, + token: NATIVE_TOKEN_UID, + }, + ], + }); + + // Full flow in one call + const tx = await sendTx.run(null); + expect(tx.hash).toBeDefined(); + await waitForTxReceived(hwallet, tx.hash); + }); }); describe('custom signature method', () => { diff --git a/__tests__/wallet/sendTransactionWalletService.test.ts b/__tests__/wallet/sendTransactionWalletService.test.ts index 6a1a9a48a..5af8b9bd6 100644 --- a/__tests__/wallet/sendTransactionWalletService.test.ts +++ b/__tests__/wallet/sendTransactionWalletService.test.ts @@ -13,7 +13,7 @@ import Network from '../../src/models/network'; import Address from '../../src/models/address'; import { TokenVersion } from '../../src/types'; import FeeHeader from '../../src/headers/fee'; -import { SendTxError } from '../../src/errors'; +import { SendTxError, WalletError } from '../../src/errors'; describe('prepareTxData', () => { let wallet; @@ -2327,3 +2327,52 @@ describe('prepareTx - Fee Tokens', () => { ); }); }); + +describe('signTx preconditions', () => { + let wallet; + const seed = + 'purse orchard camera cloud piece joke hospital mechanic timber horror shoulder rebuild you decrease garlic derive rebuild random naive elbow depart okay parrot cliff'; + + beforeEach(() => { + wallet = new HathorWalletServiceWallet({ + requestPassword: async () => '123', + seed, + network: new Network('testnet'), + }); + }); + + it('should throw when transaction is null', async () => { + const sendTransaction = new SendTransactionWalletService(wallet); + await expect(sendTransaction.signTx('1234')).rejects.toThrow(WalletError); + await expect(sendTransaction.signTx('1234')).rejects.toThrow( + "Can't sign transaction if it's null." + ); + }); + + it('should throw when pin is not set', async () => { + const sendTransaction = new SendTransactionWalletService(wallet); + // Manually set transaction to bypass the null check + sendTransaction.transaction = { + inputs: [], + getDataToSignHash: jest.fn(), + prepareToSend: jest.fn(), + }; + await expect(sendTransaction.signTx()).rejects.toThrow(SendTxError); + await expect(sendTransaction.signTx()).rejects.toThrow('Pin is not set.'); + }); + + it('should throw when utxosAddressPath does not match inputs', async () => { + const sendTransaction = new SendTransactionWalletService(wallet); + // Set transaction with inputs but leave utxosAddressPath empty + sendTransaction.transaction = { + inputs: [{ hash: 'tx1', index: 0 }], + getDataToSignHash: jest.fn(), + prepareToSend: jest.fn(), + }; + sendTransaction.utxosAddressPath = []; // mismatch: 0 paths vs 1 input + await expect(sendTransaction.signTx('1234')).rejects.toThrow(SendTxError); + await expect(sendTransaction.signTx('1234')).rejects.toThrow( + 'utxosAddressPath length does not match transaction inputs. Call prepareTx() first.' + ); + }); +});