diff --git a/packages/hathor-rpc-handler/__tests__/rpcMethods/createNanoContractCreateTokenTx.test.ts b/packages/hathor-rpc-handler/__tests__/rpcMethods/createNanoContractCreateTokenTx.test.ts index 80cb9d61..5c5a1108 100644 --- a/packages/hathor-rpc-handler/__tests__/rpcMethods/createNanoContractCreateTokenTx.test.ts +++ b/packages/hathor-rpc-handler/__tests__/rpcMethods/createNanoContractCreateTokenTx.test.ts @@ -73,6 +73,7 @@ describe('createNanoContractCreateTokenTx', () => { const createMockSendTransactionResult = (mockTransaction: ReturnType) => ({ transaction: mockTransaction, runFromMining: jest.fn().mockResolvedValue({ tx_id: 'mock-tx-id' }), + releaseUtxos: jest.fn().mockResolvedValue(undefined), }); beforeEach(() => { @@ -388,6 +389,7 @@ describe('createNanoContractCreateTokenTx', () => { const mockSendTxResult = { transaction: mockTransaction, runFromMining: jest.fn().mockResolvedValue({ tx_id: 'mock-tx-id' }), + releaseUtxos: jest.fn().mockResolvedValue(undefined), }; (wallet.createNanoContractCreateTokenTransaction as jest.Mock).mockResolvedValue(mockSendTxResult); @@ -547,6 +549,7 @@ describe('createNanoContractCreateTokenTx', () => { const mockSendTxResult = { transaction: mockTransaction, runFromMining: jest.fn().mockResolvedValue({ tx_id: 'mock-tx-id' }), + releaseUtxos: jest.fn().mockResolvedValue(undefined), }; (wallet.createNanoContractCreateTokenTransaction as jest.Mock).mockResolvedValue(mockSendTxResult); @@ -632,6 +635,129 @@ describe('createNanoContractCreateTokenTx', () => { .rejects.toThrow(SendNanoContractTxError); }); + it('should call releaseUtxos when user rejects the confirmation prompt', async () => { + const mockTransaction = createMockTransaction(); + const mockSendTxResult = createMockSendTransactionResult(mockTransaction); + + (wallet.createNanoContractCreateTokenTransaction as jest.Mock).mockResolvedValue(mockSendTxResult); + + promptHandler.mockResolvedValueOnce({ + type: TriggerResponseTypes.CreateNanoContractCreateTokenTxConfirmationResponse, + data: { accepted: false }, + }); + + await expect(createNanoContractCreateTokenTx(rpcRequest, wallet, {}, promptHandler)).rejects.toThrow(PromptRejectedError); + + expect(mockSendTxResult.releaseUtxos).toHaveBeenCalledTimes(1); + }); + + it('should call releaseUtxos when user rejects the PIN prompt', async () => { + const mockTransaction = createMockTransaction(); + const mockSendTxResult = createMockSendTransactionResult(mockTransaction); + + (wallet.createNanoContractCreateTokenTransaction as jest.Mock).mockResolvedValue(mockSendTxResult); + + promptHandler + .mockResolvedValueOnce({ + type: TriggerResponseTypes.CreateNanoContractCreateTokenTxConfirmationResponse, + data: { + accepted: true, + nano: { + blueprintId: (rpcRequest.params.data as NanoData).blueprint_id, + ncId: (rpcRequest.params.data as NanoData).nc_id, + actions: (rpcRequest.params.data as NanoData).actions, + args: (rpcRequest.params.data as NanoData).args, + method: rpcRequest.params.method, + pushTx: true, + caller: 'wallet1', + fee: 100n, + parsedArgs: [], + }, + token: createTokenOptions, + }, + }) + .mockResolvedValueOnce({ + type: TriggerResponseTypes.PinRequestResponse, + data: { accepted: false }, + }); + + await expect(createNanoContractCreateTokenTx(rpcRequest, wallet, {}, promptHandler)).rejects.toThrow(PromptRejectedError); + + expect(mockSendTxResult.releaseUtxos).toHaveBeenCalledTimes(1); + }); + + it('should call releaseUtxos when signTx fails', async () => { + const mockTransaction = createMockTransaction(); + const mockSendTxResult = createMockSendTransactionResult(mockTransaction); + + (wallet.createNanoContractCreateTokenTransaction as jest.Mock).mockResolvedValue(mockSendTxResult); + (wallet.signTx as jest.Mock).mockRejectedValue(new Error('Sign failed')); + + promptHandler + .mockResolvedValueOnce({ + type: TriggerResponseTypes.CreateNanoContractCreateTokenTxConfirmationResponse, + data: { + accepted: true, + nano: { + blueprintId: (rpcRequest.params.data as NanoData).blueprint_id, + ncId: (rpcRequest.params.data as NanoData).nc_id, + actions: (rpcRequest.params.data as NanoData).actions, + args: (rpcRequest.params.data as NanoData).args, + method: rpcRequest.params.method, + pushTx: true, + caller: 'wallet1', + fee: 100n, + parsedArgs: [], + }, + token: createTokenOptions, + }, + }) + .mockResolvedValueOnce({ + type: TriggerResponseTypes.PinRequestResponse, + data: { accepted: true, pinCode: '1234' }, + }); + + await expect(createNanoContractCreateTokenTx(rpcRequest, wallet, {}, promptHandler)).rejects.toThrow(SendNanoContractTxError); + + expect(mockSendTxResult.releaseUtxos).toHaveBeenCalledTimes(1); + }); + + it('should call releaseUtxos when runFromMining fails', async () => { + const mockTransaction = createMockTransaction(); + const mockSendTxResult = createMockSendTransactionResult(mockTransaction); + mockSendTxResult.runFromMining.mockRejectedValue(new Error('Mining failed')); + + (wallet.createNanoContractCreateTokenTransaction as jest.Mock).mockResolvedValue(mockSendTxResult); + + promptHandler + .mockResolvedValueOnce({ + type: TriggerResponseTypes.CreateNanoContractCreateTokenTxConfirmationResponse, + data: { + accepted: true, + nano: { + blueprintId: (rpcRequest.params.data as NanoData).blueprint_id, + ncId: (rpcRequest.params.data as NanoData).nc_id, + actions: (rpcRequest.params.data as NanoData).actions, + args: (rpcRequest.params.data as NanoData).args, + method: rpcRequest.params.method, + pushTx: true, + caller: 'wallet1', + fee: 100n, + parsedArgs: [], + }, + token: createTokenOptions, + }, + }) + .mockResolvedValueOnce({ + type: TriggerResponseTypes.PinRequestResponse, + data: { accepted: true, pinCode: '1234' }, + }); + + await expect(createNanoContractCreateTokenTx(rpcRequest, wallet, {}, promptHandler)).rejects.toThrow(SendNanoContractTxError); + + expect(mockSendTxResult.releaseUtxos).toHaveBeenCalledTimes(1); + }); + it('should default to DEPOSIT version when createTokenOptions.version is not provided', async () => { const pinCode = '1234'; diff --git a/packages/hathor-rpc-handler/__tests__/rpcMethods/sendNanoContractTx.test.ts b/packages/hathor-rpc-handler/__tests__/rpcMethods/sendNanoContractTx.test.ts index f77010ad..5a941873 100644 --- a/packages/hathor-rpc-handler/__tests__/rpcMethods/sendNanoContractTx.test.ts +++ b/packages/hathor-rpc-handler/__tests__/rpcMethods/sendNanoContractTx.test.ts @@ -9,7 +9,7 @@ import { nanoUtils, ncApi, type IHathorWallet } from '@hathor/wallet-lib'; import { NanoContractAction } from '@hathor/wallet-lib/lib/nano_contracts/types'; import { sendNanoContractTx, NanoContractActionWithStringAmount } from '../../src/rpcMethods/sendNanoContractTx'; import { TriggerTypes, RpcMethods, SendNanoContractRpcRequest, SendNanoContractTxConfirmationPrompt, TriggerResponseTypes, RpcResponseTypes } from '../../src/types'; -import { SendNanoContractTxError, InvalidParamsError } from '../../src/errors'; +import { SendNanoContractTxError, InvalidParamsError, PromptRejectedError } from '../../src/errors'; // Mock transactionUtils.signTransaction jest.mock('@hathor/wallet-lib', () => { @@ -76,6 +76,7 @@ describe('sendNanoContractTx', () => { createNanoContractTransaction: jest.fn().mockResolvedValue({ transaction: mockTransaction, runFromMining: jest.fn().mockResolvedValue({ tx_id: 'mock-tx-id' }), + releaseUtxos: jest.fn().mockResolvedValue(undefined), }), getServerUrl: jest.fn(), getTokenDetails: jest.fn().mockResolvedValue({ @@ -111,6 +112,7 @@ describe('sendNanoContractTx', () => { const mockSendTx = { transaction: mockTransaction, runFromMining: jest.fn().mockResolvedValue(response), + releaseUtxos: jest.fn().mockResolvedValue(undefined), }; (wallet.createNanoContractTransaction as jest.Mock).mockResolvedValue(mockSendTx); @@ -222,6 +224,7 @@ describe('sendNanoContractTx', () => { const mockSendTx = { transaction: mockTransaction, runFromMining: jest.fn().mockResolvedValue({ tx_id: 'mock-tx-id' }), + releaseUtxos: jest.fn().mockResolvedValue(undefined), }; (wallet.createNanoContractTransaction as jest.Mock).mockResolvedValue(mockSendTx); @@ -302,6 +305,7 @@ describe('sendNanoContractTx', () => { const mockSendTx = { transaction: mockTransaction, runFromMining: jest.fn().mockResolvedValue({ tx_id: 'mock-tx-id' }), + releaseUtxos: jest.fn().mockResolvedValue(undefined), }; (wallet.createNanoContractTransaction as jest.Mock).mockResolvedValue(mockSendTx); @@ -372,6 +376,7 @@ describe('sendNanoContractTx', () => { (wallet.createNanoContractTransaction as jest.Mock).mockResolvedValue({ transaction: mockTransaction, runFromMining: jest.fn().mockRejectedValue(new Error('Transaction failed')), + releaseUtxos: jest.fn().mockResolvedValue(undefined), }); promptHandler @@ -414,6 +419,143 @@ describe('sendNanoContractTx', () => { type: TriggerTypes.SendNanoContractTxLoadingTrigger, }, {}); }); + + it('should call releaseUtxos when user rejects the confirmation prompt', async () => { + const address = 'address123'; + + const mockTransaction = { + getFeeHeader: jest.fn().mockReturnValue({ entries: [] }), + getNanoHeaders: jest.fn().mockReturnValue([{ address: null, seqnum: 0 }]), + prepareToSend: jest.fn(), + toHex: jest.fn().mockReturnValue('tx-hex'), + }; + const mockReleaseUtxos = jest.fn().mockResolvedValue(undefined); + const mockSendTx = { + transaction: mockTransaction, + runFromMining: jest.fn().mockResolvedValue({ tx_id: 'mock-tx-id' }), + releaseUtxos: mockReleaseUtxos, + }; + (wallet.createNanoContractTransaction as jest.Mock).mockResolvedValue(mockSendTx); + + promptHandler.mockResolvedValueOnce({ + type: TriggerResponseTypes.SendNanoContractTxConfirmationResponse, + data: { + accepted: false, + nc: { + caller: address, + method: rpcRequest.params.method, + blueprintId: rpcRequest.params.blueprint_id, + ncId: rpcRequest.params.nc_id, + args: rpcRequest.params.args, + parsedArgs: [], + actions: [], + pushTx: true, + fee: 0n, + }, + }, + }); + + await expect(sendNanoContractTx(rpcRequest, wallet, {}, promptHandler)).rejects.toThrow(PromptRejectedError); + + expect(mockReleaseUtxos).toHaveBeenCalledTimes(1); + }); + + it('should call releaseUtxos when user rejects the PIN prompt', async () => { + const address = 'address123'; + + const mockTransaction = { + getFeeHeader: jest.fn().mockReturnValue({ entries: [] }), + getNanoHeaders: jest.fn().mockReturnValue([{ address: null, seqnum: 0 }]), + prepareToSend: jest.fn(), + toHex: jest.fn().mockReturnValue('tx-hex'), + }; + const mockReleaseUtxos = jest.fn().mockResolvedValue(undefined); + const mockSendTx = { + transaction: mockTransaction, + runFromMining: jest.fn().mockResolvedValue({ tx_id: 'mock-tx-id' }), + releaseUtxos: mockReleaseUtxos, + }; + (wallet.createNanoContractTransaction as jest.Mock).mockResolvedValue(mockSendTx); + + promptHandler + .mockResolvedValueOnce({ + type: TriggerResponseTypes.SendNanoContractTxConfirmationResponse, + data: { + accepted: true, + nc: { + caller: address, + method: rpcRequest.params.method, + blueprintId: rpcRequest.params.blueprint_id, + ncId: rpcRequest.params.nc_id, + args: rpcRequest.params.args, + parsedArgs: [], + actions: [], + pushTx: true, + fee: 0n, + }, + }, + }) + .mockResolvedValueOnce({ + type: TriggerResponseTypes.PinRequestResponse, + data: { + accepted: false, + pinCode: '', + }, + }); + + await expect(sendNanoContractTx(rpcRequest, wallet, {}, promptHandler)).rejects.toThrow(PromptRejectedError); + + expect(mockReleaseUtxos).toHaveBeenCalledTimes(1); + }); + + it('should call releaseUtxos when runFromMining fails', async () => { + const address = 'address123'; + const pinCode = '1234'; + + const mockTransaction = { + getFeeHeader: jest.fn().mockReturnValue({ entries: [] }), + getNanoHeaders: jest.fn().mockReturnValue([{ address: null, seqnum: 0 }]), + prepareToSend: jest.fn(), + toHex: jest.fn().mockReturnValue('tx-hex'), + }; + const mockReleaseUtxos = jest.fn().mockResolvedValue(undefined); + const mockSendTx = { + transaction: mockTransaction, + runFromMining: jest.fn().mockRejectedValue(new Error('Mining failed')), + releaseUtxos: mockReleaseUtxos, + }; + (wallet.createNanoContractTransaction as jest.Mock).mockResolvedValue(mockSendTx); + + promptHandler + .mockResolvedValueOnce({ + type: TriggerResponseTypes.SendNanoContractTxConfirmationResponse, + data: { + accepted: true, + nc: { + caller: address, + method: rpcRequest.params.method, + blueprintId: rpcRequest.params.blueprint_id, + ncId: rpcRequest.params.nc_id, + args: rpcRequest.params.args, + parsedArgs: [], + actions: [], + pushTx: true, + fee: 0n, + }, + }, + }) + .mockResolvedValueOnce({ + type: TriggerResponseTypes.PinRequestResponse, + data: { + accepted: true, + pinCode, + }, + }); + + await expect(sendNanoContractTx(rpcRequest, wallet, {}, promptHandler)).rejects.toThrow(SendNanoContractTxError); + + expect(mockReleaseUtxos).toHaveBeenCalledTimes(1); + }); }); describe('fee pre-calculation', () => { @@ -436,6 +578,7 @@ describe('fee pre-calculation', () => { createAndSendNanoContractTransaction: jest.fn(), createNanoContractTransaction: jest.fn().mockResolvedValue({ transaction: mockTransaction, + releaseUtxos: jest.fn().mockResolvedValue(undefined), }), getServerUrl: jest.fn(), getTokenDetails: jest.fn().mockResolvedValue({ @@ -488,6 +631,7 @@ describe('fee pre-calculation', () => { createAndSendNanoContractTransaction: jest.fn(), createNanoContractTransaction: jest.fn().mockResolvedValue({ transaction: mockTransaction, + releaseUtxos: jest.fn().mockResolvedValue(undefined), }), getServerUrl: jest.fn(), getTokenDetails: jest.fn().mockResolvedValue({ @@ -547,6 +691,7 @@ describe('sendNanoContractTx parameter validation', () => { createNanoContractTransaction: jest.fn().mockImplementation(() => ({ transaction: mockTransaction, runFromMining: jest.fn().mockResolvedValue({ tx_id: 'mock-tx-id' }), + releaseUtxos: jest.fn().mockResolvedValue(undefined), })), getServerUrl: jest.fn(), getFullTxById: jest.fn().mockImplementation(() => ({ diff --git a/packages/hathor-rpc-handler/__tests__/rpcMethods/sendTransaction.test.ts b/packages/hathor-rpc-handler/__tests__/rpcMethods/sendTransaction.test.ts index 79b2f3b3..ab084c35 100644 --- a/packages/hathor-rpc-handler/__tests__/rpcMethods/sendTransaction.test.ts +++ b/packages/hathor-rpc-handler/__tests__/rpcMethods/sendTransaction.test.ts @@ -81,6 +81,7 @@ describe('sendTransaction', () => { prepareTx: jest.fn().mockResolvedValue(mockTransaction), signTx: jest.fn().mockResolvedValue(mockTransaction), runFromMining: sendTransactionMock, + releaseUtxos: jest.fn().mockResolvedValue(undefined), }), } as unknown as jest.Mocked; @@ -417,6 +418,7 @@ describe('sendTransaction', () => { prepareTx: jest.fn().mockResolvedValue(mockPreparedTransaction), signTx: signTxMock, runFromMining: sendTransactionMock, + releaseUtxos: jest.fn().mockResolvedValue(undefined), }); promptHandler @@ -464,6 +466,7 @@ describe('sendTransaction', () => { prepareTx: jest.fn().mockResolvedValue(mockTransaction), signTx: signTxMock, runFromMining: sendTransactionMock.mockResolvedValue(txResponse), + releaseUtxos: jest.fn().mockResolvedValue(undefined), }); promptHandler @@ -510,6 +513,7 @@ describe('sendTransaction', () => { prepareTx: jest.fn().mockResolvedValue(fbtMockTransaction), signTx: jest.fn().mockResolvedValue(fbtMockTransaction), runFromMining: sendTransactionMock.mockResolvedValue({ hash: 'txHash123' }), + releaseUtxos: jest.fn().mockResolvedValue(undefined), }); promptHandler @@ -539,4 +543,77 @@ describe('sendTransaction', () => { {}, ); }); + + it('should call releaseUtxos when user rejects confirmation prompt', async () => { + const releaseUtxosMock = jest.fn().mockResolvedValue(undefined); + (wallet.sendManyOutputsSendTransaction as jest.Mock).mockResolvedValue({ + prepareTx: jest.fn().mockResolvedValue(mockTransaction), + signTx: jest.fn().mockResolvedValue(mockTransaction), + runFromMining: sendTransactionMock, + releaseUtxos: releaseUtxosMock, + }); + + promptHandler.mockResolvedValueOnce({ + type: TriggerResponseTypes.SendTransactionConfirmationResponse, + data: { accepted: false }, + }); + + await expect(sendTransaction(rpcRequest, wallet, {}, promptHandler)) + .rejects + .toThrow(PromptRejectedError); + + expect(releaseUtxosMock).toHaveBeenCalledTimes(1); + }); + + it('should call releaseUtxos when user rejects PIN prompt', async () => { + const releaseUtxosMock = jest.fn().mockResolvedValue(undefined); + (wallet.sendManyOutputsSendTransaction as jest.Mock).mockResolvedValue({ + prepareTx: jest.fn().mockResolvedValue(mockTransaction), + signTx: jest.fn().mockResolvedValue(mockTransaction), + runFromMining: sendTransactionMock, + releaseUtxos: releaseUtxosMock, + }); + + promptHandler + .mockResolvedValueOnce({ + type: TriggerResponseTypes.SendTransactionConfirmationResponse, + data: { accepted: true }, + }) + .mockResolvedValueOnce({ + type: TriggerResponseTypes.PinRequestResponse, + data: { accepted: false }, + }); + + await expect(sendTransaction(rpcRequest, wallet, {}, promptHandler)) + .rejects + .toThrow(PromptRejectedError); + + expect(releaseUtxosMock).toHaveBeenCalledTimes(1); + }); + + it('should call releaseUtxos (best-effort) when transaction execution fails', async () => { + const releaseUtxosMock = jest.fn().mockResolvedValue(undefined); + (wallet.sendManyOutputsSendTransaction as jest.Mock).mockResolvedValue({ + prepareTx: jest.fn().mockResolvedValue(mockTransaction), + signTx: jest.fn().mockResolvedValue(mockTransaction), + runFromMining: jest.fn().mockRejectedValue(new Error('execution failed')), + releaseUtxos: releaseUtxosMock, + }); + + promptHandler + .mockResolvedValueOnce({ + type: TriggerResponseTypes.SendTransactionConfirmationResponse, + data: { accepted: true }, + }) + .mockResolvedValueOnce({ + type: TriggerResponseTypes.PinRequestResponse, + data: { accepted: true, pinCode: '1234' }, + }); + + await expect(sendTransaction(rpcRequest, wallet, {}, promptHandler)) + .rejects + .toThrow(SendTransactionError); + + expect(releaseUtxosMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/hathor-rpc-handler/package.json b/packages/hathor-rpc-handler/package.json index 5e045e61..d0767ee4 100644 --- a/packages/hathor-rpc-handler/package.json +++ b/packages/hathor-rpc-handler/package.json @@ -30,7 +30,7 @@ "typescript-eslint": "7.13.0" }, "dependencies": { - "@hathor/wallet-lib": "2.17.0", + "@hathor/wallet-lib": "3.0.1", "zod": "3.23.8" } } diff --git a/packages/hathor-rpc-handler/src/rpcMethods/createNanoContractCreateTokenTx.ts b/packages/hathor-rpc-handler/src/rpcMethods/createNanoContractCreateTokenTx.ts index af897f6d..bbdf2b0c 100644 --- a/packages/hathor-rpc-handler/src/rpcMethods/createNanoContractCreateTokenTx.ts +++ b/packages/hathor-rpc-handler/src/rpcMethods/createNanoContractCreateTokenTx.ts @@ -24,6 +24,7 @@ import { import { PromptRejectedError, InvalidParamsError, SendNanoContractTxError } from '../errors'; import { INanoContractActionSchema } from '@hathor/wallet-lib'; import { bigIntCoercibleSchema } from '@hathor/wallet-lib/lib/utils/bigint'; +import type { ISendTransaction } from '@hathor/wallet-lib/lib/wallet/types'; import { createTokenBaseSchema, createNanoContractCreateTokenTxConfirmationResponseSchema } from '../schemas'; const createNanoContractCreateTokenTxSchema = z.object({ @@ -101,7 +102,7 @@ export async function createNanoContractCreateTokenTx( args: data?.args ?? [], }; - const preBuildResult = await wallet.createNanoContractCreateTokenTransaction( + const preBuildResult: ISendTransaction = await wallet.createNanoContractCreateTokenTransaction( method, address, preBuildData, @@ -163,6 +164,7 @@ export async function createNanoContractCreateTokenTx( const confirmationResponse = responseValidation.data; if (!confirmationResponse.data.accepted) { + await preBuildResult.releaseUtxos(); throw new PromptRejectedError('User rejected nano contract create token transaction prompt'); } @@ -175,6 +177,7 @@ export async function createNanoContractCreateTokenTx( }; const pinResponse = await promptHandler(pinPrompt, requestMetadata) as PinRequestResponse; if (!pinResponse.data.accepted) { + await preBuildResult.releaseUtxos(); throw new PromptRejectedError('User rejected PIN prompt'); } @@ -184,35 +187,43 @@ export async function createNanoContractCreateTokenTx( }; promptHandler(loadingTrigger, requestMetadata); - // If caller changed, update the pre-built transaction - if (confirmedCaller !== address) { - const nanoHeaders = preBuildResult.transaction.getNanoHeaders(); - if (!nanoHeaders || nanoHeaders.length === 0) { - throw new SendNanoContractTxError('No nano headers found in the transaction'); + try { + // If caller changed, update the pre-built transaction + if (confirmedCaller !== address) { + const nanoHeaders = preBuildResult.transaction.getNanoHeaders(); + if (!nanoHeaders || nanoHeaders.length === 0) { + throw new SendNanoContractTxError('No nano headers found in the transaction'); + } + await wallet.setNanoHeaderCaller(nanoHeaders[0], confirmedCaller); } - await wallet.setNanoHeaderCaller(nanoHeaders[0], confirmedCaller); - } - - await wallet.signTx(preBuildResult.transaction, { pinCode: pinResponse.data.pinCode }); - // Send or return hex based on push_tx flag - let response: Transaction | string; - if (push_tx) { - // Send the transaction - response = await preBuildResult.runFromMining(); - } else { - // Convert to hex format for the response when not pushing to network - response = preBuildResult.transaction.toHex(); - } + await wallet.signTx(preBuildResult.transaction, { pinCode: pinResponse.data.pinCode }); - // Emit loading finished trigger - const loadingFinishedTrigger: CreateNanoContractCreateTokenTxLoadingFinishedTrigger = { - type: TriggerTypes.CreateNanoContractCreateTokenTxLoadingFinishedTrigger, - }; - promptHandler(loadingFinishedTrigger, requestMetadata); + // Send or return hex based on push_tx flag + let response: Transaction | string; + if (push_tx) { + // Send the transaction + response = await preBuildResult.runFromMining(); + } else { + // Convert to hex format for the response when not pushing to network + response = preBuildResult.transaction.toHex(); + } - return { - type: RpcResponseTypes.CreateNanoContractCreateTokenTxResponse, - response, - }; + // Emit loading finished trigger + const loadingFinishedTrigger: CreateNanoContractCreateTokenTxLoadingFinishedTrigger = { + type: TriggerTypes.CreateNanoContractCreateTokenTxLoadingFinishedTrigger, + }; + promptHandler(loadingFinishedTrigger, requestMetadata); + + return { + type: RpcResponseTypes.CreateNanoContractCreateTokenTxResponse, + response, + }; + } catch (err) { + await preBuildResult.releaseUtxos(); + if (err instanceof Error) { + throw new SendNanoContractTxError(err.message); + } + throw new SendNanoContractTxError('An unknown error occurred'); + } } diff --git a/packages/hathor-rpc-handler/src/rpcMethods/sendNanoContractTx.ts b/packages/hathor-rpc-handler/src/rpcMethods/sendNanoContractTx.ts index e08914ae..22c03c37 100644 --- a/packages/hathor-rpc-handler/src/rpcMethods/sendNanoContractTx.ts +++ b/packages/hathor-rpc-handler/src/rpcMethods/sendNanoContractTx.ts @@ -23,6 +23,7 @@ import { import { PromptRejectedError, SendNanoContractTxError, InvalidParamsError } from '../errors'; import { INanoContractActionSchema, NanoContractAction, ncApi, nanoUtils, Network, config, HathorWallet } from '@hathor/wallet-lib'; import { bigIntCoercibleSchema } from '@hathor/wallet-lib/lib/utils/bigint'; +import type { ISendTransaction } from '@hathor/wallet-lib/lib/wallet/types'; import { fetchTokenDetails } from '../helpers'; import { sendNanoContractTxConfirmationResponseSchema } from '../schemas'; @@ -130,7 +131,7 @@ export async function sendNanoContractTx( args: params.args, }; - const preBuildSendTx = await wallet.createNanoContractTransaction( + const preBuildSendTx: ISendTransaction = await wallet.createNanoContractTransaction( params.method, tempCallerAddress, preBuildTxData, @@ -184,6 +185,7 @@ export async function sendNanoContractTx( const sendNanoContractTxResponse = responseValidation.data; if (!sendNanoContractTxResponse.data.accepted) { + await preBuildSendTx.releaseUtxos(); throw new PromptRejectedError(); } @@ -192,6 +194,7 @@ export async function sendNanoContractTx( const pinCodeResponse: PinRequestResponse = (await triggerHandler(pinPrompt, requestMetadata)) as PinRequestResponse; if (!pinCodeResponse.data.accepted) { + await preBuildSendTx.releaseUtxos(); throw new PromptRejectedError('Pin prompt rejected'); } @@ -232,6 +235,7 @@ export async function sendNanoContractTx( response, } as RpcResponse; } catch (err) { + await preBuildSendTx.releaseUtxos(); if (err instanceof Error) { throw new SendNanoContractTxError(err.message); } else { diff --git a/packages/hathor-rpc-handler/src/rpcMethods/sendTransaction.ts b/packages/hathor-rpc-handler/src/rpcMethods/sendTransaction.ts index bfd17be2..76f8f9c2 100644 --- a/packages/hathor-rpc-handler/src/rpcMethods/sendTransaction.ts +++ b/packages/hathor-rpc-handler/src/rpcMethods/sendTransaction.ts @@ -47,6 +47,11 @@ interface ISendTransactionObject { prepareTx(): Promise; signTx(pin: string): Promise; runFromMining(): Promise; + /** + * Releases the UTXOs associated with the transaction. + * This is a best-effort operation that will not throw an error. + */ + releaseUtxos(): Promise; } const OutputValueSchema = z.object({ @@ -182,6 +187,7 @@ export async function sendTransaction( const sendResponse = await promptHandler(prompt, requestMetadata) as SendTransactionConfirmationResponse; if (!sendResponse.data.accepted) { + await sendTransactionObject.releaseUtxos(); throw new PromptRejectedError('User rejected send transaction prompt'); } @@ -194,6 +200,7 @@ export async function sendTransaction( const pinResponse = await promptHandler(pinPrompt, requestMetadata) as PinRequestResponse; if (!pinResponse.data.accepted) { + await sendTransactionObject.releaseUtxos(); throw new PromptRejectedError('User rejected PIN prompt'); } @@ -225,6 +232,7 @@ export async function sendTransaction( response, } as RpcResponse; } catch (err) { + await sendTransactionObject.releaseUtxos(); throw new SendTransactionError(err instanceof Error ? err.message : 'An unknown error occurred while sending the transaction'); } } diff --git a/yarn.lock b/yarn.lock index a2923365..0082c051 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2456,7 +2456,7 @@ __metadata: resolution: "@hathor/hathor-rpc-handler@workspace:packages/hathor-rpc-handler" dependencies: "@eslint/js": "npm:9.4.0" - "@hathor/wallet-lib": "npm:2.17.0" + "@hathor/wallet-lib": "npm:3.0.1" "@types/eslint__js": "npm:8.42.3" "@types/jest": "npm:29.5.12" "@types/node": "npm:22.0.0" @@ -2591,6 +2591,24 @@ __metadata: languageName: node linkType: hard +"@hathor/wallet-lib@npm:3.0.1": + version: 3.0.1 + resolution: "@hathor/wallet-lib@npm:3.0.1" + dependencies: + axios: "npm:1.7.7" + bitcore-lib: "npm:8.25.10" + bitcore-mnemonic: "npm:8.25.10" + buffer: "npm:6.0.3" + crypto-js: "npm:4.2.0" + isomorphic-ws: "npm:5.0.0" + lodash: "npm:4.17.21" + queue-microtask: "npm:1.2.3" + ws: "npm:8.17.1" + zod: "npm:3.23.8" + checksum: 10/db74f87ffbc3c9fb915db88c8290821fc61629d9558b3aed3e9d61248caa7513fdd53af12f87e600ac2e2534903a52ae75c4f3832599728370e7b9523639d04e + languageName: node + linkType: hard + "@hathor/web-wallet@workspace:packages/web-wallet": version: 0.0.0-use.local resolution: "@hathor/web-wallet@workspace:packages/web-wallet"