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
152 changes: 116 additions & 36 deletions packages/hathor-rpc-handler/__tests__/rpcMethods/sendTransaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ describe('sendTransaction', () => {
let wallet: jest.Mocked<IHathorWallet>;
let promptHandler: jest.Mock;
let sendTransactionMock: jest.Mock;
let mockTransaction: Record<string, unknown>;

// 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
Expand All @@ -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({
Expand All @@ -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<IHathorWallet>;

Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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')
),
});
Expand All @@ -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')),
};
});

Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -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({
Expand All @@ -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),
}),
}),
{},
);
});
});
2 changes: 1 addition & 1 deletion packages/hathor-rpc-handler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
83 changes: 57 additions & 26 deletions packages/hathor-rpc-handler/src/rpcMethods/sendTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Transaction>;
signTx(pin: string): Promise<Transaction>;
runFromMining(): Promise<Transaction>;
}

const OutputValueSchema = z.object({
address: z.string(),
value: z.string().regex(/^\d+$/)
Expand Down Expand Up @@ -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')) {
Expand All @@ -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<string[]>((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,
}
};

Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading