Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
raul-oliveira marked this conversation as resolved.
import { SendTransaction } from '../../../src';

describe('Nano contract UTXO lock/unlock lifecycle', () => {
Comment thread
tuliomir marked this conversation as resolved.
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();
}
});
});
2 changes: 1 addition & 1 deletion __tests__/integration/helpers/wallet.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ export async function waitNextBlock(storage) {
export async function waitTxConfirmed(
hWallet: HathorWallet,
txId: string,
timeout: number | null | undefined
timeout?: number
): Promise<void> {
let timeoutHandler: ReturnType<typeof setTimeout> | undefined;
let timeoutErrorFlag = false;
Expand Down
70 changes: 70 additions & 0 deletions __tests__/new/sendTransaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions src/nano_contracts/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
FEE_PER_OUTPUT,
NATIVE_TOKEN_UID,
NANO_CONTRACTS_INITIALIZE_METHOD,
SELECT_OUTPUTS_TIMEOUT,
TOKEN_MINT_MASK,
TOKEN_MELT_MASK,
} from '../constants';
Expand Down Expand Up @@ -383,7 +384,7 @@
}
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);

Check warning on line 387 in src/nano_contracts/builder.ts

View check run for this annotation

Codecov / codecov/patch

src/nano_contracts/builder.ts#L387

Added line #L387 was not covered by tests
inputs.push({
txId: utxo.txId,
index: utxo.index,
Expand Down Expand Up @@ -543,7 +544,7 @@
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);

Check warning on line 547 in src/nano_contracts/builder.ts

View check run for this annotation

Codecov / codecov/patch

src/nano_contracts/builder.ts#L547

Added line #L547 was not covered by tests
inputs.push({
txId: utxo.txId,
index: utxo.index,
Expand Down Expand Up @@ -768,7 +769,7 @@

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);

Check warning on line 772 in src/nano_contracts/builder.ts

View check run for this annotation

Codecov / codecov/patch

src/nano_contracts/builder.ts#L772

Added line #L772 was not covered by tests
inputs.push({
txId: utxo.txId,
index: utxo.index,
Expand Down
23 changes: 23 additions & 0 deletions src/new/sendTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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}`);
}
}
}
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/wallet/sendTransactionWalletService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
// No-op: wallet-service manages UTXO state server-side
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Is there a plan to implement this feature for the Wallet Service too? An API call to release the stuck UTXOs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not, because the way wallet service locks the utxos is by binding them to a tx proposal. In this use case that we are dealing, we never create a tx proposal before the user approves

}

/**
* Run sendTransaction from preparing, i.e. prepare, sign, mine and send the tx
*
Expand Down
1 change: 1 addition & 0 deletions src/wallet/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ export interface ISendTransaction {
runFromMining(until?: 'mine-tx' | null): Promise<Transaction>;
prepareTx(): Promise<Transaction>;
signTx(pin?: string | null): Promise<Transaction>;
releaseUtxos(): Promise<void>;
readonly transaction: Transaction | null;
readonly fullTxData: IDataTx | null;
}
Expand Down
Loading