diff --git a/__tests__/integration/adapters/fullnode.adapter.ts b/__tests__/integration/adapters/fullnode.adapter.ts index d5ec424cc..5d6b61778 100644 --- a/__tests__/integration/adapters/fullnode.adapter.ts +++ b/__tests__/integration/adapters/fullnode.adapter.ts @@ -27,14 +27,21 @@ import { GenesisWalletHelper } from '../helpers/genesis-wallet.helper'; import { precalculationHelpers } from '../helpers/wallet-precalculation.helper'; import type { WalletStopOptions } from '../../../src/new/types'; import { FULLNODE_URL, NETWORK_NAME } from '../configuration/test-constants'; +import type { FullNodeTxResponse } from '../../../src/wallet/types'; import type { FuzzyWalletType, IWalletTestAdapter, WalletCapabilities, CreateWalletOptions, CreateWalletResult, + SendTransactionOptions, + SendTransactionResult, CreateTokenOptions, CreateTokenResult, + GetUtxosAdapterOptions, + GetUtxosResult, + AdapterOutput, + SendManyOutputsAdapterOptions, AuthorityUtxoResult, GetAuthorityUtxosOptions, DelegateAuthorityAdapterOptions, @@ -186,9 +193,17 @@ export class FullnodeWalletTestAdapter implements IWalletTestAdapter { return result.hash; } - async waitForTx(wallet: FuzzyWalletType, txId: string): Promise { + async waitForTx( + wallet: FuzzyWalletType, + txId: string, + recvWallet?: FuzzyWalletType + ): Promise { const hWallet = this.concrete(wallet); await waitForTxReceived(hWallet, txId); + if (recvWallet) { + const hRecv = this.concrete(recvWallet); + await waitForTxReceived(hRecv, txId); + } await waitUntilNextTimestamp(hWallet, txId); } @@ -196,6 +211,39 @@ export class FullnodeWalletTestAdapter implements IWalletTestAdapter { return precalculationHelpers.test!.getPrecalculatedWallet(); } + async sendTransaction( + wallet: FuzzyWalletType, + address: string, + amount: bigint, + options?: SendTransactionOptions + ): Promise { + const hWallet = this.concrete(wallet); + const { recvWallet, ...txOptions } = options ?? {}; + const result = await hWallet.sendTransaction(address, amount, { + pinCode: DEFAULT_PIN_CODE, + ...txOptions, + }); + if (!result || !result.hash) { + throw new Error('sendTransaction: transaction had no hash'); + } + await this.waitForTx(wallet, result.hash, recvWallet); + return { hash: result.hash, transaction: result }; + } + + async getTx(wallet: FuzzyWalletType, txId: string) { + const result = await this.concrete(wallet).getTx(txId); + if (!result) { + throw new Error(`getTx: transaction ${txId} not found`); + } + return result; + } + + async getFullTxById(wallet: FuzzyWalletType, txId: string): Promise { + // The fullnode facade returns FullNodeTxApiResponse (zod-inferred), which is structurally + // compatible with FullNodeTxResponse but has minor nullability differences. + return this.concrete(wallet).getFullTxById(txId) as Promise; + } + async createToken( wallet: FuzzyWalletType, name: string, @@ -204,13 +252,46 @@ export class FullnodeWalletTestAdapter implements IWalletTestAdapter { options?: CreateTokenOptions ): Promise { const hWallet = this.concrete(wallet); - const response = await hWallet.createNewToken(name, symbol, amount, { + const result = await hWallet.createNewToken(name, symbol, amount, { pinCode: DEFAULT_PIN_CODE, ...options, }); - await waitForTxReceived(hWallet, response.hash); - await waitUntilNextTimestamp(hWallet, response.hash); - return { hash: response.hash }; + if (!result?.hash) { + throw new Error('createToken: transaction had no hash'); + } + await waitForTxReceived(hWallet, result.hash); + await waitUntilNextTimestamp(hWallet, result.hash); + return { hash: result.hash, transaction: result }; + } + + async getUtxos( + wallet: FuzzyWalletType, + options?: GetUtxosAdapterOptions + ): Promise { + const result = await this.concrete(wallet).getUtxos(options); + return { + total_amount_available: result.total_amount_available, + total_utxos_available: result.total_utxos_available, + utxos: result.utxos, + }; + } + + async sendManyOutputsTransaction( + wallet: FuzzyWalletType, + outputs: AdapterOutput[], + options?: SendManyOutputsAdapterOptions + ): Promise { + const hWallet = this.concrete(wallet); + const { recvWallet, ...txOptions } = options ?? {}; + const result = await hWallet.sendManyOutputsTransaction(outputs, { + pinCode: DEFAULT_PIN_CODE, + ...txOptions, + }); + if (!result?.hash) { + throw new Error('sendManyOutputsTransaction: transaction had no hash'); + } + await this.waitForTx(wallet, result.hash, recvWallet); + return { hash: result.hash, transaction: result }; } async getAuthorityUtxos( diff --git a/__tests__/integration/adapters/service.adapter.ts b/__tests__/integration/adapters/service.adapter.ts index 2c3540f0b..6f2a56b17 100644 --- a/__tests__/integration/adapters/service.adapter.ts +++ b/__tests__/integration/adapters/service.adapter.ts @@ -7,6 +7,7 @@ */ import { HathorWalletServiceWallet } from '../../../src'; +import { NATIVE_TOKEN_UID } from '../../../src/constants'; import config from '../../../src/config'; import { WalletTracker } from '../utils/wallet-tracker.util'; import type Transaction from '../../../src/models/transaction'; @@ -20,16 +21,24 @@ import { import { GenesisWalletServiceHelper } from '../helpers/genesis-wallet.helper'; import { precalculationHelpers } from '../helpers/wallet-precalculation.helper'; import type { WalletStopOptions } from '../../../src/new/types'; +import type { IHistoryTx } from '../../../src/types'; import { AuthorityType } from '../../../src/types'; import { NETWORK_NAME } from '../configuration/test-constants'; +import type { FullNodeTxResponse } from '../../../src/wallet/types'; import type { FuzzyWalletType, IWalletTestAdapter, WalletCapabilities, CreateWalletOptions, CreateWalletResult, + SendTransactionOptions, + SendTransactionResult, CreateTokenOptions, CreateTokenResult, + GetUtxosAdapterOptions, + GetUtxosResult, + AdapterOutput, + SendManyOutputsAdapterOptions, AuthorityUtxoResult, GetAuthorityUtxosOptions, DelegateAuthorityAdapterOptions, @@ -216,14 +225,65 @@ export class ServiceWalletTestAdapter implements IWalletTestAdapter { return fundTx.hash; } - async waitForTx(wallet: FuzzyWalletType, txId: string): Promise { + async waitForTx( + wallet: FuzzyWalletType, + txId: string, + recvWallet?: FuzzyWalletType + ): Promise { await pollForTx(this.concrete(wallet), txId); + if (recvWallet) { + await pollForTx(this.concrete(recvWallet), txId); + } } getPrecalculatedWallet(): PrecalculatedWalletData { return precalculationHelpers.test!.getPrecalculatedWallet(); } + async sendTransaction( + wallet: FuzzyWalletType, + address: string, + amount: bigint, + options?: SendTransactionOptions + ): Promise { + const sw = this.concrete(wallet); + const { recvWallet, ...txOptions } = options ?? {}; + const result = await sw.sendTransaction(address, amount, { + pinCode: SERVICE_PIN, + ...txOptions, + }); + if (!result.hash) { + throw new Error('sendTransaction: transaction had no hash'); + } + await this.waitForTx(wallet, result.hash, recvWallet); + return { hash: result.hash, transaction: result }; + } + + async getTx(wallet: FuzzyWalletType, txId: string) { + // HathorWalletServiceWallet.getTx() is not implemented — use fullnode API + // and map the response to IHistoryTx format (adding token UID to each output/input) + const fullNodeResponse = await this.concrete(wallet).getFullTxById(txId); + const { tx } = fullNodeResponse; + const tokenUids = tx.tokens.map(t => t.uid); + const resolveToken = (tokenData: number) => + tokenData === 0 ? NATIVE_TOKEN_UID : tokenUids[tokenData - 1]; + return { + tx_id: tx.hash, + version: tx.version, + timestamp: tx.timestamp, + inputs: tx.inputs.map(i => ({ ...i, token: resolveToken(i.token_data) })), + outputs: tx.outputs.map(o => ({ ...o, token: resolveToken(o.token_data) })), + parents: tx.parents, + tokens: tx.tokens, + weight: tx.weight, + nonce: Number(tx.nonce), + } as unknown as IHistoryTx; + } + + async getFullTxById(wallet: FuzzyWalletType, txId: string): Promise { + return this.concrete(wallet).getFullTxById(txId); + } + async createToken( wallet: FuzzyWalletType, name: string, @@ -232,16 +292,46 @@ export class ServiceWalletTestAdapter implements IWalletTestAdapter { options?: CreateTokenOptions ): Promise { const sw = this.concrete(wallet); - const response = await sw.createNewToken(name, symbol, amount, { + const result = await sw.createNewToken(name, symbol, amount, { ...options, pinCode: SERVICE_PIN, }); - if (!response.hash) { + if (!result?.hash) { throw new Error('createToken: transaction had no hash'); } - await pollForTx(sw, response.hash); - await pollForTokenDetails(sw, response.hash); - return { hash: response.hash }; + await pollForTx(sw, result.hash); + await pollForTokenDetails(sw, result.hash); + return { hash: result.hash, transaction: result }; + } + + async getUtxos( + wallet: FuzzyWalletType, + options?: GetUtxosAdapterOptions + ): Promise { + const result = await this.concrete(wallet).getUtxos(options); + return { + total_amount_available: result.total_amount_available, + total_utxos_available: result.total_utxos_available, + utxos: result.utxos, + }; + } + + async sendManyOutputsTransaction( + wallet: FuzzyWalletType, + outputs: AdapterOutput[], + options?: SendManyOutputsAdapterOptions + ): Promise { + const sw = this.concrete(wallet); + const { recvWallet, ...txOptions } = options ?? {}; + const result = await sw.sendManyOutputsTransaction(outputs, { + pinCode: SERVICE_PIN, + ...txOptions, + }); + if (!result?.hash) { + throw new Error('sendManyOutputsTransaction: transaction had no hash'); + } + await this.waitForTx(wallet, result.hash, recvWallet); + return { hash: result.hash, transaction: result }; } async getAuthorityUtxos( diff --git a/__tests__/integration/adapters/types.ts b/__tests__/integration/adapters/types.ts index 2295b5d6a..a725ebb56 100644 --- a/__tests__/integration/adapters/types.ts +++ b/__tests__/integration/adapters/types.ts @@ -6,10 +6,10 @@ * LICENSE file in the root directory of this source tree. */ -import type { IHathorWallet } from '../../../src/wallet/types'; +import type { IHathorWallet, FullNodeTxResponse } from '../../../src/wallet/types'; import type { PrecalculatedWalletData } from '../helpers/wallet-precalculation.helper'; import type Transaction from '../../../src/models/transaction'; -import type { IStorage, TokenVersion, AuthorityType } from '../../../src/types'; +import type { IHistoryTx, IStorage, TokenVersion, AuthorityType } from '../../../src/types'; import { HathorWallet, HathorWalletServiceWallet } from '../../../src'; /** @@ -169,14 +169,40 @@ export interface IWalletTestAdapter { // --- Tx waiting --- - /** Waits until a specific tx is visible in the wallet */ - waitForTx(wallet: FuzzyWalletType, txId: string): Promise; + /** Waits until a specific tx is visible in the wallet, and optionally on a receiving wallet. */ + waitForTx(wallet: FuzzyWalletType, txId: string, recvWallet?: FuzzyWalletType): Promise; // --- Precalculated data --- /** Returns a fresh precalculated wallet for tests that need one */ getPrecalculatedWallet(): PrecalculatedWalletData; + // --- Transaction operations --- + + /** + * Sends a transaction from the wallet to the given address. + * Handles pinCode injection for facades that require per-call credentials. + * Returns the hash and the full Transaction model. + */ + sendTransaction( + wallet: FuzzyWalletType, + address: string, + amount: bigint, + options?: SendTransactionOptions + ): Promise; + + /** + * Retrieves a transaction from the wallet's local history. + * Both facades support `getTx()`. + */ + getTx(wallet: FuzzyWalletType, txId: string): Promise; + + /** + * Retrieves the full transaction data from the network node. + * Both facades support this via the fullnode API. + */ + getFullTxById(wallet: FuzzyWalletType, txId: string): Promise; + // --- Token operations --- /** @@ -191,6 +217,26 @@ export interface IWalletTestAdapter { options?: CreateTokenOptions ): Promise; + // --- UTXO queries --- + + /** + * Retrieves unspent transaction outputs for a wallet. + * Both facades support `getUtxos()`. + */ + getUtxos(wallet: FuzzyWalletType, options?: GetUtxosAdapterOptions): Promise; + + // --- Multi-output transactions --- + + /** + * Sends a transaction with multiple outputs and optional explicit inputs. + * Both facades support `sendManyOutputsTransaction()`. + */ + sendManyOutputsTransaction( + wallet: FuzzyWalletType, + outputs: AdapterOutput[], + options?: SendManyOutputsAdapterOptions + ): Promise; + // --- Authority UTXOs --- /** @@ -219,6 +265,24 @@ export interface IWalletTestAdapter { ): Promise; } +/** + * Options for sending a transaction via the adapter. + */ +export interface SendTransactionOptions { + token?: string; + changeAddress?: string; + /** If provided, the adapter also waits for the tx on the receiving wallet. */ + recvWallet?: FuzzyWalletType; +} + +/** + * Result of sending a transaction. + */ +export interface SendTransactionResult { + hash: string; + transaction: Transaction; +} + /** * Options for creating a new token via the adapter. */ @@ -238,6 +302,67 @@ export interface CreateTokenOptions { */ export interface CreateTokenResult { hash: string; + transaction: Transaction; +} + +/** + * Options for querying UTXOs via the adapter. + */ +export interface GetUtxosAdapterOptions { + token?: string; + max_utxos?: number; + filter_address?: string; + amount_smaller_than?: bigint; + amount_bigger_than?: bigint; +} + +/** + * A single UTXO entry returned by the adapter. + */ +export interface AdapterUtxo { + address: string; + amount: bigint; + tx_id: string; + locked: boolean; + index: number; +} + +/** + * Result of a getUtxos query. + */ +export interface GetUtxosResult { + total_amount_available: bigint; + total_utxos_available: bigint; + utxos: AdapterUtxo[]; +} + +/** + * An output for sendManyOutputsTransaction. + */ +export interface AdapterOutput { + address: string; + value: bigint; + token: string; + timelock?: number; +} + +/** + * An explicit input for sendManyOutputsTransaction. + */ +export interface AdapterInput { + txId: string; + index: number; + token?: string; +} + +/** + * Options for sendManyOutputsTransaction via the adapter. + */ +export interface SendManyOutputsAdapterOptions { + inputs?: AdapterInput[]; + changeAddress?: string; + /** If provided, the adapter also waits for the tx on the receiving wallet. */ + recvWallet?: FuzzyWalletType; } /** diff --git a/__tests__/integration/fullnode-specific/send-transaction.test.ts b/__tests__/integration/fullnode-specific/send-transaction.test.ts new file mode 100644 index 000000000..e8759731c --- /dev/null +++ b/__tests__/integration/fullnode-specific/send-transaction.test.ts @@ -0,0 +1,254 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Fullnode-facade sendTransaction tests. + * + * Tests that rely on fullnode-only APIs: storage.getAddressInfo, multisig. + * + * Shared sendTransaction tests live in `shared/send-transaction.test.ts`. + * Shared token tests live in `shared/send-transaction-tokens.test.ts`. + * + * Why address tracking is NOT shared: + * The wallet-service facade's `wallet.storage` is a plain Storage instance. + * The `WalletServiceStorageProxy` (which maps `getAddressInfo` to the REST + * API's `wallet/address/info` endpoint) is only created transiently inside + * `handleSendPreparedTransaction` for nano contract signing — it does NOT + * wrap `wallet.storage` permanently. Calling `wallet.storage.getAddressInfo()` + * on the service facade returns null for addresses that haven't been locally + * indexed, making `numTransactions` assertions impossible. + */ + +import { GenesisWalletHelper } from '../helpers/genesis-wallet.helper'; +import { + DEFAULT_PIN_CODE, + generateMultisigWalletHelper, + generateWalletHelper, + stopAllWallets, + waitForTxReceived, + waitUntilNextTimestamp, + createTokenHelper, +} from '../helpers/wallet.helper'; +import { NATIVE_TOKEN_UID } from '../../../src/constants'; +import { TokenVersion } from '../../../src/types'; +import SendTransaction from '../../../src/new/sendTransaction'; +import transaction from '../../../src/utils/transaction'; + +describe('[Fullnode] sendTransaction — address tracking', () => { + afterEach(async () => { + await stopAllWallets(); + }); + + it('should track address usage for HTR transactions', async () => { + const hWallet = await generateWalletHelper(); + await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n); + + const tx1 = await hWallet.sendTransaction(await hWallet.getAddressAtIndex(2), 6n); + await waitForTxReceived(hWallet, tx1.hash); + + expect(tx1).toMatchObject({ + hash: expect.any(String), + inputs: expect.any(Array), + outputs: expect.any(Array), + version: expect.any(Number), + weight: expect.any(Number), + nonce: expect.any(Number), + timestamp: expect.any(Number), + parents: expect.any(Array), + tokens: expect.any(Array), + }); + + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(0))).toHaveProperty( + 'numTransactions', + 2 + ); + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(1))).toHaveProperty( + 'numTransactions', + 1 + ); + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(2))).toHaveProperty( + 'numTransactions', + 1 + ); + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(3))).toHaveProperty( + 'numTransactions', + 0 + ); + + const { hWallet: gWallet } = await GenesisWalletHelper.getSingleton(); + await waitUntilNextTimestamp(hWallet, tx1.hash); + const { hash: tx2Hash } = await hWallet.sendTransaction( + await gWallet.getAddressAtIndex(0), + 8n, + { changeAddress: await hWallet.getAddressAtIndex(5) } + ); + await waitForTxReceived(hWallet, tx2Hash); + + const htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); + expect(htrBalance[0].balance.unlocked).toEqual(2n); + + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(5))).toHaveProperty( + 'numTransactions', + 1 + ); + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(6))).toHaveProperty( + 'numTransactions', + 0 + ); + }); + + it('should track address usage for custom token transactions', async () => { + const hWallet = await generateWalletHelper(); + await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n); + const { hash: tokenUid } = await createTokenHelper(hWallet, 'DepositBasedToken', 'DBT', 100n); + + const tx1 = await hWallet.sendTransaction(await hWallet.getAddressAtIndex(5), 30n, { + token: tokenUid, + changeAddress: await hWallet.getAddressAtIndex(6), + }); + await waitForTxReceived(hWallet, tx1.hash); + + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(5))).toHaveProperty( + 'numTransactions', + 1 + ); + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(6))).toHaveProperty( + 'numTransactions', + 1 + ); + + const { hWallet: gWallet } = await GenesisWalletHelper.getSingleton(); + await waitUntilNextTimestamp(hWallet, tx1.hash); + const { hash: tx2Hash } = await hWallet.sendTransaction( + await gWallet.getAddressAtIndex(0), + 80n, + { token: tokenUid, changeAddress: await hWallet.getAddressAtIndex(12) } + ); + await waitForTxReceived(hWallet, tx2Hash); + + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(5))).toHaveProperty( + 'numTransactions', + 2 + ); + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(6))).toHaveProperty( + 'numTransactions', + 2 + ); + expect( + await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(12)) + ).toHaveProperty('numTransactions', 1); + }); + + it('should track address usage for fee token transactions', async () => { + const hWallet = await generateWalletHelper(); + await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n); + const { hash: tokenUid } = await createTokenHelper(hWallet, 'FeeBasedToken', 'FBT', 8582n, { + tokenVersion: TokenVersion.FEE, + }); + + const tx1 = await hWallet.sendTransaction(await hWallet.getAddressAtIndex(5), 8000n, { + token: tokenUid, + changeAddress: await hWallet.getAddressAtIndex(6), + }); + await waitForTxReceived(hWallet, tx1.hash); + + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(5))).toHaveProperty( + 'numTransactions', + 1 + ); + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(6))).toHaveProperty( + 'numTransactions', + 1 + ); + + const { hWallet: gWallet } = await GenesisWalletHelper.getSingleton(); + await waitUntilNextTimestamp(hWallet, tx1.hash); + const { hash: tx2Hash } = await hWallet.sendTransaction( + await gWallet.getAddressAtIndex(0), + 82n, + { token: tokenUid, changeAddress: await hWallet.getAddressAtIndex(12) } + ); + await waitForTxReceived(hWallet, tx2Hash); + + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(5))).toHaveProperty( + 'numTransactions', + 1 + ); + expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(6))).toHaveProperty( + 'numTransactions', + 2 + ); + expect( + await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(12)) + ).toHaveProperty('numTransactions', 1); + }); +}); + +describe('[Fullnode] sendTransaction — multisig', () => { + afterEach(async () => { + await stopAllWallets(); + }); + + it('should send a multisig transaction', async () => { + const mhWallet1 = await generateMultisigWalletHelper({ walletIndex: 0 }); + const mhWallet2 = await generateMultisigWalletHelper({ walletIndex: 1 }); + const mhWallet3 = await generateMultisigWalletHelper({ walletIndex: 2 }); + await GenesisWalletHelper.injectFunds(mhWallet1, await mhWallet1.getAddressAtIndex(0), 10n); + + const { tx_id: inputTxId, index: inputIndex } = (await mhWallet1.getUtxos()).utxos[0]; + const network = mhWallet1.getNetworkObject(); + const sendTransaction = new SendTransaction({ + storage: mhWallet1.storage, + inputs: [{ txId: inputTxId, index: inputIndex }], + outputs: [ + { + address: await mhWallet1.getAddressAtIndex(1), + value: 10n, + token: NATIVE_TOKEN_UID, + }, + ], + }); + const tx = transaction.createTransactionFromData( + { version: 1, ...(await sendTransaction.prepareTxData()) }, + network + ); + const txHex = tx.toHex(); + + const sig1 = await mhWallet1.getAllSignatures(txHex, DEFAULT_PIN_CODE); + const sig2 = await mhWallet2.getAllSignatures(txHex, DEFAULT_PIN_CODE); + const sig3 = await mhWallet3.getAllSignatures(txHex, DEFAULT_PIN_CODE); + + await waitUntilNextTimestamp(mhWallet1, inputTxId); + + const partiallyAssembledTx = await mhWallet1.assemblePartialTransaction(txHex, [ + sig1, + sig2, + sig3, + ]); + partiallyAssembledTx.prepareToSend(); + const finalTx = new SendTransaction({ + storage: mhWallet1.storage, + transaction: partiallyAssembledTx, + }); + + const sentTx = await finalTx.runFromMining(); + expect(sentTx).toHaveProperty('hash'); + await waitForTxReceived(mhWallet1, sentTx.hash, 10000); + + const historyTx = await mhWallet1.getTx(sentTx.hash); + expect(historyTx).toMatchObject({ + tx_id: partiallyAssembledTx.hash, + inputs: [expect.objectContaining({ tx_id: inputTxId, value: 10n })], + }); + + const fullNodeTx = await mhWallet1.getFullTxById(sentTx.hash); + expect(fullNodeTx.tx).toMatchObject({ + hash: partiallyAssembledTx.hash, + inputs: [expect.objectContaining({ tx_id: inputTxId, value: 10n })], + }); + }); +}); diff --git a/__tests__/integration/hathorwallet_facade.test.ts b/__tests__/integration/hathorwallet_facade.test.ts index 2e18c510d..3918e98c0 100644 --- a/__tests__/integration/hathorwallet_facade.test.ts +++ b/__tests__/integration/hathorwallet_facade.test.ts @@ -17,9 +17,7 @@ import { TOKEN_AUTHORITY_MASK, } from '../../src/constants'; import { TOKEN_DATA, WALLET_CONSTANTS } from './configuration/test-constants'; -import dateFormatter from '../../src/utils/date'; import { verifyMessage } from '../../src/utils/crypto'; -import { loggers } from './utils/logger.util'; import { NftValidationError, TxNotFoundError } from '../../src/errors'; import SendTransaction from '../../src/new/sendTransaction'; import transaction from '../../src/utils/transaction'; @@ -830,8 +828,12 @@ describe('graphvizNeighborsQuery', () => { }); }); +// sendTransaction tests moved to: +// shared/send-transaction.test.ts, shared/send-transaction-tokens.test.ts, +// fullnode-specific/send-transaction.test.ts +// sendManyOutputsTransaction tests moved to shared/send-many-outputs.test.ts + const validateFeeAmount = (headers: Header[], amount: bigint) => { - // validate fee amount expect(headers).toHaveLength(1); expect(headers[0]).toEqual( expect.objectContaining({ @@ -845,581 +847,6 @@ const validateFeeAmount = (headers: Header[], amount: bigint) => { ); }; -describe('sendTransaction', () => { - afterEach(async () => { - await stopAllWallets(); - await GenesisWalletHelper.clearListeners(); - }); - - it('should send HTR transactions', async () => { - const hWallet = await generateWalletHelper(); - await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n); - - // Sending a transaction inside the same wallet - const tx1 = await hWallet.sendTransaction(await hWallet.getAddressAtIndex(2), 6n); - - // Validating all fields - await waitForTxReceived(hWallet, tx1.hash); - expect(tx1).toMatchObject({ - hash: expect.any(String), - inputs: expect.any(Array), - outputs: expect.any(Array), - version: expect.any(Number), - weight: expect.any(Number), - nonce: expect.any(Number), - timestamp: expect.any(Number), - parents: expect.any(Array), - tokens: expect.any(Array), - }); - - // Validating balance stays the same for internal transactions - let htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); - expect(htrBalance[0].balance.unlocked).toEqual(10n); - - // Validating the correct addresses received the tokens - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(0))).toHaveProperty( - 'numTransactions', - 2 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(1))).toHaveProperty( - 'numTransactions', - 1 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(2))).toHaveProperty( - 'numTransactions', - 1 - ); - - // Sending a transaction to outside the wallet ( returning funds to genesis ) - const { hWallet: gWallet } = await GenesisWalletHelper.getSingleton(); - await waitUntilNextTimestamp(hWallet, tx1.hash); - const { hash: tx2Hash } = await hWallet.sendTransaction( - await gWallet.getAddressAtIndex(0), - 8n, - { - changeAddress: await hWallet.getAddressAtIndex(5), - } - ); - await waitForTxReceived(hWallet, tx2Hash); - - // Balance was reduced - htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); - expect(htrBalance[0].balance.unlocked).toEqual(2n); - - // Change was moved to correct address - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(0))).toHaveProperty( - 'numTransactions', - 2 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(1))).toHaveProperty( - 'numTransactions', - 2 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(2))).toHaveProperty( - 'numTransactions', - 2 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(3))).toHaveProperty( - 'numTransactions', - 0 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(4))).toHaveProperty( - 'numTransactions', - 0 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(5))).toHaveProperty( - 'numTransactions', - 1 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(6))).toHaveProperty( - 'numTransactions', - 0 - ); - }); - - it('should send custom token transactions', async () => { - const hWallet = await generateWalletHelper(); - await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n); - const { hash: tokenUid } = await createTokenHelper(hWallet, 'Token to Send', 'TTS', 100n); - - const tx1 = await hWallet.sendTransaction(await hWallet.getAddressAtIndex(5), 30n, { - token: tokenUid, - changeAddress: await hWallet.getAddressAtIndex(6), - }); - await waitForTxReceived(hWallet, tx1.hash); - - // Validating balance stays the same for internal transactions - let htrBalance = await hWallet.getBalance(tokenUid); - expect(htrBalance[0].balance.unlocked).toEqual(100n); - - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(5))).toHaveProperty( - 'numTransactions', - 1 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(6))).toHaveProperty( - 'numTransactions', - 1 - ); - - // Transaction outside the wallet - const { hWallet: gWallet } = await GenesisWalletHelper.getSingleton(); - await waitUntilNextTimestamp(hWallet, tx1.hash); - const { hash: tx2Hash } = await hWallet.sendTransaction( - await gWallet.getAddressAtIndex(0), - 80n, - { - token: tokenUid, - changeAddress: await hWallet.getAddressAtIndex(12), - } - ); - await waitForTxReceived(hWallet, tx2Hash); - await waitForTxReceived(gWallet, tx2Hash); - - // Balance was reduced - htrBalance = await hWallet.getBalance(tokenUid); - expect(htrBalance[0].balance.unlocked).toEqual(20n); - - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(5))).toHaveProperty( - 'numTransactions', - 2 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(6))).toHaveProperty( - 'numTransactions', - 2 - ); - expect( - await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(12)) - ).toHaveProperty('numTransactions', 1); - }); - it('should send custom fee token transactions', async () => { - const hWallet = await generateWalletHelper(); - await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n); - const { hash: tokenUid } = await createTokenHelper(hWallet, 'FeeBasedToken', 'FBT', 8582n, { - tokenVersion: TokenVersion.FEE, - }); - - const tx1 = await hWallet.sendTransaction(await hWallet.getAddressAtIndex(5), 8000n, { - token: tokenUid, - changeAddress: await hWallet.getAddressAtIndex(6), - }); - validateFeeAmount(tx1.headers, 2n); - await waitForTxReceived(hWallet, tx1.hash); - - // Validating balance stays the same for internal transactions - let fbtBalance = await hWallet.getBalance(tokenUid); - expect(fbtBalance[0].balance.unlocked).toEqual(8582n); - - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(5))).toHaveProperty( - 'numTransactions', - 1 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(6))).toHaveProperty( - 'numTransactions', - 1 - ); - - // Transaction outside the wallet - const { hWallet: gWallet } = await GenesisWalletHelper.getSingleton(); - await waitUntilNextTimestamp(hWallet, tx1.hash); - const { hash: tx2Hash, headers: tx2Headers } = await hWallet.sendTransaction( - await gWallet.getAddressAtIndex(0), - 82n, - { - token: tokenUid, - changeAddress: await hWallet.getAddressAtIndex(12), - } - ); - validateFeeAmount(tx2Headers, 2n); - await waitForTxReceived(hWallet, tx2Hash); - await waitForTxReceived(gWallet, tx2Hash); - - // Balance was reduced - fbtBalance = await hWallet.getBalance(tokenUid); - expect(fbtBalance[0].balance.unlocked).toEqual(8500n); - - const htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); - expect(htrBalance[0].balance.unlocked).toEqual(5n); - - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(5))).toHaveProperty( - 'numTransactions', - 1 - ); - expect(await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(6))).toHaveProperty( - 'numTransactions', - 2 - ); - expect( - await hWallet.storage.getAddressInfo(await hWallet.getAddressAtIndex(12)) - ).toHaveProperty('numTransactions', 1); - }); - - it('should send fee token with manually provided HTR input (no HTR output)', async () => { - const hWallet = await generateWalletHelper(); - await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n); - const { hash: tokenUid } = await createTokenHelper( - hWallet, - 'FeeTokenManualInput', - 'FTMI', - 100n, - { - tokenVersion: TokenVersion.FEE, - } - ); - - // Get UTXOs for both HTR and the fee token - const { utxos: utxosHtr } = await hWallet.getUtxos({ token: NATIVE_TOKEN_UID }); - const { utxos: utxosToken } = await hWallet.getUtxos({ token: tokenUid }); - - // Get the first UTXO of each token - const htrUtxo = utxosHtr[0]; - const tokenUtxo = utxosToken[0]; - - // Send transaction with manually provided inputs (HTR + token) and only token output - // This tests the scenario where user provides HTR input to pay for fee - // but has no HTR output (only token output) - const tx = await hWallet.sendManyOutputsTransaction( - [ - { - address: await hWallet.getAddressAtIndex(5), - value: 50n, - token: tokenUid, - }, - ], - { - inputs: [ - { txId: htrUtxo.tx_id, index: htrUtxo.index }, - { txId: tokenUtxo.tx_id, index: tokenUtxo.index }, - ], - } - ); - - validateFeeAmount(tx.headers, 2n); - await waitForTxReceived(hWallet, tx.hash); - - // Validate the transaction was created correctly - const decodedTx = await hWallet.getTx(tx.hash); - - // Should have 2 inputs (HTR + token) - expect(decodedTx.inputs).toHaveLength(2); - expect(decodedTx.inputs).toContainEqual( - expect.objectContaining({ tx_id: htrUtxo.tx_id, index: htrUtxo.index }) - ); - expect(decodedTx.inputs).toContainEqual( - expect.objectContaining({ tx_id: tokenUtxo.tx_id, index: tokenUtxo.index }) - ); - - // Should have outputs: token output (50) + token change (50) + HTR change - // Validate both token identity and token_data in a mixed-token transaction - expect(decodedTx.outputs).toContainEqual( - expect.objectContaining({ value: 50n, token: tokenUid, token_data: TOKEN_DATA.TOKEN }) - ); - expect(decodedTx.outputs).toContainEqual( - expect.objectContaining({ token: NATIVE_TOKEN_UID, token_data: TOKEN_DATA.HTR }) - ); - }); - - it('should send a multisig transaction', async () => { - // Initialize 3 wallets from the same multisig and inject funds in them to test - const mhWallet1 = await generateMultisigWalletHelper({ walletIndex: 0 }); - const mhWallet2 = await generateMultisigWalletHelper({ walletIndex: 1 }); - const mhWallet3 = await generateMultisigWalletHelper({ walletIndex: 2 }); - await GenesisWalletHelper.injectFunds(mhWallet1, await mhWallet1.getAddressAtIndex(0), 10n); - - /* - * Building tx proposal: - * 1) Identify the UTXO - * 2) Build the outputs - */ - const { tx_id: inputTxId, index: inputIndex } = (await mhWallet1.getUtxos()).utxos[0]; - const network = mhWallet1.getNetworkObject(); - const sendTransaction = new SendTransaction({ - storage: mhWallet1.storage, - inputs: [{ txId: inputTxId, index: inputIndex }], - outputs: [ - { - address: await mhWallet1.getAddressAtIndex(1), - value: 10n, - token: NATIVE_TOKEN_UID, - }, - ], - }); - const tx = transaction.createTransactionFromData( - { version: 1, ...(await sendTransaction.prepareTxData()) }, - network - ); - const txHex = tx.toHex(); - - // Getting signatures for the proposal - const sig1 = await mhWallet1.getAllSignatures(txHex, DEFAULT_PIN_CODE); - const sig2 = await mhWallet2.getAllSignatures(txHex, DEFAULT_PIN_CODE); - const sig3 = await mhWallet3.getAllSignatures(txHex, DEFAULT_PIN_CODE); - - // Delay to avoid the same timestamp as the fundTx - await waitUntilNextTimestamp(mhWallet1, inputTxId); - - // Sign and push - const partiallyAssembledTx = await mhWallet1.assemblePartialTransaction(txHex, [ - sig1, - sig2, - sig3, - ]); - partiallyAssembledTx.prepareToSend(); - const finalTx = new SendTransaction({ - storage: mhWallet1.storage, - transaction: partiallyAssembledTx, - }); - - /** @type BaseTransactionResponse */ - const sentTx = await finalTx.runFromMining(); - expect(sentTx).toHaveProperty('hash'); - await waitForTxReceived(mhWallet1, sentTx.hash, 10000); // Multisig transactions take longer - - const historyTx = await mhWallet1.getTx(sentTx.hash); - expect(historyTx).toMatchObject({ - tx_id: partiallyAssembledTx.hash, - inputs: [ - expect.objectContaining({ - tx_id: inputTxId, - value: 10n, - }), - ], - }); - - const fullNodeTx = await mhWallet1.getFullTxById(sentTx.hash); - expect(fullNodeTx.tx).toMatchObject({ - hash: partiallyAssembledTx.hash, - inputs: [ - expect.objectContaining({ - tx_id: inputTxId, - value: 10n, - }), - ], - }); - }); -}); - -describe('sendManyOutputsTransaction', () => { - afterEach(async () => { - await stopAllWallets(); - await GenesisWalletHelper.clearListeners(); - }); - - it('should send simple HTR transactions', async () => { - const hWallet = await generateWalletHelper(); - await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 100n); - - // Single input and single output - const rawSimpleTx = await hWallet.sendManyOutputsTransaction([ - { - address: await hWallet.getAddressAtIndex(2), - value: 100n, - token: NATIVE_TOKEN_UID, - }, - ]); - expect(rawSimpleTx).toHaveProperty('hash'); - await waitForTxReceived(hWallet, rawSimpleTx.hash); - const decodedSimple = await hWallet.getTx(rawSimpleTx.hash); - expect(decodedSimple.inputs).toHaveLength(1); - expect(decodedSimple.outputs).toHaveLength(1); - - // Single input and two outputs - await waitUntilNextTimestamp(hWallet, rawSimpleTx.hash); - const rawDoubleOutputTx = await hWallet.sendManyOutputsTransaction([ - { - address: await hWallet.getAddressAtIndex(5), - value: 60n, - token: NATIVE_TOKEN_UID, - }, - { - address: await hWallet.getAddressAtIndex(6), - value: 40n, - token: NATIVE_TOKEN_UID, - }, - ]); - await waitForTxReceived(hWallet, rawDoubleOutputTx.hash); - const decodedDoubleOutput = await hWallet.getTx(rawDoubleOutputTx.hash); - expect(decodedDoubleOutput.inputs).toHaveLength(1); - expect(decodedDoubleOutput.outputs).toHaveLength(2); - const largerOutputIndex = decodedDoubleOutput.outputs.findIndex(o => o.value === 60n); - - // Explicit input and three outputs - await waitUntilNextTimestamp(hWallet, rawDoubleOutputTx.hash); - const rawExplicitInputTx = await hWallet.sendManyOutputsTransaction( - [ - { - address: await hWallet.getAddressAtIndex(1), - value: 5n, - token: NATIVE_TOKEN_UID, - }, - { - address: await hWallet.getAddressAtIndex(2), - value: 35n, - token: NATIVE_TOKEN_UID, - }, - ], - { - inputs: [ - { - txId: decodedDoubleOutput.tx_id, - token: NATIVE_TOKEN_UID, - index: largerOutputIndex, - }, - ], - } - ); - await waitForTxReceived(hWallet, rawExplicitInputTx.hash); - const explicitInput = await hWallet.getTx(rawExplicitInputTx.hash); - expect(explicitInput.inputs).toHaveLength(1); - expect(explicitInput.outputs).toHaveLength(3); - - // Expect our explicit outputs and an automatic one to complete the 60 HTR input - expect(explicitInput.outputs).toContainEqual(expect.objectContaining({ value: 5n })); - expect(explicitInput.outputs).toContainEqual(expect.objectContaining({ value: 35n })); - // Validate change output - expect(explicitInput.outputs).toContainEqual(expect.objectContaining({ value: 20n })); - }); - - it('should send transactions with multiple tokens', async () => { - const hWallet = await generateWalletHelper(); - await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n); - const { hash: tokenUid } = await createTokenHelper(hWallet, 'Multiple Tokens Tk', 'MTTK', 200n); - - // Generating tx - const rawSendTx = await hWallet.sendManyOutputsTransaction([ - { - token: tokenUid, - value: 110n, - address: await hWallet.getAddressAtIndex(1), - }, - { - token: NATIVE_TOKEN_UID, - value: 5n, - address: await hWallet.getAddressAtIndex(2), - }, - ]); - await waitForTxReceived(hWallet, rawSendTx.hash); - - // Validating amount of inputs and outputs - const sendTx = await hWallet.getTx(rawSendTx.hash); - expect(sendTx.inputs).toHaveLength(2); - expect(sendTx.outputs).toHaveLength(4); - - // Validating that each of the outputs has the values we expect - expect(sendTx.outputs).toContainEqual( - expect.objectContaining({ - value: 3n, - token: NATIVE_TOKEN_UID, - }) - ); - expect(sendTx.outputs).toContainEqual( - expect.objectContaining({ - value: 5n, - token: NATIVE_TOKEN_UID, - }) - ); - expect(sendTx.outputs).toContainEqual( - expect.objectContaining({ - value: 90n, - token: tokenUid, - }) - ); - expect(sendTx.outputs).toContainEqual( - expect.objectContaining({ - value: 110n, - token: tokenUid, - }) - ); - - // Validating that each of the inputs has the values we expect - expect(sendTx.inputs).toContainEqual( - expect.objectContaining({ - value: 8n, - token: NATIVE_TOKEN_UID, - }) - ); - expect(sendTx.inputs).toContainEqual( - expect.objectContaining({ - value: 200n, - token: tokenUid, - }) - ); - }); - - it('should respect timelocks', async () => { - const hWallet = await generateWalletHelper(); - await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n); - - // Defining timelocks (milliseconds) and timestamps (seconds) - const startTime = Date.now().valueOf(); - const timelock1 = startTime + 5000; // 5 seconds of locked resources - const timelock2 = startTime + 8000; // 8 seconds of locked resources - const timelock1Timestamp = dateFormatter.dateToTimestamp(new Date(timelock1)); - const timelock2Timestamp = dateFormatter.dateToTimestamp(new Date(timelock2)); - - const rawTimelockTx = await hWallet.sendManyOutputsTransaction([ - { - address: await hWallet.getAddressAtIndex(1), - value: 7n, - token: NATIVE_TOKEN_UID, - timelock: timelock1Timestamp, - }, - { - address: await hWallet.getAddressAtIndex(1), - value: 3n, - token: NATIVE_TOKEN_UID, - timelock: timelock2Timestamp, - }, - ]); - await waitForTxReceived(hWallet, rawTimelockTx.hash); - - // Validating the transaction with getFullHistory / getTx - const timelockTx = await hWallet.getTx(rawTimelockTx.hash); - expect(timelockTx.outputs.find(o => o.decoded.timelock === timelock1Timestamp)).toBeDefined(); - expect(timelockTx.outputs.find(o => o.decoded.timelock === timelock2Timestamp)).toBeDefined(); - - // Validating getBalance ( moment 0 ) - let htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); - expect(htrBalance[0].balance).toStrictEqual({ locked: 10n, unlocked: 0n }); - - // Validating interfaces with only a partial lock of the resources - const waitFor1 = timelock1 - Date.now().valueOf() + 1000; - loggers.test.log(`Will wait for ${waitFor1}ms for timelock1 to expire`); - await delay(waitFor1); - - /* - * The locked/unlocked balances are usually updated when new transactions arrive. - * We will force this update here without a new tx, for testing purposes. - */ - await hWallet.storage.processHistory(); - - // Validating getBalance ( moment 1 ) - htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); - expect(htrBalance[0].balance).toEqual({ locked: 3n, unlocked: 7n }); - - // Confirm that the balance is unavailable - await expect(hWallet.sendTransaction(await hWallet.getAddressAtIndex(3), 8n)).rejects.toThrow( - 'Insufficient' - ); - // XXX: Error message should show the token identification, not "Token undefined" - - // Validating interfaces with all resources unlocked - const waitFor2 = timelock2 - Date.now().valueOf() + 1000; - loggers.test.log(`Will wait for ${waitFor2}ms for timelock2 to expire`); - await delay(waitFor2); - - // Forcing balance updates - await hWallet.storage.processHistory(); - - // Validating getBalance ( moment 2 ) - htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); - expect(htrBalance[0].balance).toStrictEqual({ locked: 0n, unlocked: 10n }); - - // Confirm that now the balance is available - const sendTx = await hWallet.sendTransaction(await hWallet.getAddressAtIndex(4), 8n); - expect(sendTx).toHaveProperty('hash'); - }); -}); - // authority utxo selection tests moved to fullnode-specific/authority-utxos.test.ts describe('createNewToken', () => { diff --git a/__tests__/integration/shared/send-many-outputs.test.ts b/__tests__/integration/shared/send-many-outputs.test.ts new file mode 100644 index 000000000..499f75a92 --- /dev/null +++ b/__tests__/integration/shared/send-many-outputs.test.ts @@ -0,0 +1,223 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Shared sendManyOutputsTransaction tests. + * + * Validates multi-output transaction behavior that is common to both the fullnode + * ({@link HathorWallet}) and wallet-service ({@link HathorWalletServiceWallet}) + * facades. + */ + +import type { IWalletTestAdapter } from '../adapters/types'; +import { NATIVE_TOKEN_UID } from '../../../src/constants'; +import { FullnodeWalletTestAdapter } from '../adapters/fullnode.adapter'; +import { ServiceWalletTestAdapter } from '../adapters/service.adapter'; +import dateFormatter from '../../../src/utils/date'; +import { delay } from '../utils/core.util'; +import { loggers } from '../utils/logger.util'; + +const adapters: IWalletTestAdapter[] = [ + new FullnodeWalletTestAdapter(), + new ServiceWalletTestAdapter(), +]; + +describe.each(adapters)('[Shared] sendManyOutputsTransaction — $name', adapter => { + beforeAll(async () => { + await adapter.suiteSetup(); + }); + + afterAll(async () => { + await adapter.suiteTeardown(); + }); + + it('should send simple HTR transactions', async () => { + const { wallet } = await adapter.createWallet(); + await adapter.injectFunds(wallet, (await wallet.getAddressAtIndex(0))!, 100n); + + // Single input and single output + const { transaction: tx1 } = await adapter.sendManyOutputsTransaction(wallet, [ + { + address: (await wallet.getAddressAtIndex(2))!, + value: 100n, + token: NATIVE_TOKEN_UID, + }, + ]); + const decoded1 = await adapter.getTx(wallet, tx1.hash); + expect(decoded1.inputs).toHaveLength(1); + expect(decoded1.outputs).toHaveLength(1); + + // Single input and two outputs + const { transaction: tx2 } = await adapter.sendManyOutputsTransaction(wallet, [ + { + address: (await wallet.getAddressAtIndex(5))!, + value: 60n, + token: NATIVE_TOKEN_UID, + }, + { + address: (await wallet.getAddressAtIndex(6))!, + value: 40n, + token: NATIVE_TOKEN_UID, + }, + ]); + const decoded2 = await adapter.getTx(wallet, tx2.hash); + expect(decoded2.inputs).toHaveLength(1); + expect(decoded2.outputs).toHaveLength(2); + const largerOutputIndex = decoded2.outputs.findIndex(o => o.value === 60n); + + // Explicit input and three outputs (5 + 35 + 20 change) + const { transaction: tx3 } = await adapter.sendManyOutputsTransaction( + wallet, + [ + { + address: (await wallet.getAddressAtIndex(1))!, + value: 5n, + token: NATIVE_TOKEN_UID, + }, + { + address: (await wallet.getAddressAtIndex(2))!, + value: 35n, + token: NATIVE_TOKEN_UID, + }, + ], + { + inputs: [ + { + txId: decoded2.tx_id, + token: NATIVE_TOKEN_UID, + index: largerOutputIndex, + }, + ], + } + ); + const decoded3 = await adapter.getTx(wallet, tx3.hash); + expect(decoded3.inputs).toHaveLength(1); + expect(decoded3.outputs).toHaveLength(3); + + expect(decoded3.outputs).toContainEqual(expect.objectContaining({ value: 5n })); + expect(decoded3.outputs).toContainEqual(expect.objectContaining({ value: 35n })); + expect(decoded3.outputs).toContainEqual(expect.objectContaining({ value: 20n })); + }); + + it('should send transactions with multiple tokens', async () => { + const { wallet } = await adapter.createWallet(); + await adapter.injectFunds(wallet, (await wallet.getAddressAtIndex(0))!, 10n); + const { hash: tokenUid } = await adapter.createToken( + wallet, + 'Multiple Tokens Tk', + 'MTTK', + 200n + ); + + const { transaction: tx } = await adapter.sendManyOutputsTransaction(wallet, [ + { + token: tokenUid, + value: 110n, + address: (await wallet.getAddressAtIndex(1))!, + }, + { + token: NATIVE_TOKEN_UID, + value: 5n, + address: (await wallet.getAddressAtIndex(2))!, + }, + ]); + + const sendTx = await adapter.getTx(wallet, tx.hash); + expect(sendTx.inputs).toHaveLength(2); + expect(sendTx.outputs).toHaveLength(4); + + // Validate output values + expect(sendTx.outputs).toContainEqual( + expect.objectContaining({ value: 3n, token: NATIVE_TOKEN_UID }) + ); + expect(sendTx.outputs).toContainEqual( + expect.objectContaining({ value: 5n, token: NATIVE_TOKEN_UID }) + ); + expect(sendTx.outputs).toContainEqual(expect.objectContaining({ value: 90n, token: tokenUid })); + expect(sendTx.outputs).toContainEqual( + expect.objectContaining({ value: 110n, token: tokenUid }) + ); + + // Validate input values + expect(sendTx.inputs).toContainEqual( + expect.objectContaining({ value: 8n, token: NATIVE_TOKEN_UID }) + ); + expect(sendTx.inputs).toContainEqual(expect.objectContaining({ value: 200n, token: tokenUid })); + }); + + it('should respect timelocks', async () => { + const { wallet } = await adapter.createWallet(); + await adapter.injectFunds(wallet, (await wallet.getAddressAtIndex(0))!, 10n); + + const startTime = Date.now().valueOf(); + const timelock1 = startTime + 5000; + const timelock2 = startTime + 8000; + const timelock1Timestamp = dateFormatter.dateToTimestamp(new Date(timelock1)); + const timelock2Timestamp = dateFormatter.dateToTimestamp(new Date(timelock2)); + + const { transaction: tx } = await adapter.sendManyOutputsTransaction(wallet, [ + { + address: (await wallet.getAddressAtIndex(1))!, + value: 7n, + token: NATIVE_TOKEN_UID, + timelock: timelock1Timestamp, + }, + { + address: (await wallet.getAddressAtIndex(1))!, + value: 3n, + token: NATIVE_TOKEN_UID, + timelock: timelock2Timestamp, + }, + ]); + + // Validate timelocks on outputs + const timelockTx = await adapter.getTx(wallet, tx.hash); + expect(timelockTx.outputs.find(o => o.decoded.timelock === timelock1Timestamp)).toBeDefined(); + expect(timelockTx.outputs.find(o => o.decoded.timelock === timelock2Timestamp)).toBeDefined(); + + // Moment 0: all funds locked + let htrBalance = await wallet.getBalance(NATIVE_TOKEN_UID); + expect(htrBalance[0].balance).toStrictEqual({ locked: 10n, unlocked: 0n }); + + // Wait for first timelock to expire + const waitFor1 = timelock1 - Date.now().valueOf() + 1000; + loggers.test.log(`Will wait for ${waitFor1}ms for timelock1 to expire`); + await delay(waitFor1); + + // Force balance recalculation + await wallet.storage.processHistory(); + + // Moment 1: 7n unlocked, 3n still locked + htrBalance = await wallet.getBalance(NATIVE_TOKEN_UID); + expect(htrBalance[0].balance).toEqual({ locked: 3n, unlocked: 7n }); + + // Confirm that locked balance is unavailable + // Fullnode: "Insufficient funds" | Wallet Service: "No UTXOs available" + await expect( + adapter.sendTransaction(wallet, (await wallet.getAddressAtIndex(3))!, 8n) + ).rejects.toThrow(); + + // Wait for second timelock to expire + const waitFor2 = timelock2 - Date.now().valueOf() + 1000; + loggers.test.log(`Will wait for ${waitFor2}ms for timelock2 to expire`); + await delay(waitFor2); + + await wallet.storage.processHistory(); + + // Moment 2: all funds unlocked + htrBalance = await wallet.getBalance(NATIVE_TOKEN_UID); + expect(htrBalance[0].balance).toStrictEqual({ locked: 0n, unlocked: 10n }); + + // Confirm balance is now available + const { hash } = await adapter.sendTransaction( + wallet, + (await wallet.getAddressAtIndex(4))!, + 8n + ); + expect(hash).toBeDefined(); + }); +}); diff --git a/__tests__/integration/shared/send-transaction-tokens.test.ts b/__tests__/integration/shared/send-transaction-tokens.test.ts new file mode 100644 index 000000000..16a9f523d --- /dev/null +++ b/__tests__/integration/shared/send-transaction-tokens.test.ts @@ -0,0 +1,280 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Shared custom token and fee token sendTransaction tests. + * + * Validates token creation, custom token sends, and fee token behavior that + * is common to both the fullnode ({@link HathorWallet}) and wallet-service + * ({@link HathorWalletServiceWallet}) facades. + */ + +import type { IWalletTestAdapter } from '../adapters/types'; +import { NATIVE_TOKEN_UID } from '../../../src/constants'; +import { TokenVersion } from '../../../src/types'; +import { FullnodeWalletTestAdapter } from '../adapters/fullnode.adapter'; +import { ServiceWalletTestAdapter } from '../adapters/service.adapter'; +import { TOKEN_DATA } from '../configuration/test-constants'; +import FeeHeader from '../../../src/headers/fee'; +import Header from '../../../src/headers/base'; + +const adapters: IWalletTestAdapter[] = [ + new FullnodeWalletTestAdapter(), + new ServiceWalletTestAdapter(), +]; + +/** + * Validates that the fee header contains exactly one entry with the expected amount. + * This is the expected result for createToken / sendTransaction methods, which interact with only + * one token per tx. + */ +function validateFeeAmount(headers: Header[], expectedFee: bigint) { + const feeHeaders = headers.filter(h => h instanceof FeeHeader); + expect(feeHeaders).toHaveLength(1); + const { entries } = feeHeaders[0] as FeeHeader; + expect(entries).toHaveLength(1); + expect(entries[0].amount).toBe(expectedFee); +} + +describe.each(adapters)('[Shared] sendTransaction — custom tokens — $name', adapter => { + /** Creates a funded wallet and an external wallet for receiving. */ + async function createFundedPair(htrAmount: bigint) { + const created = await adapter.createWallet(); + const { wallet } = created; + await adapter.injectFunds(wallet, (await wallet.getAddressAtIndex(0))!, htrAmount); + const ext = (await adapter.createWallet()).wallet; + return { wallet, externalWallet: ext }; + } + + beforeAll(async () => { + await adapter.suiteSetup(); + }); + + afterAll(async () => { + await adapter.suiteTeardown(); + }); + + it('should send custom token transactions', async () => { + // wallet: 10n HTR + const { wallet, externalWallet } = await createFundedPair(10n); + // 100n DBT created, 1n HTR deposit deducted + const { hash: tokenUid } = await adapter.createToken(wallet, 'DepositBasedToken', 'DBT', 100n); + + // Self-send 30n DBT, total DBT unchanged + await adapter.sendTransaction(wallet, (await wallet.getAddressAtIndex(5))!, 30n, { + token: tokenUid, + changeAddress: (await wallet.getAddressAtIndex(6))!, + }); + + // 100n DBT remaining (self-send doesn't change balance) + const tokenBalance = await wallet.getBalance(tokenUid); + expect(tokenBalance[0].balance.unlocked).toEqual(100n); + + // Sends 80n DBT to external wallet + const externalAddr = (await externalWallet.getAddressAtIndex(0))!; + await adapter.sendTransaction(wallet, externalAddr, 80n, { + token: tokenUid, + recvWallet: externalWallet, + }); + + // 20n DBT remaining after sending 80n to external wallet + const remainingBalance = await wallet.getBalance(tokenUid); + expect(remainingBalance[0].balance.unlocked).toEqual(20n); + + // External wallet received 80n DBT + const externalBalance = await externalWallet.getBalance(tokenUid); + expect(externalBalance[0].balance.unlocked).toEqual(80n); + }); + + it('should send custom fee token transactions', async () => { + const { wallet, externalWallet } = await createFundedPair(10n); + // Spends 1n HTR as fee, 9n HTR remaining + const { hash: tokenUid } = await adapter.createToken(wallet, 'FeeBasedToken', 'FBT', 8582n, { + tokenVersion: TokenVersion.FEE, + }); + + // Spends 2n HTR as fee, 7n HTR remaining + const { transaction: tx1 } = await adapter.sendTransaction( + wallet, + (await wallet.getAddressAtIndex(5))!, + 8000n, + { token: tokenUid, changeAddress: (await wallet.getAddressAtIndex(6))! } + ); + validateFeeAmount(tx1.headers, 2n); + + // Full FBT and 7n HTR remaining after first send + let fbtBalance = await wallet.getBalance(tokenUid); + expect(fbtBalance[0].balance.unlocked).toEqual(8582n); + + // Spends 2n HTR as fee, 5n HTR remaining + const { transaction: tx2 } = await adapter.sendTransaction( + wallet, + (await externalWallet.getAddressAtIndex(0))!, + 82n, + { token: tokenUid } + ); + validateFeeAmount(tx2.headers, 2n); + + // 8500n FBT remaining on original wallet after second send + fbtBalance = await wallet.getBalance(tokenUid); + expect(fbtBalance[0].balance.unlocked).toEqual(8500n); + + // 5n HTR remaining after both sends + const htrBalance = await wallet.getBalance(NATIVE_TOKEN_UID); + expect(htrBalance[0].balance.unlocked).toEqual(5n); + }); + + it('should pay only 1 HTR fee when sending entire fee token UTXO (no change output)', async () => { + // wallet: 10n HTR + const { wallet } = await createFundedPair(10n); + // Spends 1n HTR as fee, 9n HTR remaining. 200n FTNC created + const { hash: tokenUid } = await adapter.createToken(wallet, 'FeeTokenNoChange', 'FTNC', 200n, { + tokenVersion: TokenVersion.FEE, + }); + + // Sends entire 200n FTNC (no change output = 1 token output), spends 1n HTR as fee, 8n HTR remaining + const { transaction: tx } = await adapter.sendTransaction( + wallet, + (await wallet.getAddressAtIndex(5))!, + 200n, + { token: tokenUid } + ); + validateFeeAmount(tx.headers, 1n); + + const fullTx = await adapter.getFullTxById(wallet, tx.hash!); + // Exactly one token output (destination, no change) + const tokenOutputs = fullTx.tx.outputs.filter( + (o: { token_data: number }) => o.token_data !== 0 + ); + expect(tokenOutputs).toHaveLength(1); + expect(tokenOutputs[0].value).toBe(200n); + + // 8n HTR remaining after createToken (1n fee) + send (1n fee) + const htrBalance = await wallet.getBalance(NATIVE_TOKEN_UID); + expect(htrBalance[0].balance.unlocked).toEqual(8n); + }); + + it('should pay 5 HTR fee when spreading fee token across 5 outputs with no change', async () => { + // wallet: 10n HTR + const { wallet } = await createFundedPair(10n); + // Spends 1n HTR as fee, 9n HTR remaining. 500n FT5O created + const { hash: tokenUid } = await adapter.createToken(wallet, 'FeeToken5Out', 'FT5O', 500n, { + tokenVersion: TokenVersion.FEE, + }); + + // Spreads entire 500n FT5O across 5 outputs (100n each), no change output + const outputs = await Promise.all( + [1, 2, 3, 4, 5].map(async i => ({ + address: (await wallet.getAddressAtIndex(i))!, + value: 100n, + token: tokenUid, + })) + ); + + // 5 token outputs × 1n HTR each = 5n HTR fee, 4n HTR remaining + const { transaction: tx } = await adapter.sendManyOutputsTransaction(wallet, outputs); + validateFeeAmount(tx.headers, 5n); + + const fullTx = await adapter.getFullTxById(wallet, tx.hash!); + const tokenOutputs = fullTx.tx.outputs.filter( + (o: { token_data: number }) => o.token_data !== 0 + ); + expect(tokenOutputs).toHaveLength(5); + tokenOutputs.forEach((o: { value: bigint }) => expect(o.value).toBe(100n)); + + // 4n HTR remaining after createToken (1n fee) + send (5n fee) + const htrBalance = await wallet.getBalance(NATIVE_TOKEN_UID); + expect(htrBalance[0].balance.unlocked).toEqual(4n); + }); + + // Skipped: SendTransactionWalletService.prepareTx() builds utxosAddressPath + // in [token, htr] order regardless of this.inputs order, causing wrong + // signatures when HTR inputs come before token inputs. See #1057. + // eslint-disable-next-line jest/no-disabled-tests + it.skip('should send fee token with manually provided HTR input — HTR first (broken signing)', async () => { + // wallet: 10n HTR + const { wallet } = await createFundedPair(10n); + // Spends 1n HTR as fee, 9n HTR remaining. 100n FTMI created + const { hash: tokenUid } = await adapter.createToken( + wallet, + 'FeeTokenManualInput', + 'FTMI', + 100n, + { + tokenVersion: TokenVersion.FEE, + } + ); + + const { utxos: utxosHtr } = await adapter.getUtxos(wallet, { token: NATIVE_TOKEN_UID }); + const { utxos: utxosToken } = await adapter.getUtxos(wallet, { token: tokenUid }); + + const htrUtxo = utxosHtr[0]; + const tokenUtxo = utxosToken[0]; + + // Sends 50n FTMI with manual inputs (HTR first), spends 2n HTR as fee, 7n HTR remaining + const { transaction: tx } = await adapter.sendManyOutputsTransaction( + wallet, + [{ address: (await wallet.getAddressAtIndex(5))!, value: 50n, token: tokenUid }], + { + inputs: [ + { txId: htrUtxo.tx_id, token: NATIVE_TOKEN_UID, index: htrUtxo.index }, + { txId: tokenUtxo.tx_id, token: tokenUid, index: tokenUtxo.index }, + ], + } + ); + validateFeeAmount(tx.headers, 2n); + + const fullTx = await adapter.getFullTxById(wallet, tx.hash!); + expect(fullTx.tx.inputs).toHaveLength(2); + expect(fullTx.tx.outputs).toContainEqual( + expect.objectContaining({ value: 50n, token_data: 1 }) + ); + }); + + it('should send fee token with manually provided inputs (token before HTR)', async () => { + // Inputs are listed token-first to match the internal processing order + // of SendTransactionWalletService.prepareTx(), which builds address + // paths as [custom_tokens..., htr...]. See #1057 for the underlying bug. + // wallet: 10n HTR + const { wallet } = await createFundedPair(10n); + // Spends 1n HTR as fee, 9n HTR remaining. 100n FTMI created + const { hash: tokenUid } = await adapter.createToken( + wallet, + 'FeeTokenManualInput', + 'FTMI', + 100n, + { + tokenVersion: TokenVersion.FEE, + } + ); + + const { utxos: utxosHtr } = await adapter.getUtxos(wallet, { token: NATIVE_TOKEN_UID }); + const { utxos: utxosToken } = await adapter.getUtxos(wallet, { token: tokenUid }); + + const htrUtxo = utxosHtr[0]; + const tokenUtxo = utxosToken[0]; + + // Sends 50n FTMI with manual inputs (token first), spends 2n HTR as fee, 7n HTR remaining + const { transaction: tx } = await adapter.sendManyOutputsTransaction( + wallet, + [{ address: (await wallet.getAddressAtIndex(5))!, value: 50n, token: tokenUid }], + { + inputs: [ + { txId: tokenUtxo.tx_id, token: tokenUid, index: tokenUtxo.index }, + { txId: htrUtxo.tx_id, token: NATIVE_TOKEN_UID, index: htrUtxo.index }, + ], + } + ); + validateFeeAmount(tx.headers, 2n); + + const fullTx = await adapter.getFullTxById(wallet, tx.hash!); + expect(fullTx.tx.inputs).toHaveLength(2); + expect(fullTx.tx.outputs).toContainEqual( + expect.objectContaining({ value: 50n, token_data: TOKEN_DATA.TOKEN }) + ); + }); +}); diff --git a/__tests__/integration/shared/send-transaction.test.ts b/__tests__/integration/shared/send-transaction.test.ts new file mode 100644 index 000000000..5eadaf954 --- /dev/null +++ b/__tests__/integration/shared/send-transaction.test.ts @@ -0,0 +1,158 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Shared sendTransaction() tests. + * + * Validates HTR transaction sending behavior that is common to both the fullnode + * ({@link HathorWallet}) and wallet-service ({@link HathorWalletServiceWallet}) + * facades. + * + * Custom token and fee token shared tests live in `shared/send-transaction-tokens.test.ts`. + * Facade-specific tests (address tracking, multisig signing) + * live in `fullnode-specific/send-transaction.test.ts`. + */ + +import type { FuzzyWalletType, IWalletTestAdapter } from '../adapters/types'; +import { NATIVE_TOKEN_UID } from '../../../src/constants'; +import { FullnodeWalletTestAdapter } from '../adapters/fullnode.adapter'; +import { ServiceWalletTestAdapter } from '../adapters/service.adapter'; +import { WALLET_CONSTANTS } from '../configuration/test-constants'; + +const adapters: IWalletTestAdapter[] = [ + new FullnodeWalletTestAdapter(), + new ServiceWalletTestAdapter(), +]; + +describe.each(adapters)('[Shared] sendTransaction — $name', adapter => { + let wallet: FuzzyWalletType; + let externalWallet: FuzzyWalletType; + + beforeAll(async () => { + await adapter.suiteSetup(); + + // Create a funded wallet + const created = await adapter.createWallet(); + wallet = created.wallet; + const addr = await wallet.getAddressAtIndex(0); + await adapter.injectFunds(wallet, addr!, 20n); + + // Create a second wallet to receive external transfers + externalWallet = (await adapter.createWallet()).wallet; + }); + + afterAll(async () => { + await adapter.suiteTeardown(); + }); + + it('should not change balance for an internal transfer', async () => { + const balanceBefore = await wallet.getBalance(NATIVE_TOKEN_UID); + const unlockedBefore = balanceBefore[0].balance.unlocked; + + // Send within the same wallet + const addr2 = await wallet.getAddressAtIndex(2); + await adapter.sendTransaction(wallet, addr2!, 5n); + + const balanceAfter = await wallet.getBalance(NATIVE_TOKEN_UID); + expect(balanceAfter[0].balance.unlocked).toEqual(unlockedBefore); + }); + + it('should decrease balance when sending to an external wallet', async () => { + const balanceBefore = await wallet.getBalance(NATIVE_TOKEN_UID); + const unlockedBefore = balanceBefore[0].balance.unlocked; + + // Send to external wallet + const externalAddr = await externalWallet.getAddressAtIndex(0); + const sendAmount = 3n; + await adapter.sendTransaction(wallet, externalAddr!, sendAmount); + + const balanceAfter = await wallet.getBalance(NATIVE_TOKEN_UID); + expect(balanceAfter[0].balance.unlocked).toEqual(unlockedBefore - sendAmount); + }); + + it('should validate full transaction structure', async () => { + const externalAddr = await externalWallet.getAddressAtIndex(1); + const { transaction: tx } = await adapter.sendTransaction(wallet, externalAddr!, 1n); + + expect(tx).toEqual( + expect.objectContaining({ + hash: expect.any(String), + inputs: expect.any(Array), + outputs: expect.any(Array), + version: expect.any(Number), + weight: expect.any(Number), + nonce: expect.any(Number), + timestamp: expect.any(Number), + parents: expect.arrayContaining([expect.any(String)]), + tokens: expect.any(Array), + signalBits: expect.any(Number), + }) + ); + + expect(tx.hash).toHaveLength(64); + expect(tx.inputs.length).toBeGreaterThan(0); + expect(tx.outputs.length).toBeGreaterThan(0); + expect(tx.tokens).toHaveLength(0); + expect(tx.parents).toHaveLength(2); + expect(tx.timestamp).toBeGreaterThan(0); + + const recipientOutput = tx.outputs.find(output => output.value === 1n); + expect(recipientOutput).toStrictEqual(expect.objectContaining({ value: 1n, tokenData: 0 })); + }); + + it('should send a transaction to a P2SH (multisig) address', async () => { + const p2shAddress = WALLET_CONSTANTS.multisig.addresses[0]; + + const { hash, transaction: tx } = await adapter.sendTransaction(wallet, p2shAddress, 1n); + + expect(tx).toEqual( + expect.objectContaining({ + hash: expect.any(String), + inputs: expect.any(Array), + outputs: expect.any(Array), + }) + ); + + const fullTx = await adapter.getFullTxById(wallet, hash); + expect(fullTx.success).toBe(true); + + const p2shOutput = fullTx.tx.outputs.find(output => output.decoded?.address === p2shAddress); + expect(p2shOutput).toBeDefined(); + expect(p2shOutput!.value).toBe(1n); + expect(p2shOutput!.decoded.type).toBe('MultiSig'); + }); + + it('should send a transaction with a set changeAddress', async () => { + const freshWallet = (await adapter.createWallet()).wallet; + await adapter.injectFunds(freshWallet, (await freshWallet.getAddressAtIndex(0))!, 10n); + + const recipientAddr = (await freshWallet.getAddressAtIndex(1))!; + const changeAddr = (await freshWallet.getAddressAtIndex(2))!; + + const { hash, transaction: tx } = await adapter.sendTransaction( + freshWallet, + recipientAddr, + 2n, + { changeAddress: changeAddr } + ); + + expect(tx.outputs.length).toBe(2); + + const fullTx = await adapter.getFullTxById(freshWallet, hash); + expect(fullTx.success).toBe(true); + + const recipientOutput = fullTx.tx.outputs.find( + output => output.decoded?.address === recipientAddr + ); + expect(recipientOutput).toBeDefined(); + expect(recipientOutput!.value).toBe(2n); + + const changeOutput = fullTx.tx.outputs.find(output => output.decoded?.address === changeAddr); + expect(changeOutput).toBeDefined(); + expect(changeOutput!.value).toBe(8n); + }); +}); diff --git a/__tests__/integration/walletservice_facade.test.ts b/__tests__/integration/walletservice_facade.test.ts index 75521b190..3a2a9ca97 100644 --- a/__tests__/integration/walletservice_facade.test.ts +++ b/__tests__/integration/walletservice_facade.test.ts @@ -28,22 +28,6 @@ initializeServiceGlobalConfigs(); const gWallet: HathorWalletServiceWallet = GenesisWalletServiceHelper.getSingleton(); /** Wallet instance used in tests */ let wallet: HathorWalletServiceWallet; -const walletWithTxs = { - words: - 'bridge balance milk impact love orchard achieve matrix mule axis size hip cargo rescue truth stable setup problem nerve fit million manage harbor connect', - addresses: [ - 'WeSnE5dnrciKahKbTvbUWmY6YM9Ntgi6MJ', - 'Wj52SGubNZu3JA2ncRXyNGfqyrdnj4XTU2', - 'Wh1Xs7zPVT9bc6dzzA23Zu8aiP3H8zLkiy', - 'WdFeZvVJkwAdLDpXLGJpFP9XSDcdrntvAg', - 'WTdWsgnCPKBuzEKAT4NZkzHaD4gHYMrk4G', - 'WSBhEBkuLpqu2Fz1j6PUyUa1W4GGybEYSF', - 'WS8yocEYBykpgjQxAhxjTcjVw9gKtYdys8', - 'WmkBa6ikYM2sZmiopM6zBGswJKvzs5Noix', - 'WeEPszSx14og6c3uPXy2vYh7BK9c6Zb9TX', - 'WWrNhymgFetPfxCv4DncveG6ykLHspHQxv', - ], -}; const customTokenWallet = { words: 'shine myself welcome feature nurse cement crumble input utility lizard melt great sample slab know leisure salmon path gate iron enlist discover cry radio', @@ -308,153 +292,7 @@ describe('basic transaction methods', () => { } }); - describe('sendTransaction - native token', () => { - it('should send a simple transaction with native token', async () => { - const sendTransaction = await gWallet.sendTransaction(walletWithTxs.addresses[0], 10n, { - pinCode, - }); - - // Shallow validate all properties of the returned Transaction object - expect(sendTransaction).toEqual( - expect.objectContaining({ - // Core transaction identification - hash: expect.any(String), - - // Inputs and outputs - inputs: expect.any(Array), - outputs: expect.any(Array), - - // Transaction metadata - version: expect.any(Number), - weight: expect.any(Number), - nonce: expect.any(Number), - signalBits: expect.any(Number), - timestamp: expect.any(Number), - - // Transaction relationships - parents: expect.arrayContaining([expect.any(String)]), - tokens: expect.any(Array), // May be empty array - - // Headers - headers: expect.any(Array), // May be empty - }) - ); - - // Deep validate the Inputs and Outputs arrays - expect(sendTransaction.inputs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - hash: expect.any(String), - index: expect.any(Number), - data: expect.any(Buffer), - }), - ]) - ); - - expect(sendTransaction.outputs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - value: expect.any(BigInt), - script: expect.any(Buffer), - tokenData: expect.any(Number), - }), - ]) - ); - - // Additional specific validations - expect(sendTransaction.hash).toHaveLength(64); // Transaction hash should be 64 hex characters - expect(sendTransaction.inputs.length).toBeGreaterThan(0); // Should have at least one input - expect(sendTransaction.outputs.length).toBeGreaterThan(0); // Should have at least one output - expect(sendTransaction.tokens).toHaveLength(0); // Not populated if only the native token is sent - expect(sendTransaction.parents).toHaveLength(2); // Should have exactly 2 parents - expect(sendTransaction.timestamp).toBeGreaterThan(0); // Should have a valid timestamp - - // Verify the transaction was sent to the correct address with correct value - const recipientOutput = sendTransaction.outputs.find(output => output.value === 10n); - expect(recipientOutput).toStrictEqual( - expect.objectContaining({ - value: 10n, - tokenData: 0, - }) - ); - - await pollForTx(gWallet, sendTransaction.hash!); - }); - - it('should send a transaction to a P2SH (multisig) address', async () => { - // Use a P2SH address from the multisig wallet constants - const p2shAddress = WALLET_CONSTANTS.multisig.addresses[0]; - - const sendTransaction = await gWallet.sendTransaction(p2shAddress, 5n, { - pinCode, - }); - - // Validate the transaction was created successfully - expect(sendTransaction).toEqual( - expect.objectContaining({ - hash: expect.any(String), - inputs: expect.any(Array), - outputs: expect.any(Array), - }) - ); - - // Wait for transaction to be confirmed and verify it on the full node - await pollForTx(gWallet, sendTransaction.hash!); - - // Get the full transaction from the network to verify the P2SH output - const fullTx = await gWallet.getFullTxById(sendTransaction.hash!); - expect(fullTx.success).toBe(true); - - // Find the output with the P2SH address - const p2shOutput = fullTx.tx.outputs.find(output => output.decoded?.address === p2shAddress); - - expect(p2shOutput).toBeDefined(); - expect(p2shOutput!.value).toBe(5n); - expect(p2shOutput!.decoded.type).toBe('MultiSig'); - expect(p2shOutput!.decoded.address).toBe(p2shAddress); - }); - - it('should send a transaction with a set changeAddress', async () => { - ({ wallet } = buildWalletInstance({ words: walletWithTxs.words })); - await wallet.start({ pinCode, password }); - - const sendTransaction = await wallet.sendTransaction(walletWithTxs.addresses[1], 4n, { - pinCode, - changeAddress: walletWithTxs.addresses[0], - }); - - // Verify that the only outputs were the recipient and the change address - expect(sendTransaction.outputs.length).toBe(2); - - // Verify the transaction was sent to the correct address with correct value - let recipientIndex; - let changeIndex; - sendTransaction.outputs.forEach((output, index) => { - if (output.value === 4n) { - recipientIndex = index; - } else if (output.value === 6n) { - changeIndex = index; - } - }); - - // Confirm the addresses through UTXO queries - await pollForTx(wallet, sendTransaction.hash!); - const recipientUtxo = await wallet.getUtxoFromId(sendTransaction.hash!, recipientIndex); - expect(recipientUtxo).toStrictEqual( - expect.objectContaining({ - address: walletWithTxs.addresses[1], - value: 4n, - }) - ); - const changeUtxo = await wallet.getUtxoFromId(sendTransaction.hash!, changeIndex); - expect(changeUtxo).toStrictEqual( - expect.objectContaining({ - address: walletWithTxs.addresses[0], - value: 6n, - }) - ); - }); - }); + // sendTransaction - native token tests moved to shared/send-transaction.test.ts describe('createNewToken, getTokenDetails', () => { const tokenName = 'TestToken';