diff --git a/__tests__/integration/adapters/fullnode.adapter.ts b/__tests__/integration/adapters/fullnode.adapter.ts new file mode 100644 index 000000000..65233fc83 --- /dev/null +++ b/__tests__/integration/adapters/fullnode.adapter.ts @@ -0,0 +1,230 @@ +/* eslint-disable class-methods-use-this */ +/** + * 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. + */ + +import HathorWallet from '../../../src/new/wallet'; +import { WalletTracker } from '../utils/wallet-tracker.util'; +import { WalletState } from '../../../src/types'; +import type Transaction from '../../../src/models/transaction'; +import { + generateConnection, + waitForWalletReady, + waitForTxReceived, + waitUntilNextTimestamp, + DEFAULT_PASSWORD, + DEFAULT_PIN_CODE, +} from '../helpers/wallet.helper'; +import { GenesisWalletHelper } from '../helpers/genesis-wallet.helper'; +import { precalculationHelpers } from '../helpers/wallet-precalculation.helper'; +import type { WalletStopOptions } from '../../../src/new/types'; +import { NETWORK_NAME } from '../configuration/test-constants'; +import type { + FuzzyWalletType, + IWalletTestAdapter, + WalletCapabilities, + CreateWalletOptions, + CreateWalletResult, +} from './types'; +import type { PrecalculatedWalletData } from '../helpers/wallet-precalculation.helper'; + +/** Stop options shared between {@link stopWallet} and the {@link WalletTracker}. */ +const STOP_OPTIONS: WalletStopOptions = { cleanStorage: true, cleanAddresses: true }; + +/** + * Adapter for the fullnode facade ({@link HathorWallet}). + * + * Key behavioral differences from the service adapter: + * - `start()` returns immediately; callers must explicitly `waitForReady()`. + * - Supports multisig, xpub-readonly, token scoping, and external signing. + * - Uses the fullnode P2P helpers ({@link GenesisWalletHelper}) for fund injection. + */ +export class FullnodeWalletTestAdapter implements IWalletTestAdapter { + name = 'Fullnode'; + + networkName = NETWORK_NAME; + + defaultPinCode = DEFAULT_PIN_CODE; + + defaultPassword = DEFAULT_PASSWORD; + + capabilities: WalletCapabilities = { + supportsMultisig: true, + supportsTokenScope: true, + supportsXpubReadonly: true, + supportsExternalSigning: true, + supportsRuntimeAddressCalculation: true, + supportsPreStartFunding: true, + requiresExplicitWaitReady: true, + stateEventValues: { + loading: WalletState.CONNECTING, + ready: WalletState.READY, + }, + }; + + private readonly tracker = new WalletTracker(STOP_OPTIONS); + + /** + * Narrows a {@link FuzzyWalletType} to the concrete {@link HathorWallet}. + * + * The double-cast (`as unknown as`) is required because {@link IHathorWallet} + * and {@link HathorWallet} are not structurally compatible (see type aliases + * in types.ts). Centralizing it here keeps the rest of the adapter cast-free. + */ + private concrete(wallet: FuzzyWalletType): HathorWallet { + return wallet as unknown as HathorWallet; + } + + async suiteSetup(): Promise { + // GenesisWalletHelper lazily initializes via getSingleton(), no explicit setup needed. + await GenesisWalletHelper.getSingleton(); + } + + async suiteTeardown(): Promise { + await this.stopAllWallets(); + await GenesisWalletHelper.clearListeners(); + } + + /** + * Creates a fully started, ready-to-use wallet with default credentials. + * + * Delegates to {@link buildWalletInstance} for construction and + * {@link startWallet} for startup, filling in default credentials so tests + * that just need a working wallet have zero setup friction. + */ + async createWallet(options?: CreateWalletOptions): Promise { + const built = this.buildWalletInstance(options); + + await this.startWallet(built.wallet, { + pinCode: options?.pinCode ?? DEFAULT_PIN_CODE, + password: options?.password ?? DEFAULT_PASSWORD, + }); + await this.waitForReady(built.wallet); + + return built; + } + + buildWalletInstance(options?: CreateWalletOptions): CreateWalletResult { + const walletData = this.resolveWordsAndAddresses(options); + const walletConfig = this.buildConfig(walletData, options); + + const hWallet = new HathorWallet(walletConfig); + this.tracker.track(hWallet); + + return { + wallet: hWallet as FuzzyWalletType, + storage: hWallet.storage, + words: walletData.words, + addresses: walletData.addresses, + }; + } + + async startWallet( + wallet: FuzzyWalletType, + options?: { pinCode?: string; password?: string } + ): Promise { + await this.concrete(wallet).start({ + pinCode: options?.pinCode, + password: options?.password, + }); + } + + async waitForReady(wallet: FuzzyWalletType): Promise { + await waitForWalletReady(this.concrete(wallet)); + } + + async stopWallet(wallet: FuzzyWalletType): Promise { + const hWallet = this.concrete(wallet); + await hWallet.stop(STOP_OPTIONS); + this.tracker.untrack(hWallet); + } + + async stopAllWallets(): Promise { + await this.tracker.stopAll(); + } + + async injectFunds( + destWallet: FuzzyWalletType, + address: string, + amount: bigint + ): Promise { + return GenesisWalletHelper.injectFunds(this.concrete(destWallet), address, amount); + } + + /** + * Sends funds to an address whose wallet has not started yet. + * + * Cannot delegate to {@link injectFunds} because that method polls both the + * genesis and the destination wallet for tx confirmation — but the destination + * wallet isn't running yet, so polling it would hang or fail. + */ + async injectFundsBeforeStart(address: string, amount: bigint): Promise { + const { hWallet: gWallet } = await GenesisWalletHelper.getSingleton(); + const result = await gWallet.sendTransaction(address, amount); + if (!result || !result.hash) { + throw new Error('injectFundsBeforeStart: transaction had no hash'); + } + return result.hash; + } + + async waitForTx(wallet: FuzzyWalletType, txId: string): Promise { + const hWallet = this.concrete(wallet); + await waitForTxReceived(hWallet, txId); + await waitUntilNextTimestamp(hWallet, txId); + } + + getPrecalculatedWallet(): PrecalculatedWalletData { + return precalculationHelpers.test!.getPrecalculatedWallet(); + } + + // --- Private helpers --- + + /** + * Resolves the wallet identity for simple cases (seed, addresses) from the caller's options. + * + * When no explicit identity is provided, a precalculated wallet is used. + * For xpub/xpriv-only wallets, `words` will be `undefined` — that's intentional: + * {@link buildConfig} spreads `xpub`/`xpriv` into the config independently of the seed. + */ + private resolveWordsAndAddresses(options?: CreateWalletOptions): { + words?: string; + addresses?: string[]; + } { + if (!options?.seed && !options?.xpub && !options?.xpriv) { + const precalc = this.getPrecalculatedWallet(); + return { words: precalc.words, addresses: precalc.addresses }; + } + return { + words: options?.seed, + addresses: options?.preCalculatedAddresses, + }; + } + + private buildConfig( + walletData: { words?: string; addresses?: string[] }, + options?: CreateWalletOptions + ) { + // xpub/xpriv and seed are mutually exclusive in HathorWallet's constructor. + // When both are provided (e.g. shared readonly tests pass seed for service + // pre-registration), prefer xpub/xpriv and omit the seed. + const useSeed = !options?.xpub && !options?.xpriv; + return { + ...(useSeed && walletData.words ? { seed: walletData.words } : {}), + connection: generateConnection(), + // Credentials are intentionally omitted here — they are passed at start() + // time instead. This lets validation tests exercise missing-credential paths + // by calling buildWalletInstance + startWallet without defaults. + ...(options?.password !== undefined && { password: options.password }), + ...(options?.pinCode !== undefined && { pinCode: options.pinCode }), + preCalculatedAddresses: walletData.addresses, + ...(options?.xpub && { xpub: options.xpub }), + ...(options?.xpriv && { xpriv: options.xpriv }), + ...(options?.passphrase && { passphrase: options.passphrase }), + ...(options?.multisig && { multisig: options.multisig }), + ...(options?.tokenUid && { tokenUid: options.tokenUid }), + }; + } +} diff --git a/__tests__/integration/adapters/service.adapter.ts b/__tests__/integration/adapters/service.adapter.ts new file mode 100644 index 000000000..fe83e9243 --- /dev/null +++ b/__tests__/integration/adapters/service.adapter.ts @@ -0,0 +1,203 @@ +/* eslint-disable class-methods-use-this */ +/** + * 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. + */ + +import { HathorWalletServiceWallet } from '../../../src'; +import { WalletTracker } from '../utils/wallet-tracker.util'; +import type Transaction from '../../../src/models/transaction'; +import { + buildWalletInstance, + initializeServiceGlobalConfigs, + pollForTx, +} from '../helpers/service-facade.helper'; +import { GenesisWalletServiceHelper } from '../helpers/genesis-wallet.helper'; +import { precalculationHelpers } from '../helpers/wallet-precalculation.helper'; +import type { WalletStopOptions } from '../../../src/new/types'; +import { NETWORK_NAME } from '../configuration/test-constants'; +import type { + FuzzyWalletType, + IWalletTestAdapter, + WalletCapabilities, + CreateWalletOptions, + CreateWalletResult, +} from './types'; +import type { PrecalculatedWalletData } from '../helpers/wallet-precalculation.helper'; + +const SERVICE_PIN = '123456'; +const SERVICE_PASSWORD = 'testpass'; + +/** Stop options shared between {@link stopWallet} and the {@link WalletTracker}. */ +const STOP_OPTIONS: WalletStopOptions = { cleanStorage: true }; + +/** + * Adapter for the wallet-service facade ({@link HathorWalletServiceWallet}). + * + * Key behavioral differences from the fullnode adapter: + * - `start()` blocks until the wallet is ready (no explicit `waitForReady()` needed). + * - Does not support multisig, token scoping, or external signing. + * - Uses the wallet-service helpers ({@link GenesisWalletServiceHelper}) for fund injection. + */ +export class ServiceWalletTestAdapter implements IWalletTestAdapter { + name = 'Wallet Service'; + + networkName = NETWORK_NAME; + + defaultPinCode = SERVICE_PIN; + + defaultPassword = SERVICE_PASSWORD; + + capabilities: WalletCapabilities = { + supportsMultisig: false, + supportsTokenScope: false, + supportsXpubReadonly: true, + supportsExternalSigning: false, + supportsRuntimeAddressCalculation: false, + supportsPreStartFunding: true, + requiresExplicitWaitReady: false, + stateEventValues: { + loading: 'Loading', + ready: 'Ready', + }, + }; + + private readonly tracker = new WalletTracker(STOP_OPTIONS); + + /** Wallets created with xpub need {@link HathorWalletServiceWallet.startReadOnly} instead of start(). */ + private readonly xpubWallets = new WeakSet(); + + /** + * Narrows a {@link FuzzyWalletType} to the concrete {@link HathorWalletServiceWallet}. + * + * The double-cast (`as unknown as`) is required because {@link IHathorWallet} + * and {@link HathorWalletServiceWallet} are not structurally compatible (see + * type aliases in types.ts). Centralizing it here keeps the rest of the adapter cast-free. + */ + private concrete(wallet: FuzzyWalletType): HathorWalletServiceWallet { + return wallet as unknown as HathorWalletServiceWallet; + } + + async suiteSetup(): Promise { + initializeServiceGlobalConfigs(); + await GenesisWalletServiceHelper.start(); + } + + async suiteTeardown(): Promise { + await this.stopAllWallets(); + await GenesisWalletServiceHelper.stop(); + } + + async createWallet(options?: CreateWalletOptions): Promise { + // The wallet-service backend must know about the wallet before startReadOnly() can + // attach to it. When both seed and xpub are provided, pre-register the wallet by + // starting it with the seed, then stop and restart as a readonly xpub client. + if (options?.xpub && options?.seed) { + const seedWallet = this.buildWalletInstance({ seed: options.seed }); + await this.startWallet(seedWallet.wallet, { + pinCode: options.pinCode ?? SERVICE_PIN, + password: options.password ?? SERVICE_PASSWORD, + }); + await this.stopWallet(seedWallet.wallet); + } + + const built = this.buildWalletInstance(options); + + await this.startWallet(built.wallet, { + pinCode: options?.pinCode ?? SERVICE_PIN, + password: options?.password ?? SERVICE_PASSWORD, + }); + + return built; + } + + buildWalletInstance(options?: CreateWalletOptions): CreateWalletResult { + // xpub and seed are mutually exclusive in the constructor — prefer xpub when present. + const result = buildWalletInstance({ + words: options?.xpub ? '' : options?.seed || '', + xpub: options?.xpub || '', + enableWs: false, + }); + + this.tracker.track(result.wallet); + if (options?.xpub) { + this.xpubWallets.add(result.wallet); + } + + return { + wallet: result.wallet as FuzzyWalletType, + storage: result.storage, + words: result.words, + addresses: result.addresses, + }; + } + + async startWallet( + wallet: FuzzyWalletType, + options?: { pinCode?: string; password?: string } + ): Promise { + const sw = this.concrete(wallet); + + if (this.xpubWallets.has(sw)) { + // Readonly wallets use a dedicated start method that requires no credentials. + await sw.startReadOnly(); + return; + } + + // Pass options through directly — do NOT fill defaults when the caller + // explicitly passes undefined (used by validation tests). + await sw.start({ + pinCode: options?.pinCode, + password: options?.password, + }); + } + + async waitForReady(_wallet: FuzzyWalletType): Promise { + // The service wallet's start() already waits for ready by default (waitReady=true). + // Nothing additional needed. + } + + async stopWallet(wallet: FuzzyWalletType): Promise { + const sw = this.concrete(wallet); + await sw.stop(STOP_OPTIONS); + this.tracker.untrack(sw); + } + + async stopAllWallets(): Promise { + await this.tracker.stopAll(); + } + + async injectFunds( + destWallet: FuzzyWalletType, + address: string, + amount: bigint + ): Promise { + return GenesisWalletServiceHelper.injectFunds(address, amount, this.concrete(destWallet)); + } + + /** + * Sends funds to an address whose wallet has not started yet. + * + * Cannot delegate to {@link injectFunds} because that method passes the + * destination wallet to the helper so it polls for tx confirmation on both + * sides — but the destination wallet isn't running yet, so polling it would + * hang or fail. Omitting the destination wallet makes the helper skip that poll. + */ + async injectFundsBeforeStart(address: string, amount: bigint): Promise { + const fundTx = await GenesisWalletServiceHelper.injectFunds(address, amount); + if (!fundTx?.hash) { + throw new Error('injectFundsBeforeStart: transaction had no hash'); + } + return fundTx.hash; + } + + async waitForTx(wallet: FuzzyWalletType, txId: string): Promise { + await pollForTx(this.concrete(wallet), txId); + } + + getPrecalculatedWallet(): PrecalculatedWalletData { + return precalculationHelpers.test!.getPrecalculatedWallet(); + } +} diff --git a/__tests__/integration/adapters/types.ts b/__tests__/integration/adapters/types.ts new file mode 100644 index 000000000..79df5a702 --- /dev/null +++ b/__tests__/integration/adapters/types.ts @@ -0,0 +1,164 @@ +/* eslint-disable jest/no-export */ +/** + * 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. + */ + +import type { IHathorWallet } from '../../../src/wallet/types'; +import type { PrecalculatedWalletData } from '../helpers/wallet-precalculation.helper'; +import type Transaction from '../../../src/models/transaction'; +import type { IStorage } from '../../../src/types'; +import { HathorWallet, HathorWalletServiceWallet } from '../../../src'; + +/** + * The codebase has three overlapping wallet types: the {@link IHathorWallet} interface + * and two concrete classes ({@link HathorWallet}, {@link HathorWalletServiceWallet}). + * They are not structurally compatible — e.g. `getCurrentAddress()` returns + * `AddressInfoObject` on the concrete classes but `AddressInfoObject | Promise` + * on the interface — so TypeScript cannot safely narrow between them. + * + * - **ConcreteWalletType**: used when the caller needs methods only on the classes + * (e.g. `isReady()`), not on the interface. + * - **FuzzyWalletType**: the adapter boundary type — accepts any of the three so that + * shared tests can pass wallets without knowing which facade is under test. + */ +export type ConcreteWalletType = HathorWallet | HathorWalletServiceWallet; +export type FuzzyWalletType = IHathorWallet | ConcreteWalletType; + +/** + * Options for creating a wallet instance via the adapter. + */ +export interface CreateWalletOptions { + seed?: string; + xpub?: string; + xpriv?: string; + passphrase?: string; + password?: string | null; + pinCode?: string | null; + preCalculatedAddresses?: string[]; + multisig?: { + pubkeys: string[]; + numSignatures: number; + }; + tokenUid?: string; +} + +/** + * Result of building or creating a wallet instance. + */ +export interface CreateWalletResult { + wallet: FuzzyWalletType; + storage: IStorage; + words?: string; + addresses?: string[]; +} + +/** + * Declares which features each facade supports, allowing shared tests + * to skip unsupported scenarios with clear messaging. + */ +export interface WalletCapabilities { + supportsMultisig: boolean; + supportsTokenScope: boolean; + supportsXpubReadonly: boolean; + supportsExternalSigning: boolean; + supportsRuntimeAddressCalculation: boolean; + supportsPreStartFunding: boolean; + requiresExplicitWaitReady: boolean; + stateEventValues: { + loading: string | number; + ready: string | number; + }; +} + +/** + * Adapter interface that abstracts differences between HathorWallet (fullnode) + * and HathorWalletServiceWallet facades for shared integration tests. + * + * Each adapter wraps the existing test helpers for its facade, providing a + * unified API that shared test factories can call without knowing which + * implementation is under test. + */ +export interface IWalletTestAdapter { + /** Human-readable name for describe blocks (e.g. "Fullnode", "Wallet Service") */ + name: string; + + /** Network name the adapter's wallets connect to (e.g. 'testnet') */ + networkName: string; + + /** Feature flags for conditional test execution */ + capabilities: WalletCapabilities; + + /** Default credentials */ + defaultPinCode: string; + defaultPassword: string; + + // --- Lifecycle --- + + /** One-time setup for the test suite (e.g. start genesis wallet, init configs) */ + suiteSetup(): Promise; + + /** One-time teardown for the test suite */ + suiteTeardown(): Promise; + + // --- Wallet creation --- + + /** + * Creates a wallet, starts it, and waits until ready. + * When called without options, uses a precalculated wallet. + */ + createWallet(options?: CreateWalletOptions): Promise; + + /** + * Builds a wallet instance WITHOUT starting it. + * Used by validation tests that need to call start() manually. + */ + buildWalletInstance(options?: CreateWalletOptions): CreateWalletResult; + + /** + * Starts a wallet that was built with buildWalletInstance(). + * Does NOT wait for ready — caller can control that separately. + */ + startWallet( + wallet: FuzzyWalletType, + options?: { pinCode?: string; password?: string } + ): Promise; + + /** + * Waits for a wallet to reach the ready state. + * Some facades handle this internally in start(); others require explicit waiting. + */ + waitForReady(wallet: FuzzyWalletType): Promise; + + /** Stops a single wallet */ + stopWallet(wallet: FuzzyWalletType): Promise; + + /** Stops all wallets started during the current test run */ + stopAllWallets(): Promise; + + // --- Fund injection --- + + /** + * Sends funds from the genesis wallet to a destination wallet's address. + * Waits for both genesis and destination wallets to see the tx. + */ + injectFunds(destWallet: FuzzyWalletType, address: string, amount: bigint): Promise; + + /** + * Injects funds to an address BEFORE the wallet is started. + * Returns the tx hash so the caller can verify it appears in history after start. + */ + injectFundsBeforeStart(address: string, amount: bigint): Promise; + + // --- Tx waiting --- + + /** Waits until a specific tx is visible in the wallet */ + waitForTx(wallet: FuzzyWalletType, txId: string): Promise; + + // --- Precalculated data --- + + /** Returns a fresh precalculated wallet for tests that need one */ + getPrecalculatedWallet(): PrecalculatedWalletData; +} diff --git a/__tests__/integration/fullnode-specific/start.test.ts b/__tests__/integration/fullnode-specific/start.test.ts new file mode 100644 index 000000000..e012c3948 --- /dev/null +++ b/__tests__/integration/fullnode-specific/start.test.ts @@ -0,0 +1,410 @@ +/** + * 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 start() tests. + * + * Defines fullnode-specific tests that rely on {@link HathorWallet}-only features + * (multisig, xpub-readonly, token scoping, external signing, constructor validation). + * + * Shared start() tests live in `shared/start.test.ts` and run via `describe.each`. + */ + +import Mnemonic from 'bitcore-mnemonic/lib/mnemonic'; +import HathorWallet from '../../../src/new/wallet'; +import { NATIVE_TOKEN_UID, P2PKH_ACCT_PATH } from '../../../src/constants'; +import { ConnectionState } from '../../../src/wallet/types'; +import { WalletFromXPubGuard } from '../../../src/errors'; +import { AuthorityType, TokenVersion } from '../../../src/types'; +import Network from '../../../src/models/network'; +import { MemoryStore, Storage } from '../../../src/storage'; +import { WalletTracker } from '../utils/wallet-tracker.util'; +import { deriveXpubFromSeed } from '../utils/core.util'; +import { WALLET_CONSTANTS } from '../configuration/test-constants'; +import { + createTokenHelper, + DEFAULT_PASSWORD, + DEFAULT_PIN_CODE, + generateConnection, + generateWalletHelper, + waitForWalletReady, +} from '../helpers/wallet.helper'; +import { + multisigWalletsData, + precalculationHelpers, +} from '../helpers/wallet-precalculation.helper'; +import { GenesisWalletHelper } from '../helpers/genesis-wallet.helper'; +import WalletConnection from '../../../src/new/connection'; +import { FullnodeWalletTestAdapter } from '../adapters/fullnode.adapter'; + +const fakeTokenUid = '008a19f84f2ae284f19bf3d03386c878ddd15b8b0b604a3a3539aa9d714686e1'; + +const adapter = new FullnodeWalletTestAdapter(); +const tracker = new WalletTracker({ + cleanStorage: true, + cleanAddresses: true, +}); + +// --- Suite lifecycle --- +beforeAll(async () => { + await adapter.suiteSetup(); +}); + +afterAll(async () => { + await adapter.suiteTeardown(); +}); + +// --- Fullnode-specific tests --- +describe('[Fullnode-specific] start', () => { + afterEach(async () => { + await tracker.stopAll(); + await adapter.stopAllWallets(); + }); + + it('should reject with invalid constructor parameters', () => { + const walletData = precalculationHelpers.test!.getPrecalculatedWallet(); + const connection = generateConnection(); + + // No arguments at all + expect(() => new HathorWallet()).toThrow('provide a connection'); + + // Missing connection + expect( + () => + // @ts-expect-error -- The test needs to remove a mandatory property + new HathorWallet({ + seed: walletData.words, + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + }) + ).toThrow('provide a connection'); + + // Missing seed/xpub/xpriv + expect( + () => + new HathorWallet({ + connection, + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + }) + ).toThrow('seed'); + + // Both seed and xpriv + expect( + () => + new HathorWallet({ + seed: walletData.words, + xpriv: 'abc123', + connection, + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + }) + ).toThrow('seed and an xpriv'); + + // xpriv with passphrase + expect( + () => + new HathorWallet({ + xpriv: 'abc123', + connection, + passphrase: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + }) + ).toThrow('xpriv with passphrase'); + + // Already-connected connection + expect( + () => + new HathorWallet({ + seed: walletData.words, + // @ts-expect-error -- Deliberately passing an incomplete mock to test rejection + connection: { + state: ConnectionState.CONNECTED, + getState(): ConnectionState { + return ConnectionState.CONNECTED; + }, + } as Partial, + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + }) + ).toThrow('share connections'); + + // Invalid multisig config (empty) + expect( + () => + new HathorWallet({ + seed: walletData.words, + connection, + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + // @ts-expect-error -- Deliberately passing empty config to test rejection + multisig: {}, + }) + ).toThrow('pubkeys and numSignatures'); + + // Invalid multisig config (numSignatures > pubkeys.length) + expect( + () => + new HathorWallet({ + seed: walletData.words, + connection, + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + multisig: { pubkeys: ['abc'], numSignatures: 2 }, + }) + ).toThrow('configuration invalid'); + }); + + it('should resolve precalculated addresses via getAddressAtIndex', async () => { + const walletData = precalculationHelpers.test!.getPrecalculatedWallet(); + + const hWallet = new HathorWallet({ + seed: walletData.words, + connection: generateConnection(), + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + preCalculatedAddresses: walletData.addresses, + }); + tracker.track(hWallet); + await hWallet.start(); + await waitForWalletReady(hWallet); + + for (const [index, precalcAddress] of walletData.addresses.entries()) { + const addressAtIndex = await hWallet.getAddressAtIndex(index); + expect(addressAtIndex).toEqual(precalcAddress); + } + }); + + it("should calculate the wallet's addresses on start (no precalculated)", async () => { + const walletData = precalculationHelpers.test!.getPrecalculatedWallet(); + + const walletConfig = { + seed: walletData.words, + connection: generateConnection(), + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + // No preCalculatedAddresses — all calculated at runtime + }; + const hWallet = new HathorWallet(walletConfig); + tracker.track(hWallet); + await hWallet.storage.setGapLimit(100); + await hWallet.start(); + await waitForWalletReady(hWallet); + + for (const [index, precalcAddress] of walletData.addresses.entries()) { + const addressAtIndex = await hWallet.getAddressAtIndex(index); + expect(precalcAddress).toEqual(addressAtIndex); + } + }); + + it('should start a multisig wallet', async () => { + const walletConfig = { + seed: multisigWalletsData.words[0], + connection: generateConnection(), + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + multisig: { + pubkeys: multisigWalletsData.pubkeys, + numSignatures: 3, + }, + }; + + const hWallet = new HathorWallet(walletConfig); + tracker.track(hWallet); + await hWallet.storage.setGapLimit(5); + await hWallet.start(); + await waitForWalletReady(hWallet); + + for (let i = 0; i < 5; ++i) { + const precalcAddress = WALLET_CONSTANTS.multisig.addresses[i]; + const addressAtIndex = await hWallet.getAddressAtIndex(i); + expect(precalcAddress).toStrictEqual(addressAtIndex); + } + }); + + it('should start a wallet to manage a specific token', async () => { + const walletData = precalculationHelpers.test!.getPrecalculatedWallet(); + + // Create a wallet and mint a custom token + let hWallet = await generateWalletHelper({ + seed: walletData.words, + preCalculatedAddresses: walletData.addresses, + }); + await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 2n); + const { hash: tokenUid } = await createTokenHelper( + hWallet, + 'Dedicated Wallet Token', + 'DWT', + 100n + ); + + await hWallet.stop({ cleanStorage: true, cleanAddresses: true }); + + // Re-start with tokenUid scope + hWallet = await generateWalletHelper({ + seed: walletData.words, + preCalculatedAddresses: walletData.addresses, + tokenUid, + }); + expect(hWallet.isReady()).toStrictEqual(true); + + // @ts-expect-error -- Passing false instead of string to test legacy behavior + expect(await hWallet.getBalance(false)).toStrictEqual([ + { + token: { + id: tokenUid, + name: 'Dedicated Wallet Token', + symbol: 'DWT', + version: TokenVersion.DEPOSIT, + }, + balance: { unlocked: 100n, locked: 0n }, + transactions: 1, + lockExpires: null, + tokenAuthorities: { + unlocked: { mint: 1n, melt: 1n }, + locked: { mint: 0n, melt: 0n }, + }, + }, + ]); + + const txHistory1 = await hWallet.getTxHistory({ token_id: undefined }); + expect(txHistory1).toStrictEqual([expect.objectContaining({ txId: tokenUid })]); + }); + + it('should generate correct addresses from xpub (readonly)', async () => { + const walletData = precalculationHelpers.test!.getPrecalculatedWallet(); + const xpub = deriveXpubFromSeed(walletData.words); + + const hWallet = await generateWalletHelper({ + xpub, + password: null, + pinCode: null, + }); + + // Fullnode derives addresses locally from xpub — verify all 20 match precalculated. + for (let i = 0; i < 20; ++i) { + expect(await hWallet.getAddressAtIndex(i)).toStrictEqual(walletData.addresses[i]); + } + }); + + it('should reject write operations on a readonly (xpub) wallet', async () => { + const walletData = precalculationHelpers.test!.getPrecalculatedWallet(); + const xpub = deriveXpubFromSeed(walletData.words); + + const hWallet = await generateWalletHelper({ + xpub, + password: null, + pinCode: null, + }); + + // Methods requiring private key should throw WalletFromXPubGuard. + // All calls below deliberately omit required args — the guard rejects before arg validation. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = hWallet as any; + await expect(w.consolidateUtxos()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.sendTransaction()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.sendManyOutputsTransaction()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.prepareCreateNewToken()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.prepareMintTokensData()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.prepareMeltTokensData()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.prepareDelegateAuthorityData()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.prepareDestroyAuthorityData()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.getAllSignatures()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.getSignatures()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.signTx()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.createAndSendNanoContractTransaction()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.createAndSendNanoContractCreateTokenTransaction()).rejects.toThrow( + WalletFromXPubGuard + ); + await expect(w.getPrivateKeyFromAddress()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.createOnChainBlueprintTransaction()).rejects.toThrow(WalletFromXPubGuard); + }); + + it('should start an externally signed wallet', async () => { + const walletData = precalculationHelpers.test!.getPrecalculatedWallet(); + const code = new Mnemonic(walletData.words); + const rootXpriv = code.toHDPrivateKey('', new Network('testnet')); + const xpriv = rootXpriv.deriveNonCompliantChild(P2PKH_ACCT_PATH); + const xpub = xpriv.xpubkey; + + const hWallet = await generateWalletHelper({ + xpub, + password: null, + pinCode: null, + }); + // @ts-expect-error -- Simplified mock: real EcdsaTxSign has a different signature + hWallet.setExternalTxSigningMethod(async () => {}); + expect(hWallet.isReady()).toStrictEqual(true); + await expect(hWallet.isReadonly()).resolves.toBe(false); + hWallet.setExternalTxSigningMethod(null); + await expect(hWallet.isReadonly()).resolves.toBe(true); + }); + + it('should start an externally signed wallet from storage', async () => { + const walletData = precalculationHelpers.test!.getPrecalculatedWallet(); + const code = new Mnemonic(walletData.words); + const rootXpriv = code.toHDPrivateKey('', new Network('testnet')); + const xpriv = rootXpriv.deriveNonCompliantChild(P2PKH_ACCT_PATH); + const xpub = xpriv.xpubkey; + + const store = new MemoryStore(); + const storage = new Storage(store); + // @ts-expect-error -- Simplified mock: real EcdsaTxSign has a different signature + storage.setTxSignatureMethod(async () => {}); + + const hWallet = await generateWalletHelper({ + xpub, + storage, + password: null, + pinCode: null, + }); + expect(hWallet.isReady()).toStrictEqual(true); + await expect(hWallet.isReadonly()).resolves.toBe(false); + hWallet.setExternalTxSigningMethod(null); + await expect(hWallet.isReadonly()).resolves.toBe(true); + }); + + it('should start a wallet without pin (hack test)', async () => { + const walletData = precalculationHelpers.test!.getPrecalculatedWallet(); + const hWallet = await generateWalletHelper({ + seed: walletData.words, + preCalculatedAddresses: walletData.addresses, + pinCode: DEFAULT_PIN_CODE, + }); + + await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n); + + // Manually remove pin to test the no-pin code paths + hWallet.pinCode = null; + + await expect( + hWallet.sendManyOutputsTransaction([ + { address: await hWallet.getAddressAtIndex(1), value: 1n, token: NATIVE_TOKEN_UID }, + ]) + ).rejects.toThrow('Pin'); + + await expect(hWallet.createNewToken('Pinless Token', 'PTT', 100n)).rejects.toThrow('Pin'); + + await expect(hWallet.mintTokens(fakeTokenUid, 100n)).rejects.toThrow('Pin'); + + await expect(hWallet.meltTokens(fakeTokenUid, 100n)).rejects.toThrow('Pin'); + + await expect( + hWallet.delegateAuthority( + fakeTokenUid, + AuthorityType.MINT, + await hWallet.getAddressAtIndex(1) + ) + ).rejects.toThrow('Pin'); + + await expect(hWallet.destroyAuthority(fakeTokenUid, AuthorityType.MINT, 1)).rejects.toThrow( + 'Pin' + ); + + await hWallet.stop({ cleanStorage: true, cleanAddresses: true }); + }); +}); diff --git a/__tests__/integration/hathorwallet_facade.test.ts b/__tests__/integration/hathorwallet_facade.test.ts index 399649303..3013a788f 100644 --- a/__tests__/integration/hathorwallet_facade.test.ts +++ b/__tests__/integration/hathorwallet_facade.test.ts @@ -1,45 +1,34 @@ -import Mnemonic from 'bitcore-mnemonic/lib/mnemonic'; -import { multisigWalletsData, precalculationHelpers } from './helpers/wallet-precalculation.helper'; import { GenesisWalletHelper } from './helpers/genesis-wallet.helper'; import { delay, getRandomInt } from './utils/core.util'; import { createTokenHelper, - DEFAULT_PASSWORD, DEFAULT_PIN_CODE, - generateConnection, generateMultisigWalletHelper, generateWalletHelper, generateWalletHelperRO, stopAllWallets, waitForTxReceived, - waitForWalletReady, waitUntilNextTimestamp, } from './helpers/wallet.helper'; -import HathorWallet from '../../src/new/wallet'; import { NATIVE_TOKEN_UID, TOKEN_MELT_MASK, TOKEN_MINT_MASK, - P2PKH_ACCT_PATH, 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, WalletFromXPubGuard } from '../../src/errors'; +import { NftValidationError, TxNotFoundError } from '../../src/errors'; import SendTransaction from '../../src/new/sendTransaction'; -import { ConnectionState } from '../../src/wallet/types'; import transaction from '../../src/utils/transaction'; -import Network from '../../src/models/network'; import { WalletType, TokenVersion } from '../../src/types'; import { parseScriptData } from '../../src/utils/scripts'; -import { MemoryStore, Storage } from '../../src/storage'; import { TransactionTemplateBuilder } from '../../src/template/transaction'; import FeeHeader from '../../src/headers/fee'; import Header from '../../src/headers/base'; import CreateTokenTransaction from '../../src/models/create_token_transaction'; -import WalletConnection from '../../src/new/connection'; const fakeTokenUid = '008a19f84f2ae284f19bf3d03386c878ddd15b8b0b604a3a3539aa9d714686e1'; const sampleNftData = @@ -312,470 +301,6 @@ describe('getTxById', () => { }); }); -describe('start', () => { - it('should reject with invalid parameters', async () => { - const walletData = precalculationHelpers.test.getPrecalculatedWallet(); - const connection = generateConnection(); - - /* - * Invalid parameters on constructing the object - */ - expect(() => new HathorWallet()).toThrow('provide a connection'); - - expect( - () => - new HathorWallet({ - seed: walletData.words, - password: DEFAULT_PASSWORD, - pinCode: DEFAULT_PIN_CODE, - }) - ).toThrow('provide a connection'); - - expect( - () => - new HathorWallet({ - connection, - password: DEFAULT_PASSWORD, - pinCode: DEFAULT_PIN_CODE, - }) - ).toThrow('seed'); - - expect( - () => - new HathorWallet({ - seed: walletData.words, - xpriv: 'abc123', - connection, - password: DEFAULT_PASSWORD, - pinCode: DEFAULT_PIN_CODE, - }) - ).toThrow('seed and an xpriv'); - - expect( - () => - new HathorWallet({ - xpriv: 'abc123', - connection, - passphrase: DEFAULT_PASSWORD, - pinCode: DEFAULT_PIN_CODE, - }) - ).toThrow('xpriv with passphrase'); - - expect( - () => - new HathorWallet({ - seed: walletData.words, - connection: { - state: ConnectionState.CONNECTED, - getState(): ConnectionState { - return ConnectionState.CONNECTED; - }, - } as Partial, - password: DEFAULT_PASSWORD, - pinCode: DEFAULT_PIN_CODE, - }) - ).toThrow('share connections'); - - expect( - () => - new HathorWallet({ - seed: walletData.words, - connection, - password: DEFAULT_PASSWORD, - pinCode: DEFAULT_PIN_CODE, - multisig: {}, - }) - ).toThrow('pubkeys and numSignatures'); - - expect( - () => - new HathorWallet({ - seed: walletData.words, - connection, - password: DEFAULT_PASSWORD, - pinCode: DEFAULT_PIN_CODE, - multisig: { pubkeys: ['abc'], numSignatures: 2 }, - }) - ).toThrow('configuration invalid'); - - /* - * Invalid parameters on starting the wallet - */ - - // A common wallet without a pin code - let walletConfig = { - seed: walletData.words, - connection, - password: DEFAULT_PASSWORD, - preCalculatedAddresses: walletData.addresses, - }; - let hWallet = new HathorWallet(walletConfig); - await expect(hWallet.start()).rejects.toThrow('Pin'); - - // A common wallet without password - walletConfig = { - seed: walletData.words, - connection, - pinCode: DEFAULT_PIN_CODE, - preCalculatedAddresses: walletData.addresses, - }; - hWallet = new HathorWallet(walletConfig); - await expect(hWallet.start()).rejects.toThrow('Password'); - }); - - it('should start a wallet with no history', async () => { - const walletData = precalculationHelpers.test.getPrecalculatedWallet(); - - // Start the wallet - const walletConfig = { - seed: walletData.words, - connection: generateConnection(), - password: DEFAULT_PASSWORD, - pinCode: DEFAULT_PIN_CODE, - preCalculatedAddresses: walletData.addresses, - }; - const hWallet = new HathorWallet(walletConfig); - await hWallet.start(); - - // Validating that the wallet detects it's not ready - expect(hWallet.isReady()).toStrictEqual(false); - await waitForWalletReady(hWallet); - expect(hWallet.isReady()).toStrictEqual(true); - - // Validate that it has no transactions - const txHistory = await hWallet.getTxHistory(); - expect(txHistory).toHaveLength(0); - - // Validate that the addresses are the same as the pre-calculated that were informed - for (const [addressIndex, precalcAddress] of walletData.addresses.entries()) { - // const precalcAddress = walletData.addresses[+addressIndex]; - const addressAtIndex = await hWallet.getAddressAtIndex(+addressIndex); - expect(precalcAddress).toEqual(addressAtIndex); - } - await hWallet.stop({ cleanStorage: true, cleanAddresses: true }); - }); - - it('should start a wallet with a transaction history', async () => { - // Send a transaction to one of the wallet's addresses - const walletData = precalculationHelpers.test.getPrecalculatedWallet(); - - // We are not using the injectFunds helper method here because - // we want to send this transaction before the wallet is started - // then we don't have the wallet object, which is an expected parameter - // for the injectFunds method now - // Since we start and load the wallet after the transaction is sent to the full node - // we don't need to worry for it to be received in the websocket - const injectAddress = walletData.addresses[0]; - const injectValue = BigInt(getRandomInt(10, 1)); - const { hWallet: gWallet } = await GenesisWalletHelper.getSingleton(); - const injectionTx = await gWallet.sendTransaction(injectAddress, injectValue); - - // Start the wallet - const walletConfig = { - seed: walletData.words, - connection: generateConnection(), - password: DEFAULT_PASSWORD, - pinCode: DEFAULT_PIN_CODE, - preCalculatedAddresses: walletData.addresses, - }; - const hWallet = new HathorWallet(walletConfig); - await hWallet.start(); - await waitForWalletReady(hWallet); - - // Validate that it has transactions - const txHistory = await hWallet.getTxHistory(); - expect(txHistory).toHaveLength(1); - expect(txHistory[0].txId).toEqual(injectionTx.hash); - await hWallet.stop({ cleanStorage: true, cleanAddresses: true }); - }); - - it("should calculate the wallet's addresses on start", async () => { - const walletData = precalculationHelpers.test.getPrecalculatedWallet(); - - // Start the wallet - const walletConfig = { - seed: walletData.words, - connection: generateConnection(), - password: DEFAULT_PASSWORD, - pinCode: DEFAULT_PIN_CODE, - /* - * No precalculated addresses here. All will be calculated at runtime. - * This operation takes a lot longer under jest's testing framework, so we avoid it - * on most tests. - */ - }; - const hWallet = new HathorWallet(walletConfig); - await hWallet.storage.setGapLimit(100); // load more addresses than preCalculated - await hWallet.start(); - await waitForWalletReady(hWallet); - - // Validate that the addresses are the same as the pre-calculated ones - for (const addressIndex in walletData.addresses) { - const precalcAddress = walletData.addresses[+addressIndex]; - const addressAtIndex = await hWallet.getAddressAtIndex(+addressIndex); - expect(precalcAddress).toEqual(addressAtIndex); - } - await hWallet.stop({ cleanStorage: true, cleanAddresses: true }); - }); - - it('should start a multisig wallet', async () => { - // Start the wallet without precalculated addresses - const walletConfig = { - seed: multisigWalletsData.words[0], - connection: generateConnection(), - password: DEFAULT_PASSWORD, - pinCode: DEFAULT_PIN_CODE, - multisig: { - pubkeys: multisigWalletsData.pubkeys, - numSignatures: 3, - }, - }; - - const hWallet = new HathorWallet(walletConfig); - /* - * The interaction between the jest infrastructure with the address derivation calculations - * somehow make this process very costly and slow, especially for multisig. - * Here we lower the gap limit to make this test shorter. - */ - await hWallet.storage.setGapLimit(5); - await hWallet.start(); - - // Validating that all the booting processes worked - await waitForWalletReady(hWallet); - - // Validate that the addresses are the same as the pre-calculated that we have - for (let i = 0; i < 5; ++i) { - const precalcAddress = WALLET_CONSTANTS.multisig.addresses[i]; - const addressAtIndex = await hWallet.getAddressAtIndex(i); - expect(precalcAddress).toStrictEqual(addressAtIndex); - } - - await hWallet.stop({ cleanStorage: true, cleanAddresses: true }); - }); - - it('should start a wallet to manage a specific token', async () => { - const walletData = precalculationHelpers.test.getPrecalculatedWallet(); - - // Creating a new wallet with a known set of words just to generate the custom token - let hWallet = await generateWalletHelper({ - seed: walletData.words, - preCalculatedAddresses: walletData.addresses, - }); - await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 2n); - const { hash: tokenUid } = await createTokenHelper( - hWallet, - 'Dedicated Wallet Token', - 'DWT', - 100n - ); - - await delay(1000); - // Stopping this wallet and destroying its memory state - await hWallet.stop({ cleanStorage: true, cleanAddresses: true }); - hWallet = null; - - // Starting a new wallet re-using the same words, this time with a specific wallet token - hWallet = await generateWalletHelper({ - seed: walletData.words, - preCalculatedAddresses: walletData.addresses, - tokenUid, - }); - expect(hWallet.isReady()).toStrictEqual(true); // This operation should work - - // Now testing the methods that use this set tokenUid information - // FIXME: No need to explicitly pass the non-boolean `false` as a tokenUid to get this result. - expect(await hWallet.getBalance(false)).toStrictEqual([ - { - token: { - id: tokenUid, - name: 'Dedicated Wallet Token', - symbol: 'DWT', - version: TokenVersion.DEPOSIT, - }, - balance: { - unlocked: 100n, - locked: 0n, - }, - transactions: 1, - lockExpires: null, - tokenAuthorities: { - unlocked: { - mint: 1n, - melt: 1n, - }, - locked: { - mint: 0n, - melt: 0n, - }, - }, - }, - ]); - - // FIXME: We should not have to explicitly pass an empty token uid to get this result - const txHistory1 = await hWallet.getTxHistory({ token_id: undefined }); - expect(txHistory1).toStrictEqual([ - expect.objectContaining({ - txId: tokenUid, - }), - ]); - - /* - * These tests could be created inside the `getBalance` and `getTxHistory` sections but for - * simplicity sake, since they are so small, were added here just as a complement to - * this `start` test. - */ - }); - - it('should start a wallet via xpub', async () => { - const walletData = precalculationHelpers.test.getPrecalculatedWallet(); - const code = new Mnemonic(walletData.words); - const rootXpriv = code.toHDPrivateKey('', new Network('testnet')); - const xpriv = rootXpriv.deriveNonCompliantChild(P2PKH_ACCT_PATH); - const xpub = xpriv.xpubkey; - - // Creating a new wallet with a known set of words just to generate the custom token - const hWallet = await generateWalletHelper({ - xpub, - password: null, - pinCode: null, - }); - expect(hWallet.isReady()).toStrictEqual(true); - await expect(hWallet.isReadonly()).resolves.toBe(true); - - // Validating that methods that require the private key will throw on call - await expect(hWallet.consolidateUtxos()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.sendTransaction()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.sendManyOutputsTransaction()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.prepareCreateNewToken()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.prepareMintTokensData()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.prepareMeltTokensData()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.prepareDelegateAuthorityData()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.prepareDestroyAuthorityData()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.getAllSignatures()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.getSignatures()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.signTx()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.createAndSendNanoContractTransaction()).rejects.toThrow( - WalletFromXPubGuard - ); - await expect(hWallet.createAndSendNanoContractCreateTokenTransaction()).rejects.toThrow( - WalletFromXPubGuard - ); - await expect(hWallet.getPrivateKeyFromAddress()).rejects.toThrow(WalletFromXPubGuard); - await expect(hWallet.createOnChainBlueprintTransaction()).rejects.toThrow(WalletFromXPubGuard); - - // Validating that the address generation works as intended - for (let i = 0; i < 20; ++i) { - expect(await hWallet.getAddressAtIndex(i)).toStrictEqual(walletData.addresses[i]); - } - - // Validating balance and utxo methods - await expect(hWallet.getBalance(NATIVE_TOKEN_UID)).resolves.toStrictEqual([ - expect.objectContaining({ - token: expect.objectContaining({ id: NATIVE_TOKEN_UID }), - balance: { unlocked: 0n, locked: 0n }, - transactions: 0, - }), - ]); - await expect(hWallet.getUtxos()).resolves.toHaveProperty('total_utxos_available', 0n); - - // Generating a transaction and validating it shows correctly - await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(1), 1n); - - await expect(hWallet.getBalance(NATIVE_TOKEN_UID)).resolves.toMatchObject([ - expect.objectContaining({ - token: expect.objectContaining({ id: NATIVE_TOKEN_UID }), - balance: { unlocked: 1n, locked: 0n }, - transactions: expect.any(Number), - }), - ]); - await expect(hWallet.getUtxos()).resolves.toHaveProperty('total_utxos_available', 1n); - }); - - it('should start an externally signed wallet', async () => { - const walletData = precalculationHelpers.test.getPrecalculatedWallet(); - const code = new Mnemonic(walletData.words); - const rootXpriv = code.toHDPrivateKey('', new Network('privatenet')); - const xpriv = rootXpriv.deriveNonCompliantChild(P2PKH_ACCT_PATH); - const xpub = xpriv.xpubkey; - - // Creating a new wallet with a known set of words just to generate the custom token - const hWallet = await generateWalletHelper({ - xpub, - password: null, - pinCode: null, - }); - hWallet.setExternalTxSigningMethod(async () => {}); - expect(hWallet.isReady()).toStrictEqual(true); - await expect(hWallet.isReadonly()).resolves.toBe(false); - hWallet.setExternalTxSigningMethod(null); - await expect(hWallet.isReadonly()).resolves.toBe(true); - }); - - it('should start an externally signed wallet from storage', async () => { - const walletData = precalculationHelpers.test.getPrecalculatedWallet(); - const code = new Mnemonic(walletData.words); - const rootXpriv = code.toHDPrivateKey('', new Network('privatenet')); - const xpriv = rootXpriv.deriveNonCompliantChild(P2PKH_ACCT_PATH); - const xpub = xpriv.xpubkey; - - const store = new MemoryStore(); - const storage = new Storage(store); - storage.setTxSignatureMethod(async () => {}); - // Creating a new wallet with a known set of words just to generate the custom token - const hWallet = await generateWalletHelper({ - xpub, - storage, - password: null, - pinCode: null, - }); - expect(hWallet.isReady()).toStrictEqual(true); - await expect(hWallet.isReadonly()).resolves.toBe(false); - hWallet.setExternalTxSigningMethod(null); - await expect(hWallet.isReadonly()).resolves.toBe(true); - }); - - it('should start a wallet without pin', async () => { - // Generating the wallet - const walletData = precalculationHelpers.test.getPrecalculatedWallet(); - const hWallet = await generateWalletHelper({ - seed: walletData.words, - preCalculatedAddresses: walletData.addresses, - pinCode: DEFAULT_PIN_CODE, - }); - - // Adding funds to it - await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n); - - /* - * XXX: The code branches that require a PIN would not be achievable without this hack that - * manually removes the pin from the wallet. - * In order to increase the test coverage we will add this procedure here - */ - hWallet.pinCode = null; - - // XXX: This is the only method that resolves instead of rejects. Check the standard here. - await expect( - hWallet.sendManyOutputsTransaction([ - { address: await hWallet.getAddressAtIndex(1), value: 1 }, - ]) - ).rejects.toThrow('Pin'); - - await expect(hWallet.createNewToken('Pinless Token', 'PTT', 100)).rejects.toThrow('Pin'); - - await expect(hWallet.mintTokens(fakeTokenUid, 100n)).rejects.toThrow('Pin'); - - await expect(hWallet.meltTokens(fakeTokenUid, 100n)).rejects.toThrow('Pin'); - - await expect( - hWallet.delegateAuthority(fakeTokenUid, 'mint', await hWallet.getAddressAtIndex(1)) - ).rejects.toThrow('Pin'); - - await expect(hWallet.destroyAuthority(fakeTokenUid, 'mint', 1)).rejects.toThrow('Pin'); - - await hWallet.stop({ cleanStorage: true, cleanAddresses: true }); - }); -}); - describe('addresses methods', () => { afterEach(async () => { await stopAllWallets(); diff --git a/__tests__/integration/helpers/service-facade.helper.ts b/__tests__/integration/helpers/service-facade.helper.ts index aa9ff6b72..3dcc6d7b0 100644 --- a/__tests__/integration/helpers/service-facade.helper.ts +++ b/__tests__/integration/helpers/service-facade.helper.ts @@ -39,22 +39,27 @@ export function initializeServiceGlobalConfigs() { } /** - * Builds a HathorWalletServiceWallet instance with a wallet seed words - * If no words are provided, it will use a precalculated wallet for faster tests and return its addresses. + * Builds a HathorWalletServiceWallet instance. + * + * Accepts either `words` (seed) or `xpub` — they are mutually exclusive. + * If neither is provided, a precalculated wallet is used for faster tests. + * * @param enableWs - Whether to enable websocket connection (default: false) * @param words - The 24 words to use for the wallet (default: random wallet) + * @param xpub - An xpub key for creating a readonly wallet (mutually exclusive with words) * @param passwordForRequests - The password that will be returned by the mocked requestPassword function (default: 'test-password') * @returns The wallet instance along with its store and storage for eventual mocking/spying */ export function buildWalletInstance({ enableWs = false, words = '', + xpub = '', passwordForRequests = 'test-password', } = {}) { let addresses: string[] = []; - // If no words are provided, use an empty precalculated wallet - if (!words) { + // If neither identity is provided, use a precalculated wallet + if (!words && !xpub) { if (!precalculationHelpers.test) { throw new Error('Precalculation helper not initialized'); } @@ -64,19 +69,19 @@ export function buildWalletInstance({ addresses = preFetchedWallet.addresses; } - // Builds the wallet parameters - const walletData = { words }; const network = new Network(NETWORK_NAME); const requestPassword = jest.fn().mockResolvedValue(passwordForRequests); const store = new MemoryStore(); const storage = new Storage(store); + + // xpub and seed are mutually exclusive in the constructor const newWallet = new HathorWalletServiceWallet({ requestPassword, - seed: walletData.words, + ...(xpub ? { xpub } : { seed: words }), network, storage, - enableWs, // Disable websocket for integration tests by default + enableWs, }); return { wallet: newWallet, store, storage, words, addresses }; diff --git a/__tests__/integration/service-specific/start.test.ts b/__tests__/integration/service-specific/start.test.ts new file mode 100644 index 000000000..4e7744426 --- /dev/null +++ b/__tests__/integration/service-specific/start.test.ts @@ -0,0 +1,145 @@ +/** + * 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. + */ + +/** + * Service-facade start() tests. + * + * Defines service-specific tests that rely on {@link HathorWalletServiceWallet}-only + * APIs (e.g. `isWsEnabled()`) or use mocks that don't belong in integration-level + * shared tests. + * + * Shared start() tests live in `shared/start.test.ts` and run via `describe.each`. + */ + +import Mnemonic from 'bitcore-mnemonic'; +import { HathorWalletServiceWallet, Storage } from '../../../src'; +import { WALLET_SERVICE_AUTH_DERIVATION_PATH } from '../../../src/constants'; +import { WalletFromXPubGuard } from '../../../src/errors'; +import { decryptData } from '../../../src/utils/crypto'; +import walletUtils from '../../../src/utils/wallet'; +import Network from '../../../src/models/network'; +import { NETWORK_NAME } from '../configuration/test-constants'; +import { buildWalletInstance, emptyWallet } from '../helpers/service-facade.helper'; +import { ServiceWalletTestAdapter } from '../adapters/service.adapter'; +import { deriveXpubFromSeed } from '../utils/core.util'; +import { loggers } from '../utils/logger.util'; + +const pinCode = '123456'; +const password = 'testpass'; + +const adapter = new ServiceWalletTestAdapter(); + +// --- Suite lifecycle --- +beforeAll(async () => { + await adapter.suiteSetup(); +}); + +afterAll(async () => { + await adapter.suiteTeardown(); +}); + +// --- Service-specific tests --- +describe('[Service-specific] start', () => { + let wallet: HathorWalletServiceWallet; + + afterEach(async () => { + if (wallet) { + try { + await wallet.stop({ cleanStorage: true }); + } catch (e) { + loggers.test!.warn('Failed to stop wallet during cleanup', { + error: (e as Error).message, + }); + } + } + }); + + it('should have websocket disabled by default in tests', async () => { + ({ wallet } = buildWalletInstance({ words: emptyWallet.words })); + await wallet.start({ pinCode, password }); + expect(wallet.isWsEnabled()).toBe(false); + }); + + // TODO: Move mock-based tests to unit tests + it('should handle getAccessData unexpected errors', async () => { + let storage: Storage; + ({ wallet, storage } = buildWalletInstance({ words: emptyWallet.words })); + + // Exercise the event-emission path during a failed start + const events: string[] = []; + wallet.on('state', (state: string) => { + events.push(`state:${state}`); + }); + + jest.spyOn(storage, 'getAccessData').mockRejectedValueOnce(new Error('Crash')); + + await expect(() => wallet.start({ pinCode, password })).rejects.toThrow('Crash'); + + expect(wallet.isReady()).toBe(false); + }); + + // TODO: Move mock-based tests to unit tests + it('should create wallet with xpriv', async () => { + let storage: Storage; + ({ wallet, storage } = buildWalletInstance({ words: emptyWallet.words })); + + const seed = emptyWallet.words; + const accessData = walletUtils.generateAccessDataFromSeed(seed, { + networkName: 'testnet', + password: '1234', + pin: '1234', + }); + + const code = new Mnemonic(seed); + const xpriv = code.toHDPrivateKey('', new Network('testnet')); + const authxpriv = xpriv.deriveChild(WALLET_SERVICE_AUTH_DERIVATION_PATH).xprivkey; + // '1234' is the pin/password used to encrypt the access data above (generateAccessDataFromSeed), + // not the wallet start credentials (pinCode/password defined in outer scope). + expect(accessData.acctPathKey).toBeDefined(); + const acctKey = decryptData(accessData.acctPathKey!, '1234'); + + const network = new Network(NETWORK_NAME); + const requestPassword = jest.fn().mockResolvedValue('test-password'); + wallet = new HathorWalletServiceWallet({ + requestPassword, + xpriv: acctKey, + authxpriv, + network, + storage, + enableWs: false, + }); + + await wallet.start({ pinCode, password }); + + expect(wallet.isReady()).toBe(true); + + const currentAddress = wallet.getCurrentAddress(); + expect(currentAddress.index).toBeDefined(); + expect(currentAddress.address).toEqual(emptyWallet.addresses[currentAddress.index]); + }); + + it('should reject write operations on a readonly (xpub) wallet', async () => { + const walletData = adapter.getPrecalculatedWallet(); + const xpub = deriveXpubFromSeed(walletData.words); + + const { wallet: xpubWallet } = await adapter.createWallet({ + seed: walletData.words, + xpub, + }); + wallet = xpubWallet as unknown as HathorWalletServiceWallet; + + // Methods requiring private key should throw WalletFromXPubGuard. + // All calls below deliberately omit required args — the guard rejects before arg validation. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = wallet as any; + await expect(w.sendManyOutputsSendTransaction()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.getPrivateKeyFromAddress()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.signTx()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.createNanoContractTransaction()).rejects.toThrow(WalletFromXPubGuard); + await expect(w.createNanoContractCreateTokenTransaction()).rejects.toThrow(WalletFromXPubGuard); + }); +}); diff --git a/__tests__/integration/shared/start.test.ts b/__tests__/integration/shared/start.test.ts new file mode 100644 index 000000000..8b641beb6 --- /dev/null +++ b/__tests__/integration/shared/start.test.ts @@ -0,0 +1,293 @@ +/* eslint-disable jest/no-conditional-expect -- Adapter validations must be conditional */ +/** + * 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. + */ + +import type { EventEmitter } from 'events'; +import type { AddressInfoObject } from '../../../src/wallet/types'; +import type { ConcreteWalletType, FuzzyWalletType, IWalletTestAdapter } from '../adapters/types'; +import { NATIVE_TOKEN_UID } from '../../../src/constants'; +import { deriveXpubFromSeed, getRandomInt } from '../utils/core.util'; +import { loggers } from '../utils/logger.util'; +import { FullnodeWalletTestAdapter } from '../adapters/fullnode.adapter'; +import { ServiceWalletTestAdapter } from '../adapters/service.adapter'; + +const adapters: IWalletTestAdapter[] = [ + new FullnodeWalletTestAdapter(), + new ServiceWalletTestAdapter(), +]; + +describe.each(adapters)('[Shared] start — $name', adapter => { + beforeAll(async () => { + await adapter.suiteSetup(); + }); + + afterAll(async () => { + await adapter.suiteTeardown(); + }); + + // --- Validation tests --- + + describe('mandatory parameter validation', () => { + let wallet: FuzzyWalletType; + + afterEach(async () => { + if (wallet) { + try { + await adapter.stopWallet(wallet); + } catch (e) { + loggers.test!.warn('Failed to stop wallet during cleanup', { + error: e instanceof Error ? e.message : String(e), + }); + } + } + }); + + it('should reject when pinCode is not provided', async () => { + const built = adapter.buildWalletInstance(); + wallet = built.wallet; + + // Both facades throw an error mentioning "pin" (case-insensitive) + await expect( + adapter.startWallet(wallet, { + pinCode: undefined, + password: adapter.defaultPassword, + }) + ).rejects.toThrow(/pin/i); + }); + + it('should reject when password is not provided for seed wallet', async () => { + const built = adapter.buildWalletInstance(); + wallet = built.wallet; + + // Both facades throw an error mentioning "password" (case-insensitive) + await expect( + adapter.startWallet(wallet, { + pinCode: adapter.defaultPinCode, + password: undefined, + }) + ).rejects.toThrow(/password/i); + }); + }); + + // --- Successful start tests --- + + describe('successful start', () => { + it('should start a wallet with no history', async () => { + const walletData = adapter.getPrecalculatedWallet(); + const built = adapter.buildWalletInstance({ + seed: walletData.words, + preCalculatedAddresses: walletData.addresses, + }); + const { wallet } = built; + + try { + await adapter.startWallet(wallet, { + pinCode: adapter.defaultPinCode, + password: adapter.defaultPassword, + }); + + // Fullnode's start() is non-blocking, so the wallet should still be + // in a non-ready state here. Service's start() blocks until ready, + // so this branch is skipped — testing false→true would be a race. + if (adapter.capabilities.requiresExplicitWaitReady) { + expect((wallet as ConcreteWalletType).isReady()).toBe(false); + await adapter.waitForReady(wallet); + } + + expect((wallet as ConcreteWalletType).isReady()).toBe(true); + + // Verify correct network + expect(wallet.getNetwork()).toBe(adapter.networkName); + + // Fullnode's getCurrentAddress() is async, service's is sync. + // Awaiting works for both (await on a non-Promise is a no-op). + const currentAddress = (await wallet.getCurrentAddress()) as AddressInfoObject; + expect(currentAddress.index).toBeDefined(); + expect(currentAddress.address).toEqual(walletData.addresses[currentAddress.index]); + + // Verify empty history + const txHistory = await wallet.getTxHistory({}); + expect(txHistory).toHaveLength(0); + } finally { + await adapter.stopWallet(wallet); + } + }); + + it('should start with a transaction history', async () => { + const walletData = adapter.getPrecalculatedWallet(); + const injectAddress = walletData.addresses[0]; + const injectValue = BigInt(getRandomInt(10, 1)); + + // Inject funds BEFORE the wallet starts + const txHash = await adapter.injectFundsBeforeStart(injectAddress, injectValue); + + const built = adapter.buildWalletInstance({ + seed: walletData.words, + preCalculatedAddresses: walletData.addresses, + }); + const { wallet } = built; + + try { + await adapter.startWallet(wallet, { + pinCode: adapter.defaultPinCode, + password: adapter.defaultPassword, + }); + + // Same false→true transition check as "no history" test (see comment there) + if (adapter.capabilities.requiresExplicitWaitReady) { + expect((wallet as ConcreteWalletType).isReady()).toBe(false); + await adapter.waitForReady(wallet); + } + + expect((wallet as ConcreteWalletType).isReady()).toBe(true); + + // Verify the injected tx appears in history + const txHistory = await wallet.getTxHistory({}); + expect(txHistory).toHaveLength(1); + expect(txHistory[0].txId).toEqual(txHash); + } finally { + await adapter.stopWallet(wallet); + } + }); + + it('should emit state events during startup', async () => { + const { loading, ready } = adapter.capabilities.stateEventValues; + const events: Array = []; + const built = adapter.buildWalletInstance(); + const { wallet } = built; + + // Attach state listener before start + (wallet as unknown as EventEmitter).on('state', (state: string | number) => { + events.push(state); + }); + + try { + await adapter.startWallet(wallet, { + pinCode: adapter.defaultPinCode, + password: adapter.defaultPassword, + }); + + if (adapter.capabilities.requiresExplicitWaitReady) { + await adapter.waitForReady(wallet); + } + + // Both facades should emit a loading-like state followed by a ready state + expect(events).toContain(loading); + expect(events).toContain(ready); + + // Loading must come before ready + const loadingIdx = events.indexOf(loading); + const readyIdx = events.indexOf(ready); + expect(loadingIdx).toBeLessThan(readyIdx); + } finally { + await adapter.stopWallet(wallet); + } + }); + }); + + // --- Readonly (xpub) tests --- + + // eslint-disable-next-line jest/valid-title -- conditional gating via capability flag + const readonlyDescribe = adapter.capabilities.supportsXpubReadonly ? describe : describe.skip; + + readonlyDescribe('readonly wallet (xpub)', () => { + it('should start an xpub wallet in readonly mode', async () => { + const walletData = adapter.getPrecalculatedWallet(); + const xpub = deriveXpubFromSeed(walletData.words); + + // Pass seed alongside xpub so adapters that require backend pre-registration + // (e.g. wallet-service) can create the wallet before starting in readonly mode. + const { wallet, storage } = await adapter.createWallet({ + seed: walletData.words, + xpub, + preCalculatedAddresses: walletData.addresses, + }); + + try { + expect((wallet as ConcreteWalletType).isReady()).toBe(true); + await expect(storage.isReadonly()).resolves.toBe(true); + } finally { + await adapter.stopWallet(wallet); + } + }); + + it('should report zero balance on a fresh readonly wallet', async () => { + const walletData = adapter.getPrecalculatedWallet(); + const xpub = deriveXpubFromSeed(walletData.words); + + const { wallet } = await adapter.createWallet({ + seed: walletData.words, + xpub, + preCalculatedAddresses: walletData.addresses, + }); + + try { + await expect(wallet.getBalance(NATIVE_TOKEN_UID)).resolves.toStrictEqual([ + expect.objectContaining({ + token: expect.objectContaining({ id: NATIVE_TOKEN_UID }), + balance: { unlocked: 0n, locked: 0n }, + transactions: 0, + }), + ]); + await expect(wallet.getUtxos()).resolves.toHaveProperty('total_utxos_available', 0n); + } finally { + await adapter.stopWallet(wallet); + } + }); + + it('should reflect injected funds in balance', async () => { + const walletData = adapter.getPrecalculatedWallet(); + const xpub = deriveXpubFromSeed(walletData.words); + + const { wallet } = await adapter.createWallet({ + seed: walletData.words, + xpub, + preCalculatedAddresses: walletData.addresses, + }); + + try { + const addr = await wallet.getAddressAtIndex(1); + expect(addr).toBeDefined(); + await adapter.injectFunds(wallet, addr!, 1n); + + await expect(wallet.getBalance(NATIVE_TOKEN_UID)).resolves.toMatchObject([ + expect.objectContaining({ + token: expect.objectContaining({ id: NATIVE_TOKEN_UID }), + balance: { unlocked: 1n, locked: 0n }, + transactions: expect.any(Number), + }), + ]); + await expect(wallet.getUtxos()).resolves.toHaveProperty('total_utxos_available', 1n); + } finally { + await adapter.stopWallet(wallet); + } + }); + }); + + // --- Stop lifecycle tests --- + + describe('stop', () => { + it('should not be ready after stop', async () => { + const { wallet } = await adapter.createWallet(); + expect((wallet as ConcreteWalletType).isReady()).toBe(true); + + await adapter.stopWallet(wallet); + expect((wallet as ConcreteWalletType).isReady()).toBe(false); + }); + + it('should tolerate stopping a wallet that was never started', async () => { + const { wallet } = adapter.buildWalletInstance(); + await expect(adapter.stopWallet(wallet)).resolves.not.toThrow(); + }); + + it('should tolerate being stopped twice', async () => { + const { wallet } = await adapter.createWallet(); + await adapter.stopWallet(wallet); + await expect(adapter.stopWallet(wallet)).resolves.not.toThrow(); + }); + }); +}); diff --git a/__tests__/integration/utils/core.util.ts b/__tests__/integration/utils/core.util.ts index 8c5836930..e70971d74 100644 --- a/__tests__/integration/utils/core.util.ts +++ b/__tests__/integration/utils/core.util.ts @@ -5,6 +5,10 @@ * LICENSE file in the root directory of this source tree. */ +import Mnemonic from 'bitcore-mnemonic/lib/mnemonic'; +import { P2PKH_ACCT_PATH } from '../../../src/constants'; +import Network from '../../../src/models/network'; + /** * Simple way to wait asynchronously before continuing the funcion. Does not block the JS thread. * @param ms Amount of milliseconds to delay @@ -24,3 +28,10 @@ export function getRandomInt(max: number, min: number = 0): number { const _max = Math.floor(max); return Math.floor(Math.random() * (_max - _min + 1)) + _min; } + +/** Derives the account-level xpub from a mnemonic seed phrase. */ +export function deriveXpubFromSeed(words: string): string { + const code = new Mnemonic(words); + const rootXpriv = code.toHDPrivateKey('', new Network('testnet')); + return rootXpriv.deriveNonCompliantChild(P2PKH_ACCT_PATH).xpubkey; +} diff --git a/__tests__/integration/utils/wallet-tracker.util.ts b/__tests__/integration/utils/wallet-tracker.util.ts new file mode 100644 index 000000000..bcf956c5f --- /dev/null +++ b/__tests__/integration/utils/wallet-tracker.util.ts @@ -0,0 +1,51 @@ +/** + * 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. + */ + +import type { WalletStopOptions } from '../../../src/new/types'; +import { loggers } from './logger.util'; + +interface Stoppable { + stop(options?: WalletStopOptions): Promise; +} + +/** + * Tracks wallet instances and guarantees cleanup via {@link stopAll}. + * + * Both test adapters and standalone tests can use this to avoid leaking + * wallets when an assertion fails before an explicit `stop()` call. + */ +export class WalletTracker { + private wallets: T[] = []; + + private readonly stopOptions: WalletStopOptions; + + constructor(stopOptions: WalletStopOptions = { cleanStorage: true }) { + this.stopOptions = stopOptions; + } + + track(wallet: T): void { + this.wallets.push(wallet); + } + + untrack(wallet: T): void { + this.wallets = this.wallets.filter(w => w !== wallet); + } + + async stopAll(): Promise { + let wallet = this.wallets.pop(); + while (wallet) { + try { + await wallet.stop(this.stopOptions); + } catch (e) { + loggers.test!.warn('Failed to stop wallet during cleanup', { + error: e instanceof Error ? e.message : String(e), + }); + } + wallet = this.wallets.pop(); + } + } +} diff --git a/__tests__/integration/walletservice_facade.test.ts b/__tests__/integration/walletservice_facade.test.ts index 839abe856..95c9a827a 100644 --- a/__tests__/integration/walletservice_facade.test.ts +++ b/__tests__/integration/walletservice_facade.test.ts @@ -1,24 +1,16 @@ import axios from 'axios'; -import Mnemonic from 'bitcore-mnemonic'; import config from '../../src/config'; import { loggers } from './utils/logger.util'; import HathorWalletServiceWallet from '../../src/wallet/wallet'; -import Network from '../../src/models/network'; -import { CreateTokenTransaction, FeeHeader, Output, Storage, transactionUtils } from '../../src'; +import { CreateTokenTransaction, FeeHeader, Output, transactionUtils } from '../../src'; import { FULLNODE_NETWORK_NAME, FULLNODE_URL, NETWORK_NAME, WALLET_CONSTANTS, } from './configuration/test-constants'; -import { - NATIVE_TOKEN_UID, - TOKEN_MELT_MASK, - TOKEN_MINT_MASK, - WALLET_SERVICE_AUTH_DERIVATION_PATH, -} from '../../src/constants'; -import { decryptData } from '../../src/utils/crypto'; -import walletUtils from '../../src/utils/wallet'; +import { NATIVE_TOKEN_UID, TOKEN_MELT_MASK, TOKEN_MINT_MASK } from '../../src/constants'; +import Network from '../../src/models/network'; import { buildWalletInstance, emptyWallet, @@ -170,151 +162,6 @@ afterAll(async () => { await GenesisWalletServiceHelper.stop(); }); -describe('start', () => { - describe('mandatory parameters validation', () => { - beforeEach(() => { - ({ wallet } = buildWalletInstance({ words: emptyWallet.words })); - }); - - afterEach(async () => { - if (wallet) { - await wallet.stop({ cleanStorage: true }); - } - }); - - it('should throw error when pinCode is not provided', async () => { - await expect(wallet.start({})).rejects.toThrow( - 'Pin code is required when starting the wallet.' - ); - }); - - it('should throw error when password is not provided for new wallet from seed', async () => { - await expect(wallet.start({ pinCode })).rejects.toThrow( - 'Password is required when starting the wallet from the seed.' - ); - }); - }); - - describe('handling internal errors', () => { - const events: string[] = []; - let storage: Storage; - - beforeEach(() => { - ({ wallet, storage } = buildWalletInstance({ words: emptyWallet.words })); - - // Clear events array - events.length = 0; - - // Listen for state events - wallet.on('state', state => { - events.push(`state:${state}`); - }); - }); - - afterEach(async () => { - if (wallet) { - await wallet.stop({ cleanStorage: true }); - } - }); - - it('should handle getAccessData unexpected errors', async () => { - // XXX: This test belongs to the unit tests, but adding it here temporarily for coverage - jest.spyOn(storage, 'getAccessData').mockRejectedValueOnce(new Error('Crash')); - - // Start the wallet - await expect(() => wallet.start({ pinCode, password })).rejects.toThrow('Crash'); - - // Verify wallet is ready - expect(wallet.isReady()).toBe(false); - }); - }); - - describe('successful wallet creation', () => { - const events: string[] = []; - let storage: Storage; - - beforeEach(() => { - ({ wallet, storage } = buildWalletInstance({ words: emptyWallet.words })); - - // Clear events array - events.length = 0; - - // Listen for state events - wallet.on('state', state => { - events.push(`state:${state}`); - }); - }); - - afterEach(async () => { - if (wallet) { - await wallet.stop({ cleanStorage: true }); - } - }); - - it('should create wallet with words and emit correct state events', async () => { - // Start the wallet - await wallet.start({ pinCode, password }); - - // Verify wallet is ready - expect(wallet.isReady()).toBe(true); - - // Verify correct state transitions occurred - expect(events).toContain('state:Loading'); - expect(events).toContain('state:Ready'); - - // Verify wallet has correct network - expect(wallet.getNetwork()).toBe(NETWORK_NAME); - - // Verify wallet has addresses available - const currentAddress = wallet.getCurrentAddress(); - expect(currentAddress.index).toBeDefined(); - expect(currentAddress.address).toEqual(emptyWallet.addresses[currentAddress.index]); - - // Verify websocket is disabled for this test - expect(wallet.isWsEnabled()).toBe(false); - }); - - it('should create wallet with xpriv', async () => { - // Generate access data to get the xpriv - const seed = emptyWallet.words; - const accessData = walletUtils.generateAccessDataFromSeed(seed, { - networkName: 'testnet', - password: '1234', - pin: '1234', - }); - - // Derive auth xpriv and account key - const code = new Mnemonic(seed); - const xpriv = code.toHDPrivateKey('', new Network('testnet')); - const authxpriv = xpriv.deriveChild(WALLET_SERVICE_AUTH_DERIVATION_PATH).xprivkey; - const acctKey = decryptData(accessData.acctPathKey!, '1234'); - - // Build wallet with xpriv and authxpriv - const network = new Network(NETWORK_NAME); - const requestPassword = jest.fn().mockResolvedValue('test-password'); - wallet = new HathorWalletServiceWallet({ - requestPassword, - xpriv: acctKey, - authxpriv, - network, - storage, - enableWs: false, // Disable websocket for integration tests - }); - - // Start the wallet - await wallet.start({ pinCode, password }); - - // Verify wallet is ready - expect(wallet.isReady()).toBe(true); - - // Verify wallet has addresses available - const currentAddress = wallet.getCurrentAddress(); - expect(currentAddress.index).toBeDefined(); - expect(currentAddress.address).toEqual(emptyWallet.addresses[currentAddress.index]); - }); - }); -}); - describe('wallet public methods', () => { beforeEach(async () => { ({ wallet } = buildWalletInstance({ words: emptyWallet.words }));