diff --git a/__tests__/integration/fullnode-specific/nano_utxo_lock_unlock.test.ts b/__tests__/integration/fullnode-specific/nano_utxo_lock_unlock.test.ts new file mode 100644 index 000000000..6f28319db --- /dev/null +++ b/__tests__/integration/fullnode-specific/nano_utxo_lock_unlock.test.ts @@ -0,0 +1,155 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { isEmpty } from 'lodash'; +import { GenesisWalletHelper } from '../helpers/genesis-wallet.helper'; +import { + generateWalletHelper, + stopAllWallets, + waitForTxReceived, + waitTxConfirmed, +} from '../helpers/wallet.helper'; +import HathorWallet from '../../../src/new/wallet'; +import { NATIVE_TOKEN_UID, NANO_CONTRACTS_INITIALIZE_METHOD } from '../../../src/constants'; +import { NanoContractTransactionError } from '../../../src/errors'; +import { SendTransaction } from '../../../src'; + +describe('Nano contract UTXO lock/unlock lifecycle', () => { + let hWallet: HathorWallet; + let contractId: string; + + const checkTxValid = async (wallet: HathorWallet, tx: { hash?: string | null }) => { + const txId = tx.hash!; + expect(txId).toBeDefined(); + await waitForTxReceived(wallet, txId); + await waitTxConfirmed(wallet, txId); + const txAfterExecution = await wallet.getFullTxById(txId); + expect(isEmpty(txAfterExecution.meta.voided_by)).toBe(true); + expect(txAfterExecution.meta.first_block).not.toBeNull(); + }; + + beforeAll(async () => { + hWallet = await generateWalletHelper(); + const address = await hWallet.getAddressAtIndex(0); + + await GenesisWalletHelper.injectFunds(hWallet, address, 10000n, {}); + + const initTx = await hWallet.createAndSendNanoContractTransaction( + NANO_CONTRACTS_INITIALIZE_METHOD, + address, + { + blueprintId: global.FEE_BLUEPRINT_ID, + args: [], + actions: [ + { + type: 'deposit', + token: NATIVE_TOKEN_UID, + amount: 1000n, + changeAddress: address, + }, + ], + } + ); + await checkTxValid(hWallet, initTx); + contractId = initTx.hash!; + }); + + afterAll(async () => { + await hWallet.stop(); + await stopAllWallets(); + await GenesisWalletHelper.clearListeners(); + }); + + it('should lock UTXOs on prepare and unlock with releaseUtxos()', async () => { + const address = await hWallet.getAddressAtIndex(0); + + // Use full balance to ensure all UTXOs are consumed + const balanceMap = await hWallet.getBalance(NATIVE_TOKEN_UID); + const availableBalance = balanceMap[0]?.balance?.unlocked ?? 0n; + expect(availableBalance).toBeGreaterThan(0n); + const depositAmount = availableBalance; + + let activeSendTx: SendTransaction | null = null; + try { + // Step 1: Prepare tx (UTXOs get locked) + const sendTx1: SendTransaction = await hWallet.createNanoContractTransaction( + 'noop', + address, + { + ncId: contractId, + args: [], + actions: [ + { + type: 'deposit', + token: NATIVE_TOKEN_UID, + amount: depositAmount, + changeAddress: address, + }, + ], + }, + { signTx: false } + ); + activeSendTx = sendTx1; + + expect(sendTx1.transaction).not.toBeNull(); + expect(sendTx1.transaction!.inputs.length).toBeGreaterThan(0); + + // Step 2: Second prepare should fail (UTXOs still locked) + await expect( + hWallet.createNanoContractTransaction( + 'noop', + address, + { + ncId: contractId, + args: [], + actions: [ + { + type: 'deposit', + token: NATIVE_TOKEN_UID, + amount: depositAmount, + changeAddress: address, + }, + ], + }, + { signTx: false } + ) + ).rejects.toThrow(NanoContractTransactionError); + + // Step 3: Release locked UTXOs + await sendTx1.releaseUtxos(); + activeSendTx = null; + + // Step 4: Prepare again (should succeed after unlock) + const sendTx3: SendTransaction = await hWallet.createNanoContractTransaction( + 'noop', + address, + { + ncId: contractId, + args: [], + actions: [ + { + type: 'deposit', + token: NATIVE_TOKEN_UID, + amount: depositAmount, + changeAddress: address, + }, + ], + }, + { signTx: false } + ); + activeSendTx = sendTx3; + + expect(sendTx3.transaction).not.toBeNull(); + expect(sendTx3.transaction!.inputs.length).toBeGreaterThan(0); + + await sendTx3.releaseUtxos(); + activeSendTx = null; + } finally { + await activeSendTx?.releaseUtxos(); + } + }); +}); diff --git a/__tests__/integration/helpers/wallet.helper.ts b/__tests__/integration/helpers/wallet.helper.ts index c89f05454..0b1e3adcc 100644 --- a/__tests__/integration/helpers/wallet.helper.ts +++ b/__tests__/integration/helpers/wallet.helper.ts @@ -455,7 +455,7 @@ export async function waitNextBlock(storage) { export async function waitTxConfirmed( hWallet: HathorWallet, txId: string, - timeout: number | null | undefined + timeout?: number ): Promise { let timeoutHandler: ReturnType | undefined; let timeoutErrorFlag = false; diff --git a/__tests__/new/sendTransaction.test.ts b/__tests__/new/sendTransaction.test.ts index 01c4f323f..d27bc76aa 100644 --- a/__tests__/new/sendTransaction.test.ts +++ b/__tests__/new/sendTransaction.test.ts @@ -437,3 +437,73 @@ test('prepareSendTokensData', async () => { // Reset mocks prepareSpy.mockRestore(); }); + +describe('releaseUtxos', () => { + it('should unmark all transaction inputs as selected', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + const utxoSelectSpy = jest.spyOn(storage, 'utxoSelectAsInput'); + + const sendTx = new SendTransaction({ storage, outputs: [], inputs: [] }); + + const mockTx = { + inputs: [ + { hash: 'tx1', index: 0 }, + { hash: 'tx2', index: 1 }, + ], + } as unknown as import('../../src/models/transaction').default; + sendTx.transaction = mockTx; + + await sendTx.releaseUtxos(); + + expect(utxoSelectSpy).toHaveBeenCalledTimes(2); + expect(utxoSelectSpy).toHaveBeenCalledWith({ txId: 'tx1', index: 0 }, false); + expect(utxoSelectSpy).toHaveBeenCalledWith({ txId: 'tx2', index: 1 }, false); + }); + + it('should no-op when transaction is null', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + const utxoSelectSpy = jest.spyOn(storage, 'utxoSelectAsInput'); + + const sendTx = new SendTransaction({ storage, outputs: [], inputs: [] }); + + await sendTx.releaseUtxos(); + + expect(utxoSelectSpy).not.toHaveBeenCalled(); + }); + + it('should no-op when storage is not set', async () => { + const sendTx = new SendTransaction({ outputs: [], inputs: [] }); + + const mockTx = { + inputs: [{ hash: 'tx1', index: 0 }], + } as unknown as import('../../src/models/transaction').default; + sendTx.transaction = mockTx; + + // Should resolve without throwing + await expect(sendTx.releaseUtxos()).resolves.toBeUndefined(); + }); + + it('should continue releasing remaining UTXOs if one fails', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + const utxoSelectSpy = jest + .spyOn(storage, 'utxoSelectAsInput') + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce(undefined); + + const sendTx = new SendTransaction({ storage, outputs: [], inputs: [] }); + const mockTx = { + inputs: [ + { hash: 'tx1', index: 0 }, + { hash: 'tx2', index: 1 }, + ], + } as unknown as import('../../src/models/transaction').default; + sendTx.transaction = mockTx; + + await sendTx.releaseUtxos(); // should not throw + + expect(utxoSelectSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/package-lock.json b/package-lock.json index 3d1091fcd..96eba11d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "patch-package": "8.0.0", "prettier": "3.3.2", "typescript": "5.4.5", - "winston": "^3.19.0" + "winston": "3.19.0" }, "engines": { "node": ">=22.0.0", diff --git a/src/nano_contracts/builder.ts b/src/nano_contracts/builder.ts index fb845ea8c..273bcc03d 100644 --- a/src/nano_contracts/builder.ts +++ b/src/nano_contracts/builder.ts @@ -16,6 +16,7 @@ import { FEE_PER_OUTPUT, NATIVE_TOKEN_UID, NANO_CONTRACTS_INITIALIZE_METHOD, + SELECT_OUTPUTS_TIMEOUT, TOKEN_MINT_MASK, TOKEN_MELT_MASK, } from '../constants'; @@ -383,7 +384,7 @@ class NanoContractTransactionBuilder { } const inputs: IDataInput[] = []; for (const utxo of utxosData.utxos) { - await this.wallet.markUtxoSelected(utxo.txId, utxo.index, true); + await this.wallet.markUtxoSelected(utxo.txId, utxo.index, true, SELECT_OUTPUTS_TIMEOUT); inputs.push({ txId: utxo.txId, index: utxo.index, @@ -543,7 +544,7 @@ class NanoContractTransactionBuilder { const inputs: IDataInput[] = []; // The method gets only one utxo const utxo = utxos[0]; - await this.wallet.markUtxoSelected(utxo.txId, utxo.index, true); + await this.wallet.markUtxoSelected(utxo.txId, utxo.index, true, SELECT_OUTPUTS_TIMEOUT); inputs.push({ txId: utxo.txId, index: utxo.index, @@ -768,7 +769,7 @@ class NanoContractTransactionBuilder { const inputs: IDataInput[] = []; for (const utxo of utxosData.utxos) { - await this.wallet.markUtxoSelected(utxo.txId, utxo.index, true); + await this.wallet.markUtxoSelected(utxo.txId, utxo.index, true, SELECT_OUTPUTS_TIMEOUT); inputs.push({ txId: utxo.txId, index: utxo.index, diff --git a/src/new/sendTransaction.ts b/src/new/sendTransaction.ts index 338198978..722be9433 100644 --- a/src/new/sendTransaction.ts +++ b/src/new/sendTransaction.ts @@ -687,6 +687,29 @@ export default class SendTransaction extends EventEmitter implements ISendTransa ); } } + + /** + * Release all UTXOs that were marked as selected for this transaction. + * Call this when the transaction is rejected or abandoned to free the locked UTXOs. + */ + async releaseUtxos(): Promise { + if (this.transaction === null) { + return; + } + + if (!this.storage) { + return; + } + + for (const input of this.transaction.inputs) { + try { + await this.storage.utxoSelectAsInput({ txId: input.hash, index: input.index }, false); + } catch (err) { + // Best-effort: continue releasing remaining UTXOs + this.storage.logger.debug(`Failed to release UTXO ${input.hash}:${input.index}: ${err}`); + } + } + } } /** diff --git a/src/wallet/sendTransactionWalletService.ts b/src/wallet/sendTransactionWalletService.ts index 5d545bb93..3bf5f8571 100644 --- a/src/wallet/sendTransactionWalletService.ts +++ b/src/wallet/sendTransactionWalletService.ts @@ -938,6 +938,18 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact } } + /** + * Release all UTXOs that were marked as selected for this transaction. + * No-op: wallet-service manages UTXO state server-side. + * + * @memberof SendTransactionWalletService + * @inner + */ + // eslint-disable-next-line class-methods-use-this + async releaseUtxos(): Promise { + // No-op: wallet-service manages UTXO state server-side + } + /** * Run sendTransaction from preparing, i.e. prepare, sign, mine and send the tx * diff --git a/src/wallet/types.ts b/src/wallet/types.ts index 1dbb2a223..01bac4fcc 100644 --- a/src/wallet/types.ts +++ b/src/wallet/types.ts @@ -511,6 +511,7 @@ export interface ISendTransaction { runFromMining(until?: 'mine-tx' | null): Promise; prepareTx(): Promise; signTx(pin?: string | null): Promise; + releaseUtxos(): Promise; readonly transaction: Transaction | null; readonly fullTxData: IDataTx | null; }