diff --git a/__tests__/wallet/sendTransactionWalletService.test.ts b/__tests__/wallet/sendTransactionWalletService.test.ts index 5af8b9bd6..48608175b 100644 --- a/__tests__/wallet/sendTransactionWalletService.test.ts +++ b/__tests__/wallet/sendTransactionWalletService.test.ts @@ -1080,6 +1080,118 @@ describe('prepareTxData', () => { expect(changeOutput).toBeDefined(); expect(changeOutput.address).toBe(customChangeAddress); }); + + it('should reject an HTR input via the token-not-on-outputs guard when outputs have no HTR', async () => { + // When an HTR input is provided but outputs only use custom tokens, + // prepareTxData / validateUtxos reject it via the generic + // token-not-on-outputs guard: NATIVE_TOKEN_UID is not in tokenAmountMap, + // so the input fails the membership check. + wallet.getUtxoFromId.mockImplementation(async (txId, index) => { + if (txId === 'token-tx' && index === 0) { + return { + txId: 'token-tx', + index: 0, + value: 10n, + address: 'token-address', + tokenId: '01', + authorities: 0, + addressPath: "m/44'/280'/0'/0/1", + }; + } + if (txId === 'unnecessary-htr-tx' && index === 0) { + return { + txId: 'unnecessary-htr-tx', + index: 0, + value: 5n, + address: 'htr-address', + tokenId: NATIVE_TOKEN_UID, + authorities: 0, + addressPath: "m/44'/280'/0'/0/2", + }; + } + return null; + }); + + const inputs = [ + { txId: 'token-tx', index: 0 }, + { txId: 'unnecessary-htr-tx', index: 0 }, + ]; + const outputs = [ + { + type: OutputType.P2PKH, + address: 'WP1rVhxzT3YTWg8VbBKkacLqLU2LrouWDx', + value: 10n, + token: '01', + }, + ]; + + sendTransaction = new SendTransactionWalletService(wallet, { inputs, outputs }); + + // Reuse a single promise so prepareTxData only runs once — the two + // assertions below check the class and message of the same rejection. + const promise = sendTransaction.prepareTxData(); + await expect(promise).rejects.toBeInstanceOf(SendTxError); + await expect(promise).rejects.toThrow( + 'Invalid input selection. Input unnecessary-htr-tx at index 0 has token 00 that is not on the outputs.' + ); + }); + + it('should show a helpful error when HTR input is unneeded via prepareTx path', async () => { + // prepareTx has a separate code path where the two-pass validation skips + // HTR inputs entirely when htrAmount === 0. The rebuild guard should + // produce a user-friendly message explaining the root cause. + const mockIsValid = jest.spyOn(Address.prototype, 'isValid'); + mockIsValid.mockReturnValue(true); + const mockGetType = jest.spyOn(Address.prototype, 'getType'); + mockGetType.mockReturnValue('p2pkh'); + + wallet.getUtxoFromId.mockImplementation(async (txId, index) => { + if (txId === 'token-tx' && index === 0) { + return { + txId: 'token-tx', + index: 0, + value: 10n, + address: 'token-address', + tokenId: '01', + authorities: 0, + addressPath: "m/44'/280'/0'/0/1", + }; + } + if (txId === 'unnecessary-htr-tx' && index === 0) { + return { + txId: 'unnecessary-htr-tx', + index: 0, + value: 5n, + address: 'htr-address', + tokenId: NATIVE_TOKEN_UID, + authorities: 0, + addressPath: "m/44'/280'/0'/0/2", + }; + } + return null; + }); + + const inputs = [ + { txId: 'token-tx', index: 0 }, + { txId: 'unnecessary-htr-tx', index: 0 }, + ]; + const outputs = [ + { + type: OutputType.P2PKH, + address: 'WP1rVhxzT3YTWg8VbBKkacLqLU2LrouWDx', + value: 10n, + token: '01', + }, + ]; + + sendTransaction = new SendTransactionWalletService(wallet, { inputs, outputs }); + + await expect(sendTransaction.prepareTx()).rejects.toThrow(SendTxError); + sendTransaction = new SendTransactionWalletService(wallet, { inputs, outputs }); + await expect(sendTransaction.prepareTx()).rejects.toThrow( + 'an HTR input was provided but no HTR is required' + ); + }); }); describe('selectUtxosToUse', () => { @@ -2326,6 +2438,65 @@ describe('prepareTx - Fee Tokens', () => { `No UTXOs available for the token ${NATIVE_TOKEN_UID}.` ); }); + + it('should preserve address path order when HTR inputs come before token inputs', async () => { + const mockIsValid = jest.spyOn(Address.prototype, 'isValid'); + mockIsValid.mockReturnValue(true); + + const mockGetType = jest.spyOn(Address.prototype, 'getType'); + mockGetType.mockReturnValue('p2pkh'); + + // HTR input at index 0, fee token input at index 1 + wallet.getUtxoFromId.mockImplementation(async (txId, index) => { + if (txId === 'htr-tx' && index === 0) { + return { + txId: 'htr-tx', + index: 0, + value: 10n, + address: 'htr-address', + tokenId: NATIVE_TOKEN_UID, + authorities: 0, + addressPath: "m/44'/280'/0'/0/1", + }; + } + if (txId === 'fee-token-tx' && index === 0) { + return { + txId: 'fee-token-tx', + index: 0, + value: 100n, + address: 'fee-token-address', + tokenId: FEE_TOKEN_UID, + authorities: 0, + addressPath: "m/44'/280'/0'/0/2", + }; + } + return null; + }); + + // Inputs: HTR first, then fee token (triggers the ordering bug before fix) + const inputs = [ + { txId: 'htr-tx', index: 0 }, + { txId: 'fee-token-tx', index: 0 }, + ]; + const outputs = [ + { + type: OutputType.P2PKH, + address: 'WP1rVhxzT3YTWg8VbBKkacLqLU2LrouWDx', + value: 50n, + token: FEE_TOKEN_UID, + }, + ]; + + sendTransaction = new SendTransactionWalletService(wallet, { inputs, outputs }); + await sendTransaction.prepareTx(); + + // utxosAddressPath must follow this.inputs order: + // index 0 = HTR input, index 1 = fee token input. + // Length check first so dropped entries fail the test, not just reorders. + expect(sendTransaction.utxosAddressPath).toHaveLength(inputs.length); + expect(sendTransaction.utxosAddressPath[0]).toBe("m/44'/280'/0'/0/1"); + expect(sendTransaction.utxosAddressPath[1]).toBe("m/44'/280'/0'/0/2"); + }); }); describe('signTx preconditions', () => { @@ -2376,3 +2547,269 @@ describe('signTx preconditions', () => { ); }); }); + +describe('validateUtxos', () => { + let wallet; + let sendTransaction; + + const seed = + 'purse orchard camera cloud piece joke hospital mechanic timber horror shoulder rebuild you decrease garlic derive rebuild random naive elbow depart okay parrot cliff'; + + // AddressPathMap is file-local to the module under test, so tests pass a + // duck-typed stand-in with the same `set(input, path)` / `get(input)` + // surface. It type-checks because `sendTransaction` is declared above as + // `let sendTransaction` (implicit any), not via call-site casts. + const buildMockMap = () => { + const store = new Map(); + return { + set(input: { txId: string; index: number }, path: string) { + store.set(`${input.txId}:${input.index}`, path); + }, + get(input: { txId: string; index: number }) { + return store.get(`${input.txId}:${input.index}`); + }, + }; + }; + + beforeEach(() => { + wallet = new HathorWalletServiceWallet({ + requestPassword: async () => '123', + seed, + network: new Network('testnet'), + }); + wallet.getUtxoFromId = jest.fn(); + wallet.getCurrentAddress = jest + .fn() + .mockReturnValue({ address: 'WPynsVhyU6nP7RSZAkqfijEutC88KgAyFc' }); + + const mockIsValid = jest.spyOn(Address.prototype, 'isValid'); + mockIsValid.mockReturnValue(true); + const mockGetType = jest.spyOn(Address.prototype, 'getType'); + mockGetType.mockReturnValue('p2pkh'); + }); + + it('populates addressPathMap for custom tokens and skips HTR when ignoreNative=true', async () => { + const tokenInput = { txId: 'token-tx', index: 0 }; + const htrInput = { txId: 'htr-tx', index: 0 }; + + wallet.getUtxoFromId.mockImplementation(async (txId, index) => { + if (txId === 'token-tx' && index === 0) { + return { + txId, + index, + value: 10n, + address: 'addr', + tokenId: '01', + authorities: 0, + addressPath: 'm/token', + }; + } + if (txId === 'htr-tx' && index === 0) { + return { + txId, + index, + value: 5n, + address: 'addr', + tokenId: NATIVE_TOKEN_UID, + authorities: 0, + addressPath: 'm/htr', + }; + } + return null; + }); + + sendTransaction = new SendTransactionWalletService(wallet, { + inputs: [tokenInput, htrInput], + outputs: [], + }); + + const map = buildMockMap(); + const result = await sendTransaction.validateUtxos( + { '01': { version: TokenVersion.DEPOSIT, amount: 10n } }, + map, + { ignoreNative: true } + ); + + // Post-refactor contract: returns void + expect(result).toBeUndefined(); + expect(map.get(tokenInput)).toBe('m/token'); + expect(map.get(htrInput)).toBeUndefined(); + }); + + it('populates addressPathMap for HTR and skips custom tokens when onlyNative=true', async () => { + const tokenInput = { txId: 'token-tx', index: 0 }; + const htrInput = { txId: 'htr-tx', index: 0 }; + + wallet.getUtxoFromId.mockImplementation(async (txId, index) => { + if (txId === 'token-tx' && index === 0) { + return { + txId, + index, + value: 10n, + address: 'addr', + tokenId: '01', + authorities: 0, + addressPath: 'm/token', + }; + } + if (txId === 'htr-tx' && index === 0) { + return { + txId, + index, + value: 5n, + address: 'addr', + tokenId: NATIVE_TOKEN_UID, + authorities: 0, + addressPath: 'm/htr', + }; + } + return null; + }); + + sendTransaction = new SendTransactionWalletService(wallet, { + inputs: [tokenInput, htrInput], + outputs: [], + }); + + const map = buildMockMap(); + await sendTransaction.validateUtxos( + { [NATIVE_TOKEN_UID]: { version: TokenVersion.NATIVE, amount: 5n } }, + map, + { onlyNative: true } + ); + + expect(map.get(htrInput)).toBe('m/htr'); + expect(map.get(tokenInput)).toBeUndefined(); + }); + + it('creates a change output and still populates the map when inputs exceed the required amount', async () => { + const input = { txId: 'tx', index: 0 }; + wallet.getUtxoFromId.mockResolvedValue({ + txId: 'tx', + index: 0, + value: 15n, + address: 'addr', + tokenId: '01', + authorities: 0, + addressPath: 'm/change', + }); + + sendTransaction = new SendTransactionWalletService(wallet, { + inputs: [input], + outputs: [], + }); + + const map = buildMockMap(); + await sendTransaction.validateUtxos( + { '01': { version: TokenVersion.DEPOSIT, amount: 10n } }, + map + ); + + expect(map.get(input)).toBe('m/change'); + const changeOutputs = sendTransaction.outputs.filter(o => o.token === '01' && o.value === 5n); + expect(changeOutputs).toHaveLength(1); + expect(changeOutputs[0].address).toBe('WPynsVhyU6nP7RSZAkqfijEutC88KgAyFc'); + }); + + it('increments _feeAmount by FEE_PER_OUTPUT when a fee-tagged token produces change', async () => { + wallet.getUtxoFromId.mockResolvedValue({ + txId: 'tx', + index: 0, + value: 15n, + address: 'addr', + tokenId: '02', + authorities: 0, + addressPath: 'm/fee', + }); + + sendTransaction = new SendTransactionWalletService(wallet, { + inputs: [{ txId: 'tx', index: 0 }], + outputs: [], + }); + // Constructor initializes _feeAmount to 0n; asserting the absolute value + // directly verifies validateUtxos added exactly FEE_PER_OUTPUT. + expect(sendTransaction._feeAmount).toBe(0n); + + await sendTransaction.validateUtxos( + { '02': { version: TokenVersion.FEE, amount: 10n } }, + buildMockMap() + ); + + expect(sendTransaction._feeAmount).toBe(FEE_PER_OUTPUT); + }); + + it('throws UtxoError when wallet.getUtxoFromId returns null for an input', async () => { + wallet.getUtxoFromId.mockResolvedValue(null); + sendTransaction = new SendTransactionWalletService(wallet, { + inputs: [{ txId: 'missing', index: 0 }], + outputs: [], + }); + + await expect( + sendTransaction.validateUtxos( + { '01': { version: TokenVersion.DEPOSIT, amount: 10n } }, + buildMockMap() + ) + ).rejects.toThrow('Invalid input selection. Input missing at index 0.'); + }); + + it("throws SendTxError when an input's tokenId is not in the tokenAmountMap", async () => { + wallet.getUtxoFromId.mockResolvedValue({ + txId: 'tx', + index: 0, + value: 10n, + address: 'addr', + tokenId: 'wrong', + authorities: 0, + addressPath: 'm/wrong', + }); + sendTransaction = new SendTransactionWalletService(wallet, { + inputs: [{ txId: 'tx', index: 0 }], + outputs: [], + }); + + await expect( + sendTransaction.validateUtxos( + { '01': { version: TokenVersion.DEPOSIT, amount: 10n } }, + buildMockMap() + ) + ).rejects.toThrow('has token wrong that is not on the outputs'); + }); + + it('throws SendTxError when a token in the amount map has no matching inputs', async () => { + sendTransaction = new SendTransactionWalletService(wallet, { + inputs: [], + outputs: [], + }); + + await expect( + sendTransaction.validateUtxos( + { '01': { version: TokenVersion.DEPOSIT, amount: 10n } }, + buildMockMap() + ) + ).rejects.toThrow('Token 01 is in the outputs but there are no inputs for it.'); + }); + + it('throws SendTxError when summed input value is below the required amount', async () => { + wallet.getUtxoFromId.mockResolvedValue({ + txId: 'tx', + index: 0, + value: 5n, + address: 'addr', + tokenId: '01', + authorities: 0, + addressPath: 'm/short', + }); + sendTransaction = new SendTransactionWalletService(wallet, { + inputs: [{ txId: 'tx', index: 0 }], + outputs: [], + }); + + await expect( + sendTransaction.validateUtxos( + { '01': { version: TokenVersion.DEPOSIT, amount: 10n } }, + buildMockMap() + ) + ).rejects.toThrow('Sum of inputs for token 01 is smaller than the sum of outputs'); + }); +}); diff --git a/src/wallet/sendTransactionWalletService.ts b/src/wallet/sendTransactionWalletService.ts index 3bf5f8571..09ca58d63 100644 --- a/src/wallet/sendTransactionWalletService.ts +++ b/src/wallet/sendTransactionWalletService.ts @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +/* eslint-disable max-classes-per-file -- AddressPathMap is a private helper tightly coupled to SendTransactionWalletService */ import { EventEmitter } from 'events'; import { shuffle } from 'lodash'; import tokensUtils from '../utils/tokens'; @@ -40,6 +41,27 @@ type optionsType = { pin?: string | null; }; +/** + * Maps a transaction input (identified by its txId + index) to its + * BIP-44 address path. Thin wrapper around {@link Map} that centralizes + * the key format so the read and write sides cannot drift out of sync. + */ +class AddressPathMap { + private readonly map = new Map(); + + private static key(input: { txId: string; index: number }): string { + return `${input.txId}:${input.index}`; + } + + set(input: { txId: string; index: number }, path: string): void { + this.map.set(AddressPathMap.key(input), path); + } + + get(input: { txId: string; index: number }): string | undefined { + return this.map.get(AddressPathMap.key(input)); + } +} + class SendTransactionWalletService extends EventEmitter implements ISendTransaction { // Wallet that is sending the transaction private wallet: HathorWalletServiceWallet; @@ -433,8 +455,14 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact } else { // If the user selected the inputs, we must validate that // all utxos are valid and the sum is enought to fill the outputs + // We use an addressPathMap to collect paths keyed by input identity, + // then rebuild in this.inputs order. This is necessary because the + // two-pass validation (tokens first, HTR second) would otherwise + // produce paths in token-processing order rather than input order. + const addressPathMap = new AddressPathMap(); + // ignoreNative=true: HTR inputs will be validated separately below - utxosAddressPath = await this.validateUtxos(tokensWithoutHtr, { ignoreNative: true }); + await this.validateUtxos(tokensWithoutHtr, addressPathMap, { ignoreNative: true }); // here we should know the fee amount (in case of any fee-based token change output was added) const htrAmount = (tokenAmountMap[NATIVE_TOKEN_UID]?.amount ?? 0n) + this._feeAmount; @@ -445,9 +473,21 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact amount: htrAmount, }, }; - const htrAddressPath = await this.validateUtxos(htrTokenAmount, { onlyNative: true }); - utxosAddressPath.push(...htrAddressPath); + await this.validateUtxos(htrTokenAmount, addressPathMap, { onlyNative: true }); } + + // Rebuild utxosAddressPath in this.inputs order + utxosAddressPath = this.inputs.map(input => { + const path = addressPathMap.get(input); + if (!path) { + throw new SendTxError( + `Input ${input.txId}:${input.index} was not processed by any validation pass. ` + + 'This usually means an HTR input was provided but no HTR is required ' + + '(no HTR outputs and no fee-token fees).' + ); + } + return path; + }); } const tokens = Object.keys(tokenAmountMap); const htrIndex = tokens.indexOf(NATIVE_TOKEN_UID); @@ -534,11 +574,14 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact */ async validateUtxos( tokenAmountMap: TokenMap, - options: { ignoreNative?: boolean; onlyNative?: boolean } = {} - ): Promise { + addressPathMap: AddressPathMap, + options: { + ignoreNative?: boolean; + onlyNative?: boolean; + } = {} + ): Promise { const { ignoreNative = false, onlyNative = false } = options; const amountInputMap = {}; - const utxosAddressPath: string[] = []; for (const input of this.inputs) { const utxo = await this.wallet.getUtxoFromId(input.txId, input.index); if (utxo === null) { @@ -565,7 +608,7 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact ); } - utxosAddressPath.push(utxo.addressPath); + addressPathMap.set(input, utxo.addressPath); if (utxo.tokenId in amountInputMap) { amountInputMap[utxo.tokenId] += utxo.value; @@ -606,8 +649,6 @@ class SendTransactionWalletService extends EventEmitter implements ISendTransact this.outputs = shuffle(this.outputs); } } - - return utxosAddressPath; } /**