Skip to content
Merged
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: any;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// 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,13 @@ 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',
}],
inputs: [{ txId: 'testTxId', index: 0 }],
outputs: [{ address: 'testAddress', value: BigInt(100), token: '00' }],
changeAddress: 'changeAddress',
pushTx: true,
tokenDetails: new Map(),
networkFee: 0n,
preparedTx: mockTransaction,
},
}, {});
expect(promptHandler).toHaveBeenNthCalledWith(2, {
Expand Down Expand Up @@ -278,7 +275,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 +290,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 +400,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 +431,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 +451,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,11 +478,10 @@ 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,
Expand Down
79 changes: 53 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 ISendTransactionService {
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 sendTransactionService = await wallet.sendManyOutputsSendTransaction(params.outputs, {
inputs: params.inputs || [],
changeAddress: params.changeAddress,
pinCode: stubPinCode,
});
}) as unknown as ISendTransactionService;

// 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 sendTransactionService.prepareTx();
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('Insufficient amount of tokens')) {
Expand All @@ -127,22 +140,33 @@ 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
.filter((output): output is { token: string } & typeof output => 'token' in output && typeof output.token === 'string')
.map(output => output.token)
.filter(uid => uid !== constants.NATIVE_TOKEN_UID);

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.

Can't you also filter for output.token !== constants.NATIVE_TOKEN_UID so you don't have to loop again?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done 821d51e

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
const feeHeader = preparedTx.getFeeHeader();
const feeHeaderAmount = feeHeader
? feeHeader.entries.reduce((sum, entry) => sum + entry.amount, 0n)
: 0n;
const dataOutputCount = params.outputs.filter(output => 'data' in output).length;
const networkFee = 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,
outputs: params.outputs,
inputs: params.inputs,
changeAddress: params.changeAddress,
pushTx: params.pushTx,
tokenDetails,
networkFee,
preparedTx,
}
};

Expand Down Expand Up @@ -170,13 +194,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 sendTransactionService.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 sendTransactionService.runFromMining();
}

const loadingFinishedTrigger: SendTransactionLoadingFinishedTrigger = {
Expand Down
50 changes: 47 additions & 3 deletions packages/hathor-rpc-handler/src/types/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
* LICENSE file in the root directory of this source tree.
*/
import { AddressInfoObject, GetBalanceObject, TokenDetailsObject } from '@hathor/wallet-lib/lib/wallet/types';
import { TokenVersion } from '@hathor/wallet-lib';
import { NanoContractAction } from '@hathor/wallet-lib/lib/nano_contracts/types';
import { IDataInput, IDataOutput } from '@hathor/wallet-lib/lib/types';
import type { DataScriptOutputRequestObj, Transaction } from '@hathor/wallet-lib';
import { RequestMetadata, RpcRequest } from './rpcRequest';

export enum TriggerTypes {
Expand Down Expand Up @@ -186,6 +187,11 @@ export interface NanoContractParams {
parsedArgs: unknown[];
pushTx: boolean;
tokenDetails?: Map<string, TokenDetailsObject>;
/**
* Calculated network fee for the transaction.
* This is calculated based on the token outputs and their versions.
*/
networkFee?: bigint;
}

export interface CreateTokenParams {
Expand All @@ -202,6 +208,21 @@ export interface CreateTokenParams {
meltAuthorityAddress: string | null,
allowExternalMeltAuthorityAddress: boolean,
data: string[] | null,
/**
* Token version for the new token.
* If not provided, the wallet-lib will use the default (NATIVE).
*/
tokenVersion?: TokenVersion,
/**
* Calculated network fee for the token creation.
* This is only applicable for fee tokens (TokenVersion.FEE).
*/
networkFee?: bigint,
/**
* Calculated deposit amount for the token creation.
* This is only applicable for deposit tokens (TokenVersion.DEPOSIT).
*/
depositAmount?: bigint,
}

// Extended type for nano contract token creation
Expand Down Expand Up @@ -298,14 +319,37 @@ export interface SignOracleDataConfirmationResponse {
data: boolean;
}

export interface SendTransactionOutputParam {
address: string;
value: bigint;
token: string;
timelock?: number;
}

export interface SendTransactionInputParam {
txId: string;
index: number;
}

export type SendTransactionConfirmationPrompt = BaseConfirmationPrompt & {
type: TriggerTypes.SendTransactionConfirmationPrompt;
data: {
outputs: IDataOutput[],

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.

inputs: IDataInput[],
outputs: (SendTransactionOutputParam | DataScriptOutputRequestObj)[];
inputs?: SendTransactionInputParam[];
changeAddress?: string;
pushTx: boolean;
tokenDetails?: Map<string, TokenDetailsObject>;
/**
* Calculated network fee for the transaction.
* This is calculated based on the token outputs and their versions.
*/
networkFee?: 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;
}
}

Expand Down
Loading