diff --git a/packages/hathor-rpc-handler/__tests__/rpcMethods/sendTransaction.test.ts b/packages/hathor-rpc-handler/__tests__/rpcMethods/sendTransaction.test.ts index dfd85926..79b2f3b3 100644 --- a/packages/hathor-rpc-handler/__tests__/rpcMethods/sendTransaction.test.ts +++ b/packages/hathor-rpc-handler/__tests__/rpcMethods/sendTransaction.test.ts @@ -28,6 +28,11 @@ describe('sendTransaction', () => { let wallet: jest.Mocked; let promptHandler: jest.Mock; let sendTransactionMock: jest.Mock; + let mockTransaction: Record; + + // A valid P2PKH script (25 bytes: OP_DUP OP_HASH160 pushdata(20) <20-byte-hash> OP_EQUALVERIFY OP_CHECKSIG) + // so that P2PKH.identify() returns true during fee calculation + const p2pkhScript = Buffer.from([0x76, 0xa9, 0x14, ...new Array(20).fill(0), 0x88, 0xac]); beforeEach(() => { // Setup basic request @@ -51,6 +56,18 @@ describe('sendTransaction', () => { // Mock wallet sendTransactionMock = jest.fn(); + + // Create a mock Transaction object returned by prepareTx() + mockTransaction = { + inputs: [{ hash: 'testTxId', index: 0 }], + outputs: [{ value: BigInt(100), tokenData: 0, script: p2pkhScript }], + tokens: [], + getFeeHeader: jest.fn().mockReturnValue({ + entries: [{ tokenIndex: 0, amount: 0n }], + }), + toHex: jest.fn().mockReturnValue('mockedTxHex'), + }; + wallet = { getNetwork: jest.fn().mockReturnValue('testnet'), getTokenDetails: jest.fn().mockResolvedValue({ @@ -61,21 +78,9 @@ describe('sendTransaction', () => { }, }), sendManyOutputsSendTransaction: jest.fn().mockResolvedValue({ - prepareTxData: jest.fn().mockResolvedValue({ - inputs: [{ - txId: 'testTxId', - index: 0, - value: 100n, - address: 'testAddress', - token: '00', - }], - outputs: [{ - address: 'testAddress', - value: BigInt(100), - token: '00', - }], - }), - run: sendTransactionMock, + prepareTx: jest.fn().mockResolvedValue(mockTransaction), + signTx: jest.fn().mockResolvedValue(mockTransaction), + runFromMining: sendTransactionMock, }), } as unknown as jest.Mocked; @@ -115,21 +120,11 @@ describe('sendTransaction', () => { ...rpcRequest, type: TriggerTypes.SendTransactionConfirmationPrompt, data: { - outputs: [{ - address: 'testAddress', - value: 100n, - token: '00', - }], - inputs: [{ - txId: 'testTxId', - index: 0, - value: 100n, - address: 'testAddress', - token: '00', - }], changeAddress: 'changeAddress', pushTx: true, tokenDetails: new Map(), + fee: 0n, + preparedTx: mockTransaction, }, }, {}); expect(promptHandler).toHaveBeenNthCalledWith(2, { @@ -278,7 +273,7 @@ describe('sendTransaction', () => { it('should throw InsufficientFundsError when not enough funds available', async () => { (wallet.sendManyOutputsSendTransaction as jest.Mock).mockResolvedValue({ - prepareTxData: jest.fn().mockRejectedValue( + prepareTx: jest.fn().mockRejectedValue( new Error('Insufficient amount of tokens') ), }); @@ -293,7 +288,7 @@ describe('sendTransaction', () => { it('should throw SendTransactionError when transaction preparation fails', async () => { (wallet.sendManyOutputsSendTransaction as jest.Mock).mockImplementation(() => { return { - prepareTxData: jest.fn().mockRejectedValue(new Error('Failed to prepare transaction')), + prepareTx: jest.fn().mockRejectedValue(new Error('Failed to prepare transaction')), }; }); @@ -403,9 +398,26 @@ describe('sendTransaction', () => { } as SendTransactionRpcRequest; const mockHex = '00010203'; - const mockTransaction = { + + const mockPreparedTransaction = { + inputs: [{ hash: 'testTxId', index: 0 }], + outputs: [{ value: BigInt(100), tokenData: 0, script: p2pkhScript }], + tokens: [], + getFeeHeader: jest.fn().mockReturnValue({ + entries: [{ tokenIndex: 0, amount: 0n }], + }), + }; + + const mockSignedTransaction = { toHex: jest.fn().mockReturnValue(mockHex), }; + const signTxMock = jest.fn().mockResolvedValue(mockSignedTransaction); + + (wallet.sendManyOutputsSendTransaction as jest.Mock).mockResolvedValue({ + prepareTx: jest.fn().mockResolvedValue(mockPreparedTransaction), + signTx: signTxMock, + runFromMining: sendTransactionMock, + }); promptHandler .mockResolvedValueOnce({ @@ -417,11 +429,10 @@ describe('sendTransaction', () => { data: { accepted: true, pinCode: '1234' }, }); - sendTransactionMock.mockResolvedValue(mockTransaction); - const response = await sendTransaction(requestWithPushTxFalse, wallet, {}, promptHandler); - expect(sendTransactionMock).toHaveBeenCalledWith('prepare-tx', '1234'); + expect(signTxMock).toHaveBeenCalledWith('1234'); + expect(sendTransactionMock).not.toHaveBeenCalled(); // runFromMining should not be called expect(response).toEqual({ type: RpcResponseTypes.SendTransactionResponse, response: mockHex, @@ -438,6 +449,22 @@ describe('sendTransaction', () => { } as SendTransactionRpcRequest; const txResponse = { hash: 'txHash123' }; + const signTxMock = jest.fn().mockResolvedValue({ toHex: jest.fn() }); + + const mockTransaction = { + inputs: [{ hash: 'testTxId', index: 0 }], + outputs: [{ value: BigInt(100), tokenData: 0, script: p2pkhScript }], + tokens: [], + getFeeHeader: jest.fn().mockReturnValue({ + entries: [{ tokenIndex: 0, amount: 0n }], + }), + }; + + (wallet.sendManyOutputsSendTransaction as jest.Mock).mockResolvedValue({ + prepareTx: jest.fn().mockResolvedValue(mockTransaction), + signTx: signTxMock, + runFromMining: sendTransactionMock.mockResolvedValue(txResponse), + }); promptHandler .mockResolvedValueOnce({ @@ -449,14 +476,67 @@ describe('sendTransaction', () => { data: { accepted: true, pinCode: '1234' }, }); - sendTransactionMock.mockResolvedValue(txResponse); - const response = await sendTransaction(requestWithPushTxTrue, wallet, {}, promptHandler); - expect(sendTransactionMock).toHaveBeenCalledWith(null, '1234'); + expect(signTxMock).toHaveBeenCalledWith('1234'); + expect(sendTransactionMock).toHaveBeenCalled(); // runFromMining should be called expect(response).toEqual({ type: RpcResponseTypes.SendTransactionResponse, response: txResponse, }); }); + + it('should calculate non-zero fee when FBT fee header is present', async () => { + const fbtTokenUid = 'fbt-token-uid-abc123'; + + // Use a non-native token in the request outputs + rpcRequest.params.outputs = [{ + address: 'testAddress', + value: '100', + token: fbtTokenUid, + }]; + + const fbtMockTransaction = { + inputs: [{ hash: 'testTxId', index: 0 }], + outputs: [{ value: BigInt(100), tokenData: 0, script: p2pkhScript }], + tokens: [fbtTokenUid], + getFeeHeader: jest.fn().mockReturnValue({ + entries: [{ tokenIndex: 0, amount: 500n }], + }), + toHex: jest.fn().mockReturnValue('mockedTxHex'), + }; + + (wallet.sendManyOutputsSendTransaction as jest.Mock).mockResolvedValue({ + prepareTx: jest.fn().mockResolvedValue(fbtMockTransaction), + signTx: jest.fn().mockResolvedValue(fbtMockTransaction), + runFromMining: sendTransactionMock.mockResolvedValue({ hash: 'txHash123' }), + }); + + promptHandler + .mockResolvedValueOnce({ + type: TriggerResponseTypes.SendTransactionConfirmationResponse, + data: { accepted: true }, + }) + .mockResolvedValueOnce({ + type: TriggerResponseTypes.PinRequestResponse, + data: { accepted: true, pinCode: '1234' }, + }); + + await sendTransaction(rpcRequest, wallet, {}, promptHandler); + + // Verify token details were fetched for the non-native token + expect(wallet.getTokenDetails).toHaveBeenCalledWith(fbtTokenUid); + + // Verify the confirmation prompt was called with non-zero fee and token details + expect(promptHandler).toHaveBeenNthCalledWith(1, + expect.objectContaining({ + type: TriggerTypes.SendTransactionConfirmationPrompt, + data: expect.objectContaining({ + fee: 500n, + tokenDetails: expect.any(Map), + }), + }), + {}, + ); + }); }); diff --git a/packages/hathor-rpc-handler/package.json b/packages/hathor-rpc-handler/package.json index 196dcd8b..d5af075b 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.14.0", + "@hathor/wallet-lib": "2.16.0", "zod": "3.23.8" } } diff --git a/packages/hathor-rpc-handler/src/rpcMethods/sendTransaction.ts b/packages/hathor-rpc-handler/src/rpcMethods/sendTransaction.ts index 70b33530..d96648d1 100644 --- a/packages/hathor-rpc-handler/src/rpcMethods/sendTransaction.ts +++ b/packages/hathor-rpc-handler/src/rpcMethods/sendTransaction.ts @@ -6,9 +6,8 @@ */ import { z } from 'zod'; -import { constants, Transaction } from '@hathor/wallet-lib'; +import { constants, Transaction, tokensUtils } from '@hathor/wallet-lib'; import type { DataScriptOutputRequestObj, IHathorWallet } from '@hathor/wallet-lib'; -import type { IDataOutput } from '@hathor/wallet-lib/lib/types'; import { TriggerTypes, PinConfirmationPrompt, @@ -33,6 +32,23 @@ import { } from '../errors'; import { validateNetwork, fetchTokenDetails } from '../helpers'; +/** + * Unified send transaction interface for both HathorWallet and HathorWalletServiceWallet. + * + * Both wallet implementations provide sendTransaction services that support + * a prepare-then-sign flow through this interface. This allows building the + * transaction before requesting user confirmation, then signing with the PIN + * only after approval. + * + * TODO: Remove this once wallet-lib exports a unified ISendTransaction with + * prepareTx/signTx (see hathor-wallet-lib PR #1022). + */ +interface ISendTransactionObject { + prepareTx(): Promise; + signTx(pin: string): Promise; + runFromMining(): Promise; +} + const OutputValueSchema = z.object({ address: z.string(), value: z.string().regex(/^\d+$/) @@ -101,23 +117,20 @@ export async function sendTransaction( const { params } = validationResult.data; validateNetwork(wallet, params.network); - // sendManyOutputsSendTransaction throws if it doesn't receive a pin, - // but doesn't use it until prepareTxData is called, so we can just assign - // an arbitrary value to it and then mutate the instance after we get the - // actual pin from the pin prompt. - const stubPinCode = '111111'; - - // Create the transaction service but don't run it yet - const sendTransaction = await wallet.sendManyOutputsSendTransaction(params.outputs, { + // Create the transaction service and cast to the unified interface that works + // with both HathorWallet (SendTransaction) and HathorWalletServiceWallet + // (SendTransactionWalletService) implementations. + const sendTransactionObject = await wallet.sendManyOutputsSendTransaction(params.outputs, { inputs: params.inputs || [], changeAddress: params.changeAddress, - pinCode: stubPinCode, - }); + }) as unknown as ISendTransactionObject; - // Prepare the transaction to get all inputs (including automatically selected ones) - let preparedTx; + // Prepare the full transaction without signing to get inputs, outputs, and fee. + // This builds the tx so we can show it to the user for confirmation before + // requesting their PIN. + let preparedTx: Transaction; try { - preparedTx = await sendTransaction.prepareTxData(); + preparedTx = await sendTransactionObject.prepareTx(); } catch (err) { if (err instanceof Error) { if (err.message.includes('Insufficient amount of tokens')) { @@ -127,22 +140,37 @@ export async function sendTransaction( throw new PrepareSendTransactionError(err instanceof Error ? err.message : 'An unknown error occurred while preparing the transaction'); } - // Extract token UIDs from outputs and fetch their details - const tokenUids = preparedTx.outputs - .filter((output): output is IDataOutput & { token: string } => 'token' in output && typeof output.token === 'string') - .map(output => output.token); + // Extract token UIDs from the user's requested outputs and fetch their details + const tokenUids = params.outputs.reduce((acc, output) => { + if ('token' in output && typeof output.token === 'string' && output.token !== constants.NATIVE_TOKEN_UID) { + acc.push(output.token); + } + return acc; + }, []); const tokenDetails = await fetchTokenDetails(wallet, tokenUids); - // Show the complete transaction (with all inputs) to the user + // Calculate network fee: fee header (fee-based tokens) + data output fees. + // We expect all fees to be paid in HTR (tokenIndex 0). + const feeHeader = preparedTx.getFeeHeader(); + if (feeHeader && feeHeader.entries.some(entry => entry.tokenIndex !== 0)) { + throw new PrepareSendTransactionError('Unexpected fee entry with non-HTR token index'); + } + const feeHeaderAmount = feeHeader + ? feeHeader.entries.filter(entry => entry.tokenIndex === 0).reduce((sum, entry) => sum + entry.amount, 0n) + : 0n; + const dataOutputCount = params.outputs.filter(output => 'data' in output).length; + const fee = feeHeaderAmount + tokensUtils.getDataFee(dataOutputCount); + + // Show the user's original parameters for confirmation const prompt: SendTransactionConfirmationPrompt = { ...rpcRequest, type: TriggerTypes.SendTransactionConfirmationPrompt, data: { - outputs: preparedTx.outputs, - inputs: preparedTx.inputs, changeAddress: params.changeAddress, pushTx: params.pushTx, tokenDetails, + fee, + preparedTx, } }; @@ -170,13 +198,16 @@ export async function sendTransaction( promptHandler(loadingTrigger, requestMetadata); try { - // Now execute the prepared transaction + // Sign the prepared transaction with the user's PIN + const signedTx = await sendTransactionObject.signTx(pinResponse.data.pinCode); + let response: Transaction | string; if (params.pushTx === false) { - const transaction = await sendTransaction.run('prepare-tx', pinResponse.data.pinCode); - response = transaction.toHex(); + // Return the signed transaction as hex without mining/pushing + response = signedTx.toHex(); } else { - response = await sendTransaction.run(null, pinResponse.data.pinCode); + // Mine and push the signed transaction + response = await sendTransactionObject.runFromMining(); } const loadingFinishedTrigger: SendTransactionLoadingFinishedTrigger = { diff --git a/packages/hathor-rpc-handler/src/types/prompt.ts b/packages/hathor-rpc-handler/src/types/prompt.ts index ca8406e1..ab43b142 100644 --- a/packages/hathor-rpc-handler/src/types/prompt.ts +++ b/packages/hathor-rpc-handler/src/types/prompt.ts @@ -6,7 +6,7 @@ */ import { AddressInfoObject, GetBalanceObject, TokenDetailsObject } from '@hathor/wallet-lib/lib/wallet/types'; import { NanoContractAction } from '@hathor/wallet-lib/lib/nano_contracts/types'; -import { IDataInput, IDataOutput } from '@hathor/wallet-lib/lib/types'; +import type { Transaction } from '@hathor/wallet-lib'; import { RequestMetadata, RpcRequest } from './rpcRequest'; export enum TriggerTypes { @@ -301,11 +301,19 @@ export interface SignOracleDataConfirmationResponse { export type SendTransactionConfirmationPrompt = BaseConfirmationPrompt & { type: TriggerTypes.SendTransactionConfirmationPrompt; data: { - outputs: IDataOutput[], - inputs: IDataInput[], changeAddress?: string; pushTx: boolean; tokenDetails?: Map; + /** + * Calculated network fee for the transaction. + */ + fee?: bigint; + /** + * The full prepared Transaction object before signing. + * Available for clients that need access to the complete transaction details + * (e.g. inputs with scripts, outputs with token indexes, etc.). + */ + preparedTx: Transaction; } } diff --git a/yarn.lock b/yarn.lock index 583a9065..f42b8311 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.14.0" + "@hathor/wallet-lib": "npm:2.16.0" "@types/eslint__js": "npm:8.42.3" "@types/jest": "npm:29.5.12" "@types/node": "npm:20.14.2" @@ -2573,6 +2573,24 @@ __metadata: languageName: node linkType: hard +"@hathor/wallet-lib@npm:2.16.0": + version: 2.16.0 + resolution: "@hathor/wallet-lib@npm:2.16.0" + 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/808bb2269dbe14be02410a4ee976332d5f0cf20494df69a76f7430e7bea4dba9926ff6b1c709f142ba1377c309949de21d411f47c607b3805c937a3b69ebcc8f + 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"