Skip to content

Commit 0e09722

Browse files
committed
fix: unlock tx outputs after a failure in tx proposal send
1 parent dc41675 commit 0e09722

File tree

3 files changed

+310
-2
lines changed

3 files changed

+310
-2
lines changed

packages/daemon/src/services/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => {
389389
// prepare the transaction data to be sent to the SQS queue
390390
const txData: Transaction = {
391391
tx_id: hash,
392-
nonce: Number(nonce),
392+
nonce: nonce ? BigInt(nonce) : BigInt(0),
393393
timestamp,
394394
version,
395395
voided: metadata.voided_by.length > 0,
@@ -434,7 +434,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => {
434434

435435
// Call to process the data for NFT handling (if applicable)
436436
// This process is not critical, so we run it in a fire-and-forget manner, not waiting for the promise.
437-
NftUtils.processNftEvent({...fullNodeData, nonce: Number(fullNodeData.nonce)}, STAGE, network, logger)
437+
NftUtils.processNftEvent({...fullNodeData, nonce: fullNodeData.nonce ? BigInt(fullNodeData.nonce) : BigInt(0)}, STAGE, network, logger)
438438
.catch((err: unknown) => logger.error('[ALERT] Error processing NFT event', err));
439439
}
440440

packages/wallet-service/src/api/txProposalSend.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getTxProposal,
1111
getTxProposalInputs,
1212
updateTxProposal,
13+
releaseTxProposalUtxos,
1314
} from '@src/db';
1415
import {
1516
TxProposalStatus,
@@ -140,6 +141,8 @@ export const send: APIGatewayProxyHandler = middy(walletIdProxyHandler(async (wa
140141
TxProposalStatus.SEND_ERROR,
141142
);
142143

144+
await releaseTxProposalUtxos(mysql, [txProposalId]);
145+
143146
return closeDbAndGetError(mysql, ApiError.TX_PROPOSAL_SEND_ERROR, {
144147
message: e.message,
145148
txProposalId,
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
/**
2+
* Copyright (c) Hathor Labs and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
/**
9+
* @jest-environment node
10+
*/
11+
12+
import { APIGatewayProxyResult } from 'aws-lambda';
13+
import hathorLib from '@hathor/wallet-lib';
14+
import {
15+
create as txProposalCreate,
16+
} from '@src/api/txProposalCreate';
17+
18+
import {
19+
send as txProposalSend,
20+
} from '@src/api/txProposalSend';
21+
22+
import {
23+
addToWalletTable,
24+
addToAddressTable,
25+
addToUtxoTable,
26+
addToWalletBalanceTable,
27+
ADDRESSES,
28+
cleanDatabase,
29+
makeGatewayEventWithAuthorizer,
30+
} from '@tests/utils';
31+
32+
import {
33+
closeDbConnection,
34+
getDbConnection,
35+
} from '@src/utils';
36+
37+
import {
38+
getUtxos,
39+
getTxProposal,
40+
} from '@src/db';
41+
42+
import { TxProposalStatus } from '@src/types';
43+
44+
const mysql = getDbConnection();
45+
46+
beforeEach(async () => {
47+
await cleanDatabase(mysql);
48+
});
49+
50+
afterAll(async () => {
51+
await closeDbConnection(mysql);
52+
});
53+
54+
describe('TxProposal UTXO unlocking on send failure', () => {
55+
test('UTXOs should be released when txProposalSend fails', async () => {
56+
expect.hasAssertions();
57+
58+
// Create the spy to mock wallet-lib to force a failure
59+
const spy = jest.spyOn(hathorLib.axios, 'createRequestInstance');
60+
spy.mockReturnValue({
61+
post: () => {
62+
throw new Error('Network error - send failed');
63+
},
64+
// @ts-ignore
65+
get: () => Promise.resolve({
66+
data: {
67+
success: true,
68+
version: '0.38.0',
69+
network: 'mainnet',
70+
min_weight: 14,
71+
min_tx_weight: 14,
72+
min_tx_weight_coefficient: 1.6,
73+
min_tx_weight_k: 100,
74+
token_deposit_percentage: 0.01,
75+
reward_spend_min_blocks: 300,
76+
max_number_inputs: 255,
77+
max_number_outputs: 255,
78+
},
79+
}),
80+
});
81+
82+
// Setup wallet
83+
await addToWalletTable(mysql, [{
84+
id: 'test-wallet',
85+
xpubkey: 'xpubkey',
86+
authXpubkey: 'auth_xpubkey',
87+
status: 'ready',
88+
maxGap: 5,
89+
createdAt: 10000,
90+
readyAt: 10001,
91+
}]);
92+
93+
await addToAddressTable(mysql, [{
94+
address: ADDRESSES[0],
95+
index: 0,
96+
walletId: 'test-wallet',
97+
transactions: 1,
98+
}]);
99+
100+
const tokenId = '00';
101+
const utxos = [{
102+
txId: '004d75c1edd4294379e7e5b7ab6c118c53c8b07a506728feb5688c8d26a97e50',
103+
index: 0,
104+
tokenId,
105+
address: ADDRESSES[0],
106+
value: 100n,
107+
authorities: 0,
108+
timelock: null,
109+
heightlock: null,
110+
locked: false,
111+
spentBy: null,
112+
}];
113+
114+
await addToUtxoTable(mysql, utxos);
115+
await addToWalletBalanceTable(mysql, [{
116+
walletId: 'test-wallet',
117+
tokenId,
118+
unlockedBalance: 100n,
119+
lockedBalance: 0n,
120+
unlockedAuthorities: 0,
121+
lockedAuthorities: 0,
122+
timelockExpires: null,
123+
transactions: 1,
124+
}]);
125+
126+
// Verify UTXO is initially unlocked (no tx_proposal_id)
127+
let utxoResults = await getUtxos(mysql, [{ txId: utxos[0].txId, index: utxos[0].index }]);
128+
expect(utxoResults).toHaveLength(1);
129+
expect(utxoResults[0].txProposalId).toBeNull();
130+
expect(utxoResults[0].txProposalIndex).toBeNull();
131+
132+
// Create transaction
133+
const outputs = [
134+
new hathorLib.Output(
135+
100n,
136+
new hathorLib.P2PKH(new hathorLib.Address(
137+
ADDRESSES[0],
138+
{ network: new hathorLib.Network(process.env.NETWORK) }
139+
)).createScript(),
140+
{ tokenData: 0 }
141+
),
142+
];
143+
const inputs = [new hathorLib.Input(utxos[0].txId, utxos[0].index)];
144+
const transaction = new hathorLib.Transaction(inputs, outputs);
145+
const txHex = transaction.toHex();
146+
147+
// Create tx proposal
148+
const createEvent = makeGatewayEventWithAuthorizer('test-wallet', null, JSON.stringify({ txHex }));
149+
const createResult = await txProposalCreate(createEvent, null, null) as APIGatewayProxyResult;
150+
151+
expect(createResult.statusCode).toBe(201);
152+
const createBody = JSON.parse(createResult.body as string);
153+
expect(createBody.success).toBe(true);
154+
const txProposalId = createBody.txProposalId;
155+
156+
// Verify UTXO is now locked with tx proposal ID
157+
utxoResults = await getUtxos(mysql, [{ txId: utxos[0].txId, index: utxos[0].index }]);
158+
expect(utxoResults).toHaveLength(1);
159+
expect(utxoResults[0].txProposalId).toBe(txProposalId);
160+
expect(utxoResults[0].txProposalIndex).toBe(0);
161+
162+
// Attempt to send the transaction (this will fail due to our mock)
163+
const sendEvent = makeGatewayEventWithAuthorizer(
164+
'test-wallet',
165+
{ txProposalId },
166+
JSON.stringify({ txHex })
167+
);
168+
const sendResult = await txProposalSend(sendEvent, null, null) as APIGatewayProxyResult;
169+
170+
// Verify send failed and proposal status is SEND_ERROR
171+
expect(sendResult.statusCode).toBe(400);
172+
const sendBody = JSON.parse(sendResult.body as string);
173+
expect(sendBody.success).toBe(false);
174+
175+
const txProposal = await getTxProposal(mysql, txProposalId);
176+
expect(txProposal!.status).toBe(TxProposalStatus.SEND_ERROR);
177+
178+
// BUG: UTXO should be released when send fails, but currently it remains locked
179+
utxoResults = await getUtxos(mysql, [{ txId: utxos[0].txId, index: utxos[0].index }]);
180+
expect(utxoResults).toHaveLength(1);
181+
182+
// THIS ASSERTION WILL FAIL because UTXOs are not released on send failure
183+
expect(utxoResults[0].txProposalId).toBeNull(); // Should be null (released)
184+
expect(utxoResults[0].txProposalIndex).toBeNull(); // Should be null (released)
185+
186+
spy.mockRestore();
187+
});
188+
189+
test('UTXOs should remain locked when txProposalSend succeeds', async () => {
190+
expect.hasAssertions();
191+
192+
// Create the spy to mock wallet-lib to force success
193+
const spy = jest.spyOn(hathorLib.axios, 'createRequestInstance');
194+
spy.mockReturnValue({
195+
// @ts-ignore
196+
post: () => Promise.resolve({
197+
data: { success: true, hash: 'mocked-hash' }
198+
}),
199+
// @ts-ignore
200+
get: () => Promise.resolve({
201+
data: {
202+
success: true,
203+
version: '0.38.0',
204+
network: 'mainnet',
205+
min_weight: 14,
206+
min_tx_weight: 14,
207+
min_tx_weight_coefficient: 1.6,
208+
min_tx_weight_k: 100,
209+
token_deposit_percentage: 0.01,
210+
reward_spend_min_blocks: 300,
211+
max_number_inputs: 255,
212+
max_number_outputs: 255,
213+
},
214+
}),
215+
});
216+
217+
// Setup wallet (same as above)
218+
await addToWalletTable(mysql, [{
219+
id: 'test-wallet',
220+
xpubkey: 'xpubkey',
221+
authXpubkey: 'auth_xpubkey',
222+
status: 'ready',
223+
maxGap: 5,
224+
createdAt: 10000,
225+
readyAt: 10001,
226+
}]);
227+
228+
await addToAddressTable(mysql, [{
229+
address: ADDRESSES[0],
230+
index: 0,
231+
walletId: 'test-wallet',
232+
transactions: 1,
233+
}]);
234+
235+
const tokenId = '00';
236+
const utxos = [{
237+
txId: '004d75c1edd4294379e7e5b7ab6c118c53c8b07a506728feb5688c8d26a97e50',
238+
index: 0,
239+
tokenId,
240+
address: ADDRESSES[0],
241+
value: 100n,
242+
authorities: 0,
243+
timelock: null,
244+
heightlock: null,
245+
locked: false,
246+
spentBy: null,
247+
}];
248+
249+
await addToUtxoTable(mysql, utxos);
250+
await addToWalletBalanceTable(mysql, [{
251+
walletId: 'test-wallet',
252+
tokenId,
253+
unlockedBalance: 100n,
254+
lockedBalance: 0n,
255+
unlockedAuthorities: 0,
256+
lockedAuthorities: 0,
257+
timelockExpires: null,
258+
transactions: 1,
259+
}]);
260+
261+
// Create transaction and proposal (same as above)
262+
const outputs = [
263+
new hathorLib.Output(
264+
100n,
265+
new hathorLib.P2PKH(new hathorLib.Address(
266+
ADDRESSES[0],
267+
{ network: new hathorLib.Network(process.env.NETWORK) }
268+
)).createScript(),
269+
{ tokenData: 0 }
270+
),
271+
];
272+
const inputs = [new hathorLib.Input(utxos[0].txId, utxos[0].index)];
273+
const transaction = new hathorLib.Transaction(inputs, outputs);
274+
const txHex = transaction.toHex();
275+
276+
const createEvent = makeGatewayEventWithAuthorizer('test-wallet', null, JSON.stringify({ txHex }));
277+
const createResult = await txProposalCreate(createEvent, null, null) as APIGatewayProxyResult;
278+
const createBody = JSON.parse(createResult.body as string);
279+
const txProposalId = createBody.txProposalId;
280+
281+
// Send the transaction (this will succeed due to our mock)
282+
const sendEvent = makeGatewayEventWithAuthorizer(
283+
'test-wallet',
284+
{ txProposalId },
285+
JSON.stringify({ txHex })
286+
);
287+
const sendResult = await txProposalSend(sendEvent, null, null) as APIGatewayProxyResult;
288+
289+
// Verify send succeeded and proposal status is SENT
290+
expect(sendResult.statusCode).toBe(200);
291+
const sendBody = JSON.parse(sendResult.body as string);
292+
expect(sendBody.success).toBe(true);
293+
294+
const txProposal = await getTxProposal(mysql, txProposalId);
295+
expect(txProposal!.status).toBe(TxProposalStatus.SENT);
296+
297+
// UTXOs should remain locked when send succeeds (they'll be spent when tx is processed)
298+
const utxoResults = await getUtxos(mysql, [{ txId: utxos[0].txId, index: utxos[0].index }]);
299+
expect(utxoResults).toHaveLength(1);
300+
expect(utxoResults[0].txProposalId).toBe(txProposalId); // Should remain locked
301+
expect(utxoResults[0].txProposalIndex).toBe(0); // Should remain locked
302+
303+
spy.mockRestore();
304+
});
305+
});

0 commit comments

Comments
 (0)