diff --git a/__tests__/integration/adapters/fullnode.adapter.ts b/__tests__/integration/adapters/fullnode.adapter.ts index ca7dba5e7..820fffdef 100644 --- a/__tests__/integration/adapters/fullnode.adapter.ts +++ b/__tests__/integration/adapters/fullnode.adapter.ts @@ -8,7 +8,7 @@ import HathorWallet from '../../../src/new/wallet'; import { WalletTracker } from '../utils/wallet-tracker.util'; -import { WalletState } from '../../../src/types'; +import { AddressScanPolicyData, SCANNING_POLICY, WalletState } from '../../../src/types'; import type Transaction from '../../../src/models/transaction'; import { generateConnection, @@ -30,6 +30,7 @@ import type { CreateWalletResult, } from './types'; import type { PrecalculatedWalletData } from '../helpers/wallet-precalculation.helper'; +import { getGapLimitConfig } from '../utils/core.util'; /** Stop options shared between {@link stopWallet} and the {@link WalletTracker}. */ const STOP_OPTIONS: WalletStopOptions = { cleanStorage: true, cleanAddresses: true }; @@ -215,6 +216,12 @@ export class FullnodeWalletTestAdapter implements IWalletTestAdapter { // 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; + let scanPolicy: AddressScanPolicyData | null = null; + if (options?.singleAddressMode === true) { + scanPolicy = { policy: SCANNING_POLICY.SINGLE_ADDRESS }; + } else if (!options?.singleAddressMode) { + scanPolicy = getGapLimitConfig(); + } return { ...(useSeed && walletData.words ? { seed: walletData.words } : {}), connection: generateConnection(), @@ -229,6 +236,7 @@ export class FullnodeWalletTestAdapter implements IWalletTestAdapter { ...(options?.passphrase && { passphrase: options.passphrase }), ...(options?.multisig && { multisig: options.multisig }), ...(options?.tokenUid && { tokenUid: options.tokenUid }), + scanPolicy, }; } } diff --git a/__tests__/integration/adapters/service.adapter.ts b/__tests__/integration/adapters/service.adapter.ts index 05cc7941a..a0b918a28 100644 --- a/__tests__/integration/adapters/service.adapter.ts +++ b/__tests__/integration/adapters/service.adapter.ts @@ -132,6 +132,7 @@ export class ServiceWalletTestAdapter implements IWalletTestAdapter { words: options?.xpub ? '' : options?.seed || '', xpub: options?.xpub || '', enableWs: false, + singleAddressMode: options?.singleAddressMode, }); this.tracker.track(result.wallet); diff --git a/__tests__/integration/adapters/types.ts b/__tests__/integration/adapters/types.ts index ae0a9137b..63e6a4cad 100644 --- a/__tests__/integration/adapters/types.ts +++ b/__tests__/integration/adapters/types.ts @@ -43,6 +43,7 @@ export interface CreateWalletOptions { numSignatures: number; }; tokenUid?: string; + singleAddressMode?: boolean; } /** diff --git a/__tests__/integration/fullnode-specific/start.test.ts b/__tests__/integration/fullnode-specific/start.test.ts index e012c3948..fb1aa8d0e 100644 --- a/__tests__/integration/fullnode-specific/start.test.ts +++ b/__tests__/integration/fullnode-specific/start.test.ts @@ -23,7 +23,7 @@ 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 { deriveXpubFromSeed, getGapLimitConfig } from '../utils/core.util'; import { WALLET_CONSTANTS } from '../configuration/test-constants'; import { createTokenHelper, @@ -168,6 +168,7 @@ describe('[Fullnode-specific] start', () => { password: DEFAULT_PASSWORD, pinCode: DEFAULT_PIN_CODE, preCalculatedAddresses: walletData.addresses, + scanPolicy: getGapLimitConfig(), }); tracker.track(hWallet); await hWallet.start(); @@ -188,6 +189,7 @@ describe('[Fullnode-specific] start', () => { password: DEFAULT_PASSWORD, pinCode: DEFAULT_PIN_CODE, // No preCalculatedAddresses — all calculated at runtime + scanPolicy: getGapLimitConfig(), }; const hWallet = new HathorWallet(walletConfig); tracker.track(hWallet); @@ -211,6 +213,7 @@ describe('[Fullnode-specific] start', () => { pubkeys: multisigWalletsData.pubkeys, numSignatures: 3, }, + scanPolicy: getGapLimitConfig(), }; const hWallet = new HathorWallet(walletConfig); diff --git a/__tests__/integration/helpers/genesis-wallet.helper.ts b/__tests__/integration/helpers/genesis-wallet.helper.ts index dce6ddef1..3e6a42613 100644 --- a/__tests__/integration/helpers/genesis-wallet.helper.ts +++ b/__tests__/integration/helpers/genesis-wallet.helper.ts @@ -10,7 +10,7 @@ import Connection from '../../../src/new/connection'; import HathorWallet from '../../../src/new/wallet'; import { waitForTxReceived, waitForWalletReady, waitUntilNextTimestamp } from './wallet.helper'; import { loggers } from '../utils/logger.util'; -import { delay } from '../utils/core.util'; +import { delay, getGapLimitConfig } from '../utils/core.util'; import { OutputValueType } from '../../../src/types'; import Transaction from '../../../src/models/transaction'; import { HathorWalletServiceWallet } from '../../../src'; @@ -53,6 +53,7 @@ export class GenesisWalletHelper { pinCode: pin, multisig: null, preCalculatedAddresses: WALLET_CONSTANTS.genesis.addresses, + scanPolicy: getGapLimitConfig(), }); await this.hWallet.start(); diff --git a/__tests__/integration/helpers/service-facade.helper.ts b/__tests__/integration/helpers/service-facade.helper.ts index 3dcc6d7b0..bed92b5e4 100644 --- a/__tests__/integration/helpers/service-facade.helper.ts +++ b/__tests__/integration/helpers/service-facade.helper.ts @@ -55,6 +55,7 @@ export function buildWalletInstance({ words = '', xpub = '', passwordForRequests = 'test-password', + singleAddressMode = false, } = {}) { let addresses: string[] = []; @@ -82,6 +83,7 @@ export function buildWalletInstance({ network, storage, enableWs, + singleAddressMode, }); return { wallet: newWallet, store, storage, words, addresses }; diff --git a/__tests__/integration/helpers/wallet.helper.ts b/__tests__/integration/helpers/wallet.helper.ts index 09bd00f9a..c89f05454 100644 --- a/__tests__/integration/helpers/wallet.helper.ts +++ b/__tests__/integration/helpers/wallet.helper.ts @@ -17,7 +17,7 @@ import { import HathorWallet from '../../../src/new/wallet'; import walletUtils from '../../../src/utils/wallet'; import { multisigWalletsData, precalculationHelpers } from './wallet-precalculation.helper'; -import { delay } from '../utils/core.util'; +import { delay, getGapLimitConfig } from '../utils/core.util'; import { loggers } from '../utils/logger.util'; import { MemoryStore, Storage } from '../../../src/storage'; import { TxHistoryProcessingStatus, IHistoryTx } from '../../../src/types'; @@ -101,6 +101,7 @@ export async function generateWalletHelper(param) { password: DEFAULT_PASSWORD, pinCode: DEFAULT_PIN_CODE, preCalculatedAddresses: walletData.addresses, + scanPolicy: getGapLimitConfig(), }; if (param) { Object.assign(walletConfig, param); @@ -158,6 +159,7 @@ export async function generateWalletHelperRO(options) { password: DEFAULT_PASSWORD, pinCode: DEFAULT_PIN_CODE, preCalculatedAddresses: walletData.addresses, + scanPolicy: getGapLimitConfig(), }; const hWallet = new HathorWallet(walletConfig); await hWallet.start(); @@ -196,6 +198,7 @@ export async function generateMultisigWalletHelper(parameters) { pubkeys: parameters.pubkeys || multisigWalletsData.pubkeys, numSignatures: parameters.numSignatures || 3, }, + scanPolicy: getGapLimitConfig(), }; const mhWallet = new HathorWallet(walletConfig); await mhWallet.start(); diff --git a/__tests__/integration/shared/start.test.ts b/__tests__/integration/shared/start.test.ts index 8b641beb6..018befc01 100644 --- a/__tests__/integration/shared/start.test.ts +++ b/__tests__/integration/shared/start.test.ts @@ -10,10 +10,12 @@ 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 { delay, deriveXpubFromSeed, getRandomInt } from '../utils/core.util'; import { loggers } from '../utils/logger.util'; import { FullnodeWalletTestAdapter } from '../adapters/fullnode.adapter'; import { ServiceWalletTestAdapter } from '../adapters/service.adapter'; +import { WalletAddressMode } from '../../../src'; +import { HasTxOutsideFirstAddressError } from '../../../src/errors'; const adapters: IWalletTestAdapter[] = [ new FullnodeWalletTestAdapter(), @@ -266,6 +268,30 @@ describe.each(adapters)('[Shared] start — $name', adapter => { await adapter.stopWallet(wallet); } }); + + it('should be able to start in single address mode', async () => { + const walletData = adapter.getPrecalculatedWallet(); + const xpub = deriveXpubFromSeed(walletData.words); + + const { wallet } = await adapter.createWallet({ + seed: walletData.words, + xpub, + preCalculatedAddresses: walletData.addresses, + singleAddressMode: true, + }); + + try { + await expect(wallet.getAddressMode()).resolves.toEqual(WalletAddressMode.SINGLE); + const currentAddress = await wallet.getCurrentAddress(); + expect(currentAddress.index).toBe(0); + expect(currentAddress.address).toBe(walletData.addresses[0]); + const nextAddress = await wallet.getNextAddress(); + expect(nextAddress.index).toBe(0); + expect(nextAddress.address).toBe(walletData.addresses[0]); + } finally { + await adapter.stopWallet(wallet); + } + }); }); // --- Stop lifecycle tests --- @@ -290,4 +316,138 @@ describe.each(adapters)('[Shared] start — $name', adapter => { await expect(adapter.stopWallet(wallet)).resolves.not.toThrow(); }); }); + + // --- Single Address mode --- + + describe('single address mode', () => { + it('should be able to receive txs on index 0 and keep in single address mode', async () => { + const walletData = adapter.getPrecalculatedWallet(); + + const { wallet } = await adapter.createWallet({ + seed: walletData.words, + preCalculatedAddresses: walletData.addresses, + singleAddressMode: true, + }); + + try { + // Start in SINGLE mode + await expect(wallet.getAddressMode()).resolves.toEqual(WalletAddressMode.SINGLE); + const currentAddress = await wallet.getCurrentAddress(); + expect(currentAddress.index).toBe(0); + expect(currentAddress.address).toBe(walletData.addresses[0]); + const nextAddress = await wallet.getNextAddress(); + expect(nextAddress.index).toBe(0); + expect(nextAddress.address).toBe(walletData.addresses[0]); + + // Tx in index 0 + const addr = await wallet.getAddressAtIndex(0); + expect(addr).toBeDefined(); + await adapter.injectFunds(wallet, addr!, 1n); + + // Current and next address is still 0 and in SINGLE mode + await expect(wallet.getAddressMode()).resolves.toEqual(WalletAddressMode.SINGLE); + const currentAddressAfterTx = await wallet.getCurrentAddress(); + expect(currentAddressAfterTx.index).toBe(0); + expect(currentAddressAfterTx.address).toBe(walletData.addresses[0]); + const nextAddressAfterTx = await wallet.getNextAddress(); + expect(nextAddressAfterTx.index).toBe(0); + expect(nextAddressAfterTx.address).toBe(walletData.addresses[0]); + } finally { + await adapter.stopAllWallets(); + } + }); + + it('should be able to switch between modes', async () => { + const walletData = adapter.getPrecalculatedWallet(); + + const { wallet } = await adapter.createWallet({ + seed: walletData.words, + preCalculatedAddresses: walletData.addresses, + singleAddressMode: true, + }); + + try { + await expect(wallet.getAddressMode()).resolves.toEqual(WalletAddressMode.SINGLE); + + await wallet.enableMultiAddressMode(); + + await expect(wallet.getAddressMode()).resolves.toEqual(WalletAddressMode.MULTI); + + await wallet.enableSingleAddressMode(); + + await expect(wallet.getAddressMode()).resolves.toEqual(WalletAddressMode.SINGLE); + } finally { + await adapter.stopAllWallets(); + } + }); + + it('should not be able to switch with tx outside index 0', async () => { + const walletData = adapter.getPrecalculatedWallet(); + + try { + const { wallet: walletMulti } = await adapter.createWallet({ + seed: walletData.words, + preCalculatedAddresses: walletData.addresses, + singleAddressMode: false, + }); + await expect(walletMulti.getAddressMode()).resolves.toEqual(WalletAddressMode.MULTI); + + // Tx in index 1 + const addr1 = await walletMulti.getAddressAtIndex(1); + expect(addr1).toBeDefined(); + await adapter.injectFunds(walletMulti, addr1!, 1n); + await adapter.stopWallet(walletMulti); + + // Re-create the wallet with single address mode + const { wallet } = await adapter.createWallet({ + seed: walletData.words, + preCalculatedAddresses: walletData.addresses, + singleAddressMode: true, // This will be ignored by the wallet + }); + + await expect(wallet.getAddressMode()).resolves.toEqual(WalletAddressMode.MULTI); + await expect(wallet.enableSingleAddressMode()).rejects.toThrow( + HasTxOutsideFirstAddressError + ); + await expect(wallet.getAddressMode()).resolves.toEqual(WalletAddressMode.MULTI); + } finally { + await adapter.stopAllWallets(); + } + }); + + it('should not respond to tx on not-loaded index', async () => { + const walletData = adapter.getPrecalculatedWallet(); + + try { + const { wallet: walletMulti } = await adapter.createWallet({ + seed: walletData.words, + preCalculatedAddresses: walletData.addresses, + singleAddressMode: false, + }); + await expect(walletMulti.getAddressMode()).resolves.toEqual(WalletAddressMode.MULTI); + // Re-create the wallet with single address mode + const { wallet } = await adapter.createWallet({ + seed: walletData.words, + preCalculatedAddresses: walletData.addresses, + singleAddressMode: true, + }); + await expect(wallet.getAddressMode()).resolves.toEqual(WalletAddressMode.SINGLE); + + // Same address on index 1 since its the same wallet + const addr1Multi = await walletMulti.getAddressAtIndex(1); + const addr1 = await wallet.getAddressAtIndex(1); + expect(addr1).toEqual(addr1Multi); + // Tx in index 1 + expect(addr1).toBeDefined(); + await adapter.injectFunds(walletMulti, addr1!, 1n); + + await delay(100); + + await expect(wallet.getAddressMode()).resolves.toEqual(WalletAddressMode.SINGLE); + await expect(walletMulti.getAddressMode()).resolves.toEqual(WalletAddressMode.MULTI); + } finally { + await adapter.stopAllWallets(); + } + }); + }); }); diff --git a/__tests__/integration/storage/storage.test.ts b/__tests__/integration/storage/storage.test.ts index 08e8dbc54..ccca9cdc0 100644 --- a/__tests__/integration/storage/storage.test.ts +++ b/__tests__/integration/storage/storage.test.ts @@ -17,6 +17,7 @@ import { MemoryStore, Storage } from '../../../src/storage'; import transactionUtils from '../../../src/utils/transaction'; import { NATIVE_TOKEN_UID } from '../../../src/constants'; import { IHathorWallet } from '../../../src/wallet/types'; +import { getGapLimitConfig } from '../utils/core.util'; const startedWallets = []; @@ -45,6 +46,7 @@ async function startWallet(storage, walletData) { pinCode: DEFAULT_PIN_CODE, preCalculatedAddresses: walletData.addresses, storage, + scanPolicy: getGapLimitConfig(), }; const hWallet = new HathorWallet(walletConfig); await hWallet.start(); diff --git a/__tests__/integration/utils/core.util.ts b/__tests__/integration/utils/core.util.ts index e70971d74..fef9284a0 100644 --- a/__tests__/integration/utils/core.util.ts +++ b/__tests__/integration/utils/core.util.ts @@ -8,6 +8,7 @@ import Mnemonic from 'bitcore-mnemonic/lib/mnemonic'; import { P2PKH_ACCT_PATH } from '../../../src/constants'; import Network from '../../../src/models/network'; +import { AddressScanPolicyData, SCANNING_POLICY } from '../../../src/types'; /** * Simple way to wait asynchronously before continuing the funcion. Does not block the JS thread. @@ -35,3 +36,10 @@ export function deriveXpubFromSeed(words: string): string { const rootXpriv = code.toHDPrivateKey('', new Network('testnet')); return rootXpriv.deriveNonCompliantChild(P2PKH_ACCT_PATH).xpubkey; } + +/** + * Generates a gap limit scanning policy configuration. + */ +export function getGapLimitConfig(gapLimit: number = 20): AddressScanPolicyData { + return { policy: SCANNING_POLICY.GAP_LIMIT, gapLimit }; +} diff --git a/__tests__/integration/walletservice_facade.test.ts b/__tests__/integration/walletservice_facade.test.ts index 7b8146312..efbb6bbf7 100644 --- a/__tests__/integration/walletservice_facade.test.ts +++ b/__tests__/integration/walletservice_facade.test.ts @@ -1,5 +1,13 @@ import HathorWalletServiceWallet from '../../src/wallet/wallet'; -import { CreateTokenTransaction, FeeHeader, Output, transactionUtils } from '../../src'; +import { + CreateTokenTransaction, + MemoryStore, + FeeHeader, + Output, + Storage, + Network, + transactionUtils, +} from '../../src'; import { WALLET_CONSTANTS } from './configuration/test-constants'; import { NATIVE_TOKEN_UID, TOKEN_MELT_MASK, TOKEN_MINT_MASK } from '../../src/constants'; import { @@ -10,7 +18,7 @@ import { } from './helpers/service-facade.helper'; import { SendTxError, UtxoError, WalletRequestError } from '../../src/errors'; import { GetAddressesObject } from '../../src/wallet/types'; -import { TokenVersion } from '../../src/types'; +import { TokenVersion, WalletAddressMode } from '../../src/types'; import { GenesisWalletServiceHelper } from './helpers/genesis-wallet.helper'; // Set base URL for the wallet service API inside the privatenet test container @@ -133,6 +141,58 @@ const emptyFeeWallet = { 'Wfh8oHVEJcUqLeyX9k8YCF6Lnq8Q5wT3in', ], }; +const singleAddressWallet1 = { + words: + 'upon tennis increase embark dismiss diamond monitor face magnet jungle scout salute rural master shoulder cry juice jeans radar present close meat antenna mind', + addresses: [ + 'WewDeXWyvHP7jJTs7tjLoQfoB72LLxJQqN', + 'WmtWgtk5GxdcDKwjNwmXXn74nQWTPWhKfx', + 'WPynsVhyU6nP7RSZAkqfijEutC88KgAyFc', + 'WYBwT3xLpDnHNtYZiU52oanupVeDKhAvNp', + 'WVGxdgZMHkWo2Hdrb1sEFedNdjTXzjvjPi', + 'Wc4dKp6hBgr5PU9gBmzJofc93XZGAEEUXD', + 'WUujvZnk3LMbFWUW7CnZbjn5JZzALaqLfm', + 'WYiD1E8n5oB9weZ8NMyM3KoCjKf1KCjWAZ', + 'WXN7sf6WzhpESgUuRCBrjzjzHtWTCfV8Cq', + 'WYaMN32qQ9CAUNsDnbtwi1U41JY9prYhvR', + 'WWbt2ww4W45YLUAumnumZiyWrABYDzCTdN', + 'WgpRs9NxhkBPxe7ptm9RcuLdABb7DdVUA5', + 'WPzpVP34vx6X5Krj4jeiQz9VW87F4LEZnV', + 'WSn9Bn6EDPSWZqNQdpV3FxGjpTEMsqQHYQ', + 'WmYnieT3vzzY83eHphQHs6HJ5mYyPwcKSE', + 'WZfcHjgkfK9UroTzpiricB6gtg99QKraG1', + 'WiHovoQ5ZLKPpQjZYkLVeoVgP7LoVLK518', + 'Wi5AvNTnh4mZft65kzsRbDYEPGbTRhd5q3', + 'Weg6WEncAEJs5qDbGUxcLTR3iycM3hrt4C', + 'WSVarF73e6UVccGwb44FvTtqFWsHQmjKCt', + ], +}; +const singleAddressWallet2 = { + words: + 'glad drop admit april disagree picnic claim soon permit ethics cross soul pulp desert weather capital praise nose wise color else flock royal merit', + addresses: [ + 'WjE2yiEeBYSoHLnTXkGgJvB1Afn3vG3LX6', + 'WaGshSrBBCrWAjAgNHZSU3DJXTysAso5q3', + 'WS3LUTDKSgejR28v6Gi2ejT3RH2diDAS3g', + 'WR6188ey4v6BUfY29UyHgMaVLmqQ7PGxFa', + 'WTPeL8fApUQgz7UhaL694Xqt4YcDTPMGu1', + 'WdqpeFjGoUhT8Kyfbc2qjFyRsy6agwe3VU', + 'WZMjeMMnx3BPNagTqbHW2XkP1nrzxPVGCw', + 'WPgxrd5BcfDpMynrsWXAySbLhS6DCmTeKL', + 'WY7NCX2j98VfStbTyyxgai9y3xma63W1Bk', + 'WYYsfkTSRQ6fzQtdeSVMYLsPkBA6MemPhv', + 'Wjzb9KiBVL4xNArQd8ckR3UtvBmYo8NxYG', + 'WdCyoh1JzvxJhgQcrCVkZ3SpAoUwqVSEfS', + 'WfFpwweGNQ1QurAwqYLwSuccHcUz12q3Zi', + 'Wddb5Maxq6B7rFYH1JZJvZY7kTQezwrafL', + 'Wfw58Z5GrYfp4Ecmg1hDLBCQspPQC2gpqH', + 'WU931UJrREpFn8dhi3HNn4nLNkVjx9hdj5', + 'WakAnQEvsUgxUd8kGkmeeaUPC2XNPDXxFu', + 'WZUBn2K9evu3U9jPe8Y92LvYFWgctVov9L', + 'Whwv9XaKtDUpyXpEJPhvT9WdQEpfeeadr1', + 'WUL7Bc2o7ekAZHo19YVZCjc5As1ZeM3XRv', + ], +}; /** Default pin to simplify the tests */ const pinCode = '123456'; @@ -2098,3 +2158,109 @@ describe('Fee-based tokens', () => { expect(tokenBalanceAfter[0].balance.unlocked).toBe(150n); }); }); + +describe('single-address mode', () => { + afterEach(async () => { + if (wallet) { + await wallet.stop({ cleanStorage: true }); + } + }); + + it('should enable single-address mode and keep index 0 as current address after receiving tx', async () => { + ({ wallet } = buildWalletInstance({ words: singleAddressWallet1.words })); + await wallet.start({ pinCode, password }); + + await wallet.enableSingleAddressMode(); + + const currentAddress = wallet.getCurrentAddress(); + expect(currentAddress.index).toBe(0); + expect(currentAddress.address).toBe(singleAddressWallet1.addresses[0]); + + await GenesisWalletServiceHelper.injectFunds(singleAddressWallet1.addresses[0], 10n, wallet); + + const currentAddressAfterTx = wallet.getCurrentAddress(); + expect(currentAddressAfterTx.index).toBe(0); + expect(currentAddressAfterTx.address).toBe(singleAddressWallet1.addresses[0]); + + const nextAddress = wallet.getNextAddress(); + expect(nextAddress.index).toBe(0); + expect(nextAddress.address).toBe(singleAddressWallet1.addresses[0]); + }); + + it('should succeed enabling single-address mode when wallet only has tx on index 0', async () => { + ({ wallet } = buildWalletInstance({ words: singleAddressWallet2.words })); + await wallet.start({ pinCode, password }); + + await GenesisWalletServiceHelper.injectFunds(singleAddressWallet2.addresses[0], 5n, wallet); + + await wallet.enableSingleAddressMode(); + + const currentAddress = wallet.getCurrentAddress(); + expect(currentAddress.index).toBe(0); + expect(currentAddress.address).toBe(singleAddressWallet2.addresses[0]); + + const nextAddress = wallet.getNextAddress(); + expect(nextAddress.index).toBe(0); + expect(nextAddress.address).toBe(singleAddressWallet2.addresses[0]); + }); + + it('should fail to enable single-address mode when wallet has tx on index > 0', async () => { + ({ wallet } = buildWalletInstance({ words: singleAddressWallet2.words })); + await wallet.start({ pinCode, password }); + + await GenesisWalletServiceHelper.injectFunds(singleAddressWallet2.addresses[1], 10n, wallet); + + await expect(wallet.enableSingleAddressMode()).rejects.toThrow( + 'Cannot enable single-address policy' + ); + }); + + it('should fallback to start in multi-address mode via constructor when wallet has tx on index > 0', async () => { + // First, start wallet normally and fund index 1 + ({ wallet } = buildWalletInstance({ words: singleAddressWallet2.words })); + await wallet.start({ pinCode, password }); + + await GenesisWalletServiceHelper.injectFunds(singleAddressWallet2.addresses[1], 10n, wallet); + + await wallet.stop({ cleanStorage: true }); + + // Now try to re-start with singleAddressMode: true via constructor + const store = new MemoryStore(); + const storage = new Storage(store); + wallet = new HathorWalletServiceWallet({ + requestPassword: jest.fn().mockResolvedValue('test-password'), + seed: singleAddressWallet2.words, + network: new Network('testnet'), + storage, + enableWs: false, + singleAddressMode: true, + }); + + await wallet.start({ pinCode, password }); + + await expect(wallet.getAddressMode()).resolves.toBe(WalletAddressMode.MULTI); + }); + + it('should start in single-address mode via constructor', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + wallet = new HathorWalletServiceWallet({ + requestPassword: jest.fn().mockResolvedValue('test-password'), + seed: singleAddressWallet1.words, + network: new Network('testnet'), + storage, + enableWs: false, + singleAddressMode: true, + }); + + await wallet.start({ pinCode, password }); + + const currentAddress = wallet.getCurrentAddress(); + expect(currentAddress.index).toBe(0); + expect(currentAddress.address).toBe(singleAddressWallet1.addresses[0]); + + const nextAddress = wallet.getNextAddress(); + expect(nextAddress.index).toBe(0); + expect(nextAddress.address).toBe(singleAddressWallet1.addresses[0]); + }); +}); diff --git a/__tests__/new/hathorwallet.test.ts b/__tests__/new/hathorwallet.test.ts index 155906e66..793fc8e2f 100644 --- a/__tests__/new/hathorwallet.test.ts +++ b/__tests__/new/hathorwallet.test.ts @@ -24,6 +24,7 @@ import { IHistoryTx, WalletType } from '../../src/types'; import { WalletWebSocketData } from '../../src/new/types'; import txApi from '../../src/api/txApi'; import * as addressUtils from '../../src/utils/address'; +import * as storageUtils from '../../src/utils/storage'; import walletUtils from '../../src/utils/wallet'; import versionApi from '../../src/api/version'; import { decryptData, verifyMessage } from '../../src/utils/crypto'; @@ -1562,70 +1563,46 @@ test('getUtxosForAmount - should always get the best utxos', async () => { describe('hasTxOutsideFirstAddress', () => { test('returns true when there are transactions on addresses with index > 0', async () => { - const store = new MemoryStore(); - const storage = new Storage(store); - - async function* getAllAddressesMock() { - yield { base58: 'addr0', bip32AddressIndex: 0, numTransactions: 5 }; - yield { base58: 'addr1', bip32AddressIndex: 1, numTransactions: 2 }; + async function getAddressAtIndexMock(index: number) { + return `addr${index}`; + } + async function* loadAddressHistoryMock() { + yield true; } - jest.spyOn(storage, 'getAllAddresses').mockImplementation(getAllAddressesMock); + const spy = jest + .spyOn(storageUtils, 'loadAddressHistory') + .mockImplementation(loadAddressHistoryMock); - const hWallet = new FakeHathorWallet(); - hWallet.storage = storage; + try { + const hWallet = new FakeHathorWallet(); + hWallet.getAddressAtIndex = jest.fn().mockImplementation(getAddressAtIndexMock); - await expect(hWallet.hasTxOutsideFirstAddress()).resolves.toBe(true); + await expect(hWallet.hasTxOutsideFirstAddress()).resolves.toBe(true); + } finally { + spy.mockRestore(); + } }); test('returns false when only the first address has transactions', async () => { - const store = new MemoryStore(); - const storage = new Storage(store); - - async function* getAllAddressesMock() { - yield { base58: 'addr0', bip32AddressIndex: 0, numTransactions: 5 }; - yield { base58: 'addr1', bip32AddressIndex: 1, numTransactions: 0 }; - yield { base58: 'addr2', bip32AddressIndex: 2, numTransactions: 0 }; + async function getAddressAtIndexMock(index: number) { + return `addr${index}`; } - - jest.spyOn(storage, 'getAllAddresses').mockImplementation(getAllAddressesMock); - - const hWallet = new FakeHathorWallet(); - hWallet.storage = storage; - - await expect(hWallet.hasTxOutsideFirstAddress()).resolves.toBe(false); - }); - - test('returns false when there are no addresses', async () => { - const store = new MemoryStore(); - const storage = new Storage(store); - - async function* getAllAddressesMock() { - // Empty generator + async function* loadAddressHistoryMock() { + yield false; } - jest.spyOn(storage, 'getAllAddresses').mockImplementation(getAllAddressesMock); - - const hWallet = new FakeHathorWallet(); - hWallet.storage = storage; - - await expect(hWallet.hasTxOutsideFirstAddress()).resolves.toBe(false); - }); + const spy = jest + .spyOn(storageUtils, 'loadAddressHistory') + .mockImplementation(loadAddressHistoryMock); - test('returns false when there are no transactions at all', async () => { - const store = new MemoryStore(); - const storage = new Storage(store); + try { + const hWallet = new FakeHathorWallet(); + hWallet.getAddressAtIndex = jest.fn().mockImplementation(getAddressAtIndexMock); - async function* getAllAddressesMock() { - yield { base58: 'addr0', bip32AddressIndex: 0, numTransactions: 0 }; - yield { base58: 'addr1', bip32AddressIndex: 1, numTransactions: 0 }; + await expect(hWallet.hasTxOutsideFirstAddress()).resolves.toBe(false); + } finally { + spy.mockRestore(); } - - jest.spyOn(storage, 'getAllAddresses').mockImplementation(getAllAddressesMock); - - const hWallet = new FakeHathorWallet(); - hWallet.storage = storage; - - await expect(hWallet.hasTxOutsideFirstAddress()).resolves.toBe(false); }); }); diff --git a/__tests__/new/singleAddressPolicy.test.ts b/__tests__/new/singleAddressPolicy.test.ts new file mode 100644 index 000000000..c56168b08 --- /dev/null +++ b/__tests__/new/singleAddressPolicy.test.ts @@ -0,0 +1,99 @@ +/** + * 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 { MemoryStore, Storage } from '../../src/storage'; +import { SCANNING_POLICY } from '../../src/types'; +import { scanPolicyStartAddresses, checkScanningPolicy } from '../../src/utils/storage'; + +describe('single-address policy integration', () => { + it('should load only 1 address and never request more', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + + // Set single-address policy + await storage.setScanningPolicyData({ policy: SCANNING_POLICY.SINGLE_ADDRESS }); + + // scanPolicyStartAddresses should return 1 address starting at 0 + const startAddresses = await scanPolicyStartAddresses(storage); + expect(startAddresses).toEqual({ nextIndex: 0, count: 1 }); + + // Save the single address + await store.saveAddress({ + base58: 'addr0', + bip32AddressIndex: 0, + }); + + // checkScanningPolicy should never request more + const moreAddresses = await checkScanningPolicy(storage); + expect(moreAddresses).toBeNull(); + }); + + it('should not advance currentAddressIndex when saving tx', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + + await storage.setScanningPolicyData({ policy: SCANNING_POLICY.SINGLE_ADDRESS }); + + await store.saveAddress({ + base58: 'addr0', + bip32AddressIndex: 0, + }); + + // Verify initial state + let walletData = await store.getWalletData(); + expect(walletData.currentAddressIndex).toBe(0); + + // Save a transaction that references addr0 + await store.saveTx({ + tx_id: 'tx1', + version: 1, + weight: 1, + timestamp: 1, + is_voided: false, + inputs: [], + outputs: [ + { + value: 100n, + token: '00', + token_data: 0, + script: 'dummyscript', + decoded: { type: 'P2PKH', address: 'addr0', timelock: null }, + spent_by: null, + selected_as_input: false, + }, + ], + parents: [], + }); + + // currentAddressIndex should still be 0 + walletData = await store.getWalletData(); + expect(walletData.currentAddressIndex).toBe(0); + }); + + it('getCurrentAddress with markAsUsed should always return same address', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + + await storage.setScanningPolicyData({ policy: SCANNING_POLICY.SINGLE_ADDRESS }); + + await store.saveAddress({ + base58: 'addr0', + bip32AddressIndex: 0, + }); + + const addr1 = await store.getCurrentAddress(true); + const addr2 = await store.getCurrentAddress(true); + const addr3 = await store.getCurrentAddress(true); + + expect(addr1).toBe('addr0'); + expect(addr2).toBe('addr0'); + expect(addr3).toBe('addr0'); + + const walletData = await store.getWalletData(); + expect(walletData.currentAddressIndex).toBe(0); + }); +}); diff --git a/__tests__/storage/common_store.test.ts b/__tests__/storage/common_store.test.ts index 3918b3a6e..3fc2ee92b 100644 --- a/__tests__/storage/common_store.test.ts +++ b/__tests__/storage/common_store.test.ts @@ -7,7 +7,14 @@ import { MemoryStore, Storage } from '../../src/storage'; import { TOKEN_AUTHORITY_MASK, TOKEN_MINT_MASK, GAP_LIMIT } from '../../src/constants'; -import { ILockedUtxo, IStore, IUtxo, OutputValueType, TokenVersion } from '../../src/types'; +import { + ILockedUtxo, + IStore, + IUtxo, + OutputValueType, + TokenVersion, + SCANNING_POLICY, +} from '../../src/types'; describe('locked utxo methods', () => { const spyDate = jest.spyOn(Date, 'now'); @@ -243,4 +250,46 @@ describe('scanning policy methods', () => { endIndex: 42, }); } + + it('should work with single-address policy', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + + // Set single-address policy + await storage.setScanningPolicyData({ policy: SCANNING_POLICY.SINGLE_ADDRESS }); + await expect(storage.getScanningPolicy()).resolves.toEqual(SCANNING_POLICY.SINGLE_ADDRESS); + await expect(storage.getScanningPolicyData()).resolves.toEqual({ + policy: SCANNING_POLICY.SINGLE_ADDRESS, + }); + }); + + it('should not advance currentAddressIndex in single-address mode', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + + // Set single-address policy + await storage.setScanningPolicyData({ policy: SCANNING_POLICY.SINGLE_ADDRESS }); + + // Save address at index 0 + await store.saveAddress({ + base58: 'addr0', + bip32AddressIndex: 0, + numTransactions: 0, + transactions: [], + }); + + // currentAddressIndex should be 0 after initial save + const walletData = await store.getWalletData(); + expect(walletData.currentAddressIndex).toBe(0); + + // getCurrentAddress with markAsUsed should NOT advance + await store.getCurrentAddress(true); + const walletData2 = await store.getWalletData(); + expect(walletData2.currentAddressIndex).toBe(0); + + // setCurrentAddressIndex to a value > 0 should be a no-op + await store.setCurrentAddressIndex(5); + const walletData3 = await store.getWalletData(); + expect(walletData3.currentAddressIndex).toBe(0); + }); }); diff --git a/__tests__/stream.test.ts b/__tests__/stream.test.ts index 71450ffe8..1825e0cf1 100644 --- a/__tests__/stream.test.ts +++ b/__tests__/stream.test.ts @@ -4,6 +4,7 @@ import Connection from '../src/new/connection'; import { MemoryStore, Storage } from '../src/storage'; import { HistorySyncMode, getDefaultLogger } from '../src/types'; import { JSONBigInt } from '../src/utils/bigint'; +import { getGapLimitConfig } from './integration/utils/core.util'; const mock_tx = { tx_id: '00002f4c8d6516ee0c39437f30d9f20231f88652aacc263bc738f55c412cf5ee', @@ -153,6 +154,7 @@ async function startWalletFor(mode) { connection, password: '123', pinCode: '123', + scanPolicy: getGapLimitConfig(), }; const hWallet = new HathorWallet(walletConfig); hWallet.setHistorySyncMode(mode); diff --git a/__tests__/types.test.ts b/__tests__/types.test.ts new file mode 100644 index 000000000..251412d49 --- /dev/null +++ b/__tests__/types.test.ts @@ -0,0 +1,34 @@ +import { + SCANNING_POLICY, + isSingleAddressScanPolicy, + isGapLimitScanPolicy, + isIndexLimitScanPolicy, +} from '../src/types'; +import type { AddressScanPolicyData } from '../src/types'; + +describe('isSingleAddressScanPolicy type guard', () => { + it('should return true for single-address policy data', () => { + const data: AddressScanPolicyData = { policy: SCANNING_POLICY.SINGLE_ADDRESS }; + expect(isSingleAddressScanPolicy(data)).toBe(true); + expect(isGapLimitScanPolicy(data)).toBe(false); + expect(isIndexLimitScanPolicy(data)).toBe(false); + }); + + it('should return false for gap-limit policy data', () => { + const data: AddressScanPolicyData = { policy: SCANNING_POLICY.GAP_LIMIT, gapLimit: 20 }; + expect(isSingleAddressScanPolicy(data)).toBe(false); + expect(isGapLimitScanPolicy(data)).toBe(true); + expect(isIndexLimitScanPolicy(data)).toBe(false); + }); + + it('should return false for index-limit policy data', () => { + const data: AddressScanPolicyData = { + policy: SCANNING_POLICY.INDEX_LIMIT, + startIndex: 0, + endIndex: 10, + }; + expect(isSingleAddressScanPolicy(data)).toBe(false); + expect(isGapLimitScanPolicy(data)).toBe(false); + expect(isIndexLimitScanPolicy(data)).toBe(true); + }); +}); diff --git a/__tests__/utils/storage.test.ts b/__tests__/utils/storage.test.ts index 6e342a1dd..8675c0416 100644 --- a/__tests__/utils/storage.test.ts +++ b/__tests__/utils/storage.test.ts @@ -7,7 +7,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from 'axios'; -import { HistorySyncMode, WalletType, TokenVersion } from '../../src/types'; +import { HistorySyncMode, WalletType, TokenVersion, SCANNING_POLICY } from '../../src/types'; import { MemoryStore, Storage } from '../../src/storage'; import { scanPolicyStartAddresses, @@ -56,6 +56,27 @@ describe('scanning policy methods', () => { policyMock.mockReturnValue(Promise.resolve('invalid-policy')); await expect(checkScanningPolicy(storage)).resolves.toEqual(null); }); + + it('start addresses for single-address policy', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + jest + .spyOn(storage, 'getScanningPolicy') + .mockReturnValue(Promise.resolve(SCANNING_POLICY.SINGLE_ADDRESS)); + await expect(scanPolicyStartAddresses(storage)).resolves.toEqual({ + nextIndex: 0, + count: 1, + }); + }); + + it('check scanning policy returns null for single-address', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + jest + .spyOn(storage, 'getScanningPolicy') + .mockReturnValue(Promise.resolve(SCANNING_POLICY.SINGLE_ADDRESS)); + await expect(checkScanningPolicy(storage)).resolves.toEqual(null); + }); }); describe('_updateTokensData', () => { diff --git a/__tests__/wallet/enableSingleAddressMode.test.ts b/__tests__/wallet/enableSingleAddressMode.test.ts new file mode 100644 index 000000000..a90d9cef9 --- /dev/null +++ b/__tests__/wallet/enableSingleAddressMode.test.ts @@ -0,0 +1,115 @@ +import HathorWalletServiceWallet from '../../src/wallet/wallet'; +import { SCANNING_POLICY } from '../../src/types'; +import walletApi from '../../src/wallet/api/walletApi'; +import Network from '../../src/models/network'; +import { defaultWalletSeed } from '../__mock_helpers__/wallet-service.fixtures'; + +describe('enableSingleAddressMode', () => { + let wallet: HathorWalletServiceWallet; + + beforeEach(() => { + wallet = new HathorWalletServiceWallet({ + requestPassword: async () => 'password', + seed: defaultWalletSeed, + network: new Network('testnet'), + }); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + try { + await wallet.stop(); + } catch (_e) { + // ignore + } + }); + + it('should throw if wallet is not ready', async () => { + await expect(wallet.enableSingleAddressMode()).rejects.toThrow('Wallet not ready'); + }); + + it('should throw if there are transactions outside first address', async () => { + // Force wallet to ready state + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).state = 'Ready'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).firstAddress = 'addr0'; + + // Mock getAddresses to return addresses with tx on index > 0 + jest.spyOn(walletApi, 'getHasTxOutsideFirstAddress').mockResolvedValue({ + success: true, + hasTransactions: true, + }); + + await expect(wallet.enableSingleAddressMode()).rejects.toThrow( + 'Cannot enable single-address policy: wallet has transactions on addresses other than the first' + ); + }); + + it('should succeed when only first address has transactions', async () => { + // Force wallet to ready state + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).state = 'Ready'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).firstAddress = 'addr0'; + + jest.spyOn(wallet, 'getAddressPathForIndex').mockResolvedValue("m/44'/280'/0'/0/0"); + + // Mock getAddresses to return only first address with txs + jest.spyOn(walletApi, 'getHasTxOutsideFirstAddress').mockResolvedValue({ + success: true, + hasTransactions: false, + }); + + await wallet.enableSingleAddressMode(); + + // Verify scanning policy was updated + const policyData = await wallet.storage.getScanningPolicyData(); + expect(policyData.policy).toBe(SCANNING_POLICY.SINGLE_ADDRESS); + }); + + it('getCurrentAddress should always return first address in single-address mode', async () => { + // Force wallet to ready state and single-address mode + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).state = 'Ready'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).singleAddress = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).firstAddress = 'addr0'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).newAddresses = [ + { address: 'addr0', index: 0, addressPath: "m/44'/280'/0'/0/0" }, + { address: 'addr1', index: 1, addressPath: "m/44'/280'/0'/0/1" }, + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).indexToUse = 0; + + // markAsUsed should not advance + const addr1 = wallet.getCurrentAddress({ markAsUsed: true }); + expect(addr1.address).toBe('addr0'); + + const addr2 = wallet.getCurrentAddress({ markAsUsed: true }); + expect(addr2.address).toBe('addr0'); + }); + + it('getNextAddress should return same address in single-address mode', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).state = 'Ready'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).singleAddress = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).firstAddress = 'addr0'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).newAddresses = [ + { address: 'addr0', index: 0, addressPath: "m/44'/280'/0'/0/0" }, + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wallet as any).indexToUse = 0; + + const addr = wallet.getNextAddress(); + expect(addr.address).toBe('addr0'); + + const addr1 = wallet.getNextAddress(); + expect(addr1.address).toBe('addr0'); + }); +}); diff --git a/src/errorMessages.ts b/src/errorMessages.ts index 4e089d7c8..03ed52c02 100644 --- a/src/errorMessages.ts +++ b/src/errorMessages.ts @@ -37,4 +37,5 @@ export enum ErrorMessages { NANO_ORACLE_PARSE_ERROR = 'nano-oracle-parse-error', // When PIN is required in a method and not set PIN_REQUIRED = 'pin-required', + HAS_TX_OUTSIDE_FIRST_ADDRESS = 'has-tx-outside-first-address', } diff --git a/src/errors.ts b/src/errors.ts index 5d95db113..da29ba631 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -412,3 +412,13 @@ export class GlobalLoadLockTaskError extends Error { } export class NanoHeaderNotFound extends Error {} + +/** + * Error thrown when trying to enable single address mode with tx outside first address. + * + * @memberof Errors + * @inner + */ +export class HasTxOutsideFirstAddressError extends Error { + errorCode: string = ErrorMessages.HAS_TX_OUTSIDE_FIRST_ADDRESS; +} diff --git a/src/new/wallet.ts b/src/new/wallet.ts index 6c31b23a4..55081201d 100644 --- a/src/new/wallet.ts +++ b/src/new/wallet.ts @@ -30,6 +30,7 @@ import { ON_CHAIN_BLUEPRINTS_VERSION, P2PKH_ACCT_PATH, P2SH_ACCT_PATH, + GAP_LIMIT, } from '../constants'; import tokenUtils from '../utils/tokens'; import walletApi from '../api/wallet'; @@ -43,6 +44,7 @@ import SendTransaction from './sendTransaction'; import Network from '../models/network'; import { AddressError, + HasTxOutsideFirstAddressError, NanoContractTransactionError, PinRequiredError, TxNotFoundError, @@ -72,6 +74,7 @@ import { TxHistoryProcessingStatus, WalletState, WalletType, + WalletAddressMode, } from '../types'; import transactionUtils from '../utils/transaction'; import Queue from '../models/queue'; @@ -79,6 +82,7 @@ import { checkScanningPolicy, getHistorySyncMethod, getSupportedSyncMode, + loadAddressHistory, processMetadataChanged, scanPolicyStartAddresses, } from '../utils/storage'; @@ -401,7 +405,12 @@ class HathorWallet extends EventEmitter { this.wsTxQueue = new Queue(); this.newTxPromise = Promise.resolve(); - this.scanPolicy = scanPolicy; + // Defaults to single address scanning policy + if (scanPolicy == null) { + this.scanPolicy = { policy: SCANNING_POLICY.SINGLE_ADDRESS }; + } else { + this.scanPolicy = scanPolicy; + } this.isSignedExternally = this.storage.hasTxSignatureMethod(); this.historySyncMode = HistorySyncMode.POLLING_HTTP_API; @@ -615,6 +624,19 @@ class HathorWallet extends EventEmitter { // before loading the full data again if (this.firstConnection) { this.firstConnection = false; + const scanPolicy = await this.storage.getScanningPolicy(); + if (scanPolicy === SCANNING_POLICY.SINGLE_ADDRESS) { + try { + await this.enableSingleAddressMode(); + } catch (err) { + if (err instanceof HasTxOutsideFirstAddressError) { + this.scanPolicy = { policy: SCANNING_POLICY.GAP_LIMIT, gapLimit: GAP_LIMIT }; + await this.storage.setScanningPolicyData(this.scanPolicy); + } else { + throw err; + } + } + } const addressesToLoad = await scanPolicyStartAddresses(this.storage); await this.syncHistory(addressesToLoad.nextIndex, addressesToLoad.count); } else { @@ -755,12 +777,23 @@ class HathorWallet extends EventEmitter { * @memberof HathorWallet */ async hasTxOutsideFirstAddress(): Promise { - for await (const address of this.storage.getAllAddresses()) { - if (address.bip32AddressIndex > 0 && address.numTransactions > 0) { - return true; + const addresses: string[] = []; + let foundAnyTx = false; + // Load address from index 1 to GAP_LIMIT + for (let i = 1; i < GAP_LIMIT; i++) { + const address = await this.getAddressAtIndex(i); + addresses.push(address); + } + + for await (const gotTx of loadAddressHistory(addresses, this.storage, false)) { + if (gotTx) { + // This will signal we have found a transaction when syncing the history + foundAnyTx = true; + break; } } - return false; + + return foundAnyTx; } /** @@ -780,7 +813,6 @@ class HathorWallet extends EventEmitter { } else { address = await deriveAddressP2SH(index, this.storage); } - await this.storage.saveAddress(address); } return address.base58; } @@ -3481,6 +3513,81 @@ class HathorWallet extends EventEmitter { async getReadOnlyAuthToken(): Promise { throw new Error('Not implemented.'); } + + /** + * Get which address mode is currently enabled. + * + * @memberof HathorWallet + * @inner + */ + async getAddressMode(): Promise { + if (!this.isReady()) { + throw new WalletError('Wallet not ready'); + } + + const scanPolicy = await this.storage.getScanningPolicy(); + if (scanPolicy === SCANNING_POLICY.SINGLE_ADDRESS) { + return WalletAddressMode.SINGLE; + } + return WalletAddressMode.MULTI; + } + + /** + * Enable multi address mode for this wallet. + * Converts to a gap-limit wallet. + * + * @memberof HathorWallet + * @inner + */ + async enableMultiAddressMode(gapLimit: number = GAP_LIMIT): Promise { + if (!this.isReady()) { + throw new WalletError('Wallet not ready'); + } + + const currScanPolicy = await this.storage.getScanningPolicy(); + if (currScanPolicy !== SCANNING_POLICY.SINGLE_ADDRESS) { + // multi address mode is already enabled. + return; + } + + // Switch policy in storage + await this.storage.setScanningPolicyData({ + policy: SCANNING_POLICY.GAP_LIMIT, + gapLimit, + }); + + // Load new addresses + await this.reloadStorage(); + } + + /** + * Enable single-address scanning policy for this wallet. + * Converts a gap-limit wallet to use only the first address (index 0). + * + * @memberof HathorWallet + * @inner + */ + async enableSingleAddressMode(): Promise { + if (await this.hasTxOutsideFirstAddress()) { + throw new HasTxOutsideFirstAddressError( + 'Cannot enable single-address policy: wallet has transactions on addresses other than the first' + ); + } + + const currScanPolicy = await this.storage.getScanningPolicy(); + if (currScanPolicy === SCANNING_POLICY.SINGLE_ADDRESS) { + // single address mode is already enabled. + return; + } + + // Switch policy in storage + await this.storage.setScanningPolicyData({ + policy: SCANNING_POLICY.SINGLE_ADDRESS, + }); + + // Load new addresses + await this.reloadStorage(); + } } export default HathorWallet; diff --git a/src/storage/memory_store.ts b/src/storage/memory_store.ts index 6bb496a17..f701f85aa 100644 --- a/src/storage/memory_store.ts +++ b/src/storage/memory_store.ts @@ -294,7 +294,7 @@ export class MemoryStore implements IStore { this.addressIndexes.set(info.bip32AddressIndex, info.base58); if (this.walletData.currentAddressIndex === -1) { - this.walletData.currentAddressIndex = info.bip32AddressIndex; + await this.setCurrentAddressIndex(info.bip32AddressIndex); } if (info.bip32AddressIndex > this.walletData.lastLoadedAddressIndex) { @@ -327,9 +327,8 @@ export class MemoryStore implements IStore { if (markAsUsed) { // Will move the address index only if we have not reached the gap limit - this.walletData.currentAddressIndex = Math.min( - this.walletData.lastLoadedAddressIndex, - this.walletData.currentAddressIndex + 1 + await this.setCurrentAddressIndex( + Math.min(this.walletData.lastLoadedAddressIndex, this.walletData.currentAddressIndex + 1) ); } return addressInfo.base58; @@ -340,6 +339,9 @@ export class MemoryStore implements IStore { * @param {number} index The index to set */ async setCurrentAddressIndex(index: number): Promise { + if (this.walletData.scanPolicyData?.policy === SCANNING_POLICY.SINGLE_ADDRESS && index > 0) { + return; + } this.walletData.currentAddressIndex = index; } @@ -464,9 +466,8 @@ export class MemoryStore implements IStore { } } if (this.walletData.currentAddressIndex < maxIndex) { - this.walletData.currentAddressIndex = Math.min( - maxIndex + 1, - this.walletData.lastLoadedAddressIndex + await this.setCurrentAddressIndex( + Math.min(maxIndex + 1, this.walletData.lastLoadedAddressIndex) ); } this.walletData.lastUsedAddressIndex = maxIndex; diff --git a/src/types.ts b/src/types.ts index 12e6337d7..4a89d9721 100644 --- a/src/types.ts +++ b/src/types.ts @@ -384,6 +384,7 @@ export interface IWalletAccessData { export enum SCANNING_POLICY { GAP_LIMIT = 'gap-limit', INDEX_LIMIT = 'index-limit', + SINGLE_ADDRESS = 'single-address', } export interface IGapLimitAddressScanPolicy { @@ -397,6 +398,10 @@ export interface IIndexLimitAddressScanPolicy { endIndex: number; } +export interface ISingleAddressAddressScanPolicy { + policy: SCANNING_POLICY.SINGLE_ADDRESS; +} + /** * This is a request from the scanning policy to load `count` addresses starting from nextIndex. */ @@ -405,9 +410,15 @@ export interface IScanPolicyLoadAddresses { count: number; } -export type AddressScanPolicy = SCANNING_POLICY.GAP_LIMIT | SCANNING_POLICY.INDEX_LIMIT; +export type AddressScanPolicy = + | SCANNING_POLICY.GAP_LIMIT + | SCANNING_POLICY.INDEX_LIMIT + | SCANNING_POLICY.SINGLE_ADDRESS; -export type AddressScanPolicyData = IGapLimitAddressScanPolicy | IIndexLimitAddressScanPolicy; +export type AddressScanPolicyData = + | IGapLimitAddressScanPolicy + | IIndexLimitAddressScanPolicy + | ISingleAddressAddressScanPolicy; export function isGapLimitScanPolicy( scanPolicyData: AddressScanPolicyData @@ -421,6 +432,12 @@ export function isIndexLimitScanPolicy( return scanPolicyData.policy === SCANNING_POLICY.INDEX_LIMIT; } +export function isSingleAddressScanPolicy( + scanPolicyData: AddressScanPolicyData +): scanPolicyData is ISingleAddressAddressScanPolicy { + return scanPolicyData.policy === SCANNING_POLICY.SINGLE_ADDRESS; +} + export interface IWalletData { lastLoadedAddressIndex: number; lastUsedAddressIndex: number; @@ -701,3 +718,8 @@ export enum AuthorityType { export function isAuthorityType(value?: string): value is AuthorityType { return Object.values(AuthorityType).includes(value as AuthorityType); } + +export enum WalletAddressMode { + SINGLE = 'single', + MULTI = 'multi', +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts index d61c6f042..abcda264d 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -172,7 +172,8 @@ export async function apiSyncHistory( */ export async function* loadAddressHistory( addresses: string[], - storage: IStorage + storage: IStorage, + saveTxs: boolean = true ): AsyncGenerator { let foundAnyTx = false; // chunkify addresses @@ -226,7 +227,9 @@ export async function* loadAddressHistory( if (result.success) { for (const tx of result.history) { foundAnyTx = true; - await storage.addTx(tx); + if (saveTxs) { + await storage.addTx(tx); + } } hasMore = result.has_more; if (hasMore) { @@ -269,6 +272,11 @@ export async function scanPolicyStartAddresses( nextIndex: limits.startIndex, count: limits.endIndex - limits.startIndex + 1, }; + case SCANNING_POLICY.SINGLE_ADDRESS: + return { + nextIndex: 0, + count: 1, + }; case SCANNING_POLICY.GAP_LIMIT: default: return { @@ -293,6 +301,7 @@ export async function checkScanningPolicy( return checkIndexLimit(storage); case SCANNING_POLICY.GAP_LIMIT: return checkGapLimit(storage); + case SCANNING_POLICY.SINGLE_ADDRESS: default: return null; } diff --git a/src/wallet/types.ts b/src/wallet/types.ts index 9f70d1652..1dbb2a223 100644 --- a/src/wallet/types.ts +++ b/src/wallet/types.ts @@ -6,7 +6,14 @@ */ import bitcore from 'bitcore-lib'; -import { TokenVersion, IStorage, OutputValueType, IHistoryTx, IDataTx } from '../types'; +import { + TokenVersion, + IStorage, + OutputValueType, + IHistoryTx, + IDataTx, + WalletAddressMode, +} from '../types'; import Transaction from '../models/transaction'; import CreateTokenTransaction from '../models/create_token_transaction'; import SendTransactionWalletService from './sendTransactionWalletService'; @@ -354,8 +361,10 @@ export interface IHathorWallet { stop(params?: IStopWalletParams): void; getAddressAtIndex(index: number): Promise; getAddressIndex(address: string): Promise; - getCurrentAddress(options?: { markAsUsed: boolean }): AddressInfoObject | Promise; // FIXME: Should have a single return type - getNextAddress(): AddressInfoObject | Promise; // FIXME: Should have a single return type; + getCurrentAddress(options?: { + markAsUsed: boolean; + }): AddressInfoObject | Promise; // FIXME: Should have a single return type + getNextAddress(): AddressInfoObject | Promise; // FIXME: Should have a single return type; getAddressPrivKey(pinCode: string, addressIndex: number): Promise; signMessageWithAddress(message: string, index: number, pinCode: string): Promise; prepareCreateNewToken( @@ -489,6 +498,9 @@ export interface IHathorWallet { storage: IStorage; signTx(tx: Transaction, options: { pinCode?: string | null }): Promise; setNanoHeaderCaller(nanoHeader: NanoContractHeader, address: string): Promise; + getAddressMode(): Promise; + enableMultiAddressMode(): Promise; + enableSingleAddressMode(): Promise; } export interface ISendTransaction { diff --git a/src/wallet/wallet.ts b/src/wallet/wallet.ts index fcc7276a8..413318ecd 100644 --- a/src/wallet/wallet.ts +++ b/src/wallet/wallet.ts @@ -18,6 +18,7 @@ import { WALLET_SERVICE_AUTH_DERIVATION_PATH, P2SH_ACCT_PATH, P2PKH_ACCT_PATH, + GAP_LIMIT, } from '../constants'; import { decryptData, signMessage } from '../utils/crypto'; import walletApi from './api/walletApi'; @@ -78,6 +79,7 @@ import { WalletFromXPubGuard, PinRequiredError, TokenNotFoundError, + HasTxOutsideFirstAddressError, } from '../errors'; import NanoContractTransactionBuilder from '../nano_contracts/builder'; import NanoContractHeader from '../nano_contracts/header'; @@ -100,6 +102,8 @@ import { WalletType, ITokenData, TokenVersion, + SCANNING_POLICY, + WalletAddressMode, } from '../types'; import { Fee } from '../utils/fee'; @@ -141,12 +145,6 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { // State of the wallet. One of the walletState enum options private state: string; - // Variable to prevent start sending more than one tx concurrently - private isSendingTx: boolean; - - // ID of tx proposal - private txProposalId: string | null; - // Auth token to be used in the wallet API requests to wallet service private authToken: string | null; @@ -166,6 +164,12 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { // Flag to indicate if the websocket connection is enabled private readonly _isWsEnabled: boolean; + // Flag to indicate if the wallet is in single-address mode + private singleAddress: boolean; + + // Address at index 0 + private firstAddress: string | null; + public storage: IStorage; constructor({ @@ -178,6 +182,7 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { passphrase = '', enableWs = true, storage = null, + singleAddressMode = false, }: { requestPassword: () => Promise; seed?: string | null; @@ -188,6 +193,7 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { passphrase?: string; enableWs?: boolean; storage?: IStorage | null; + singleAddressMode?: boolean; }) { super(); @@ -238,8 +244,6 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { // ID of wallet after created on wallet service this.walletId = null; - this.isSendingTx = false; - this.txProposalId = null; this.network = network; networkInstance.setNetwork(this.network.name); @@ -249,6 +253,9 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { this.newAddresses = []; this.indexToUse = -1; + this.singleAddress = singleAddressMode; + this.firstAddress = null; + // TODO should we have a debug mode? } @@ -459,6 +466,7 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { firstAddress, authDerivedPrivKey, } = await this.generateCreateWalletAuthData(accessData, pinCode); + this.firstAddress = firstAddress; if (!renewPromise) { // we need to start the process to renew the auth token If for any reason we had to @@ -803,6 +811,21 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { * @inner */ private async onWalletReady(skipAddressFetch: boolean = false) { + if (this.singleAddress) { + try { + await this.enableSingleAddressMode(true); + } catch (error) { + if (error instanceof HasTxOutsideFirstAddressError) { + await this.storage.setScanningPolicyData({ + policy: SCANNING_POLICY.GAP_LIMIT, + gapLimit: GAP_LIMIT, + }); + this.singleAddress = false; + } else { + throw error; + } + } + } // We should wait for new addresses before setting wallet to ready // Skip address fetching if requested (useful for read-only wallets that don't need gap addresses) if (!skipAddressFetch) { @@ -881,6 +904,10 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { // asynchronous, so we will get an empty or partial array of addresses if they are not all loaded. this.failIfWalletNotReady(); } + if (this.singleAddress) { + await this.prepareSingleAddressMode(); + return; + } const data = await walletApi.getNewAddresses(this); this.newAddresses = data.addresses; this.indexToUse = 0; @@ -1237,6 +1264,9 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { // Derive walletId from xpub const walletId = HathorWalletServiceWallet.getWalletIdFromXPub(this.xpub); this.walletId = walletId; + if (this.singleAddress) { + await this.storage.setScanningPolicyData({ policy: SCANNING_POLICY.SINGLE_ADDRESS }); + } // Save accessData for read-only wallet // This is required for methods like getAddressPathForIndex() that need walletType @@ -1474,6 +1504,10 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { * @inner */ getCurrentAddress({ markAsUsed = false } = {}): AddressInfoObject { + if (this.singleAddress) { + return this.newAddresses[0]; + } + const newAddressesLen = this.newAddresses.length; if (this.indexToUse > newAddressesLen - 1) { const addressInfo = this.newAddresses[newAddressesLen - 1]; @@ -1508,11 +1542,97 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { * @inner */ getNextAddress(): AddressInfoObject { + if (this.singleAddress) { + return this.newAddresses[0]; + } // First we mark the current address as used, then return the next this.getCurrentAddress({ markAsUsed: true }); return this.getCurrentAddress(); } + /** + * Get which address mode is currently enabled. + * + * @memberof HathorWalletServiceWallet + * @inner + */ + async getAddressMode(): Promise { + this.failIfWalletNotReady(); + + if (this.singleAddress) { + return WalletAddressMode.SINGLE; + } + return WalletAddressMode.MULTI; + } + + /** + * Enable multi address mode for this wallet. + * Converts to a gap-limit wallet. + * + * @memberof HathorWalletServiceWallet + * @inner + */ + async enableMultiAddressMode(): Promise { + this.failIfWalletNotReady(); + + // Switch policy in storage + await this.storage.setScanningPolicyData({ + policy: SCANNING_POLICY.GAP_LIMIT, + gapLimit: GAP_LIMIT, + }); + + this.singleAddress = false; + await this.getNewAddresses(); + } + + /** + * Enable single-address scanning policy for this wallet. + * Converts a gap-limit wallet to use only the first address (index 0). + * Will throw if the wallet has transactions on any address other than the first. + * + * @memberof HathorWalletServiceWallet + * @inner + */ + async enableSingleAddressMode(ignoreWalletReady: boolean = false): Promise { + if (await this.hasTxOutsideFirstAddress(ignoreWalletReady)) { + throw new HasTxOutsideFirstAddressError( + 'Cannot enable single-address policy: wallet has transactions on addresses other than the first' + ); + } + + // Switch policy in storage + await this.storage.setScanningPolicyData({ + policy: SCANNING_POLICY.SINGLE_ADDRESS, + }); + + this.singleAddress = true; + await this.prepareSingleAddressMode(); + } + + /** + * Prepare the wallet for single address mode. + * Loads first address if needed. + * + * @memberof HathorWalletServiceWallet + * @inner + */ + private async prepareSingleAddressMode(): Promise { + if (!this.singleAddress) return; + + if (!this.firstAddress) { + this.firstAddress = await this.getAddressAtIndex(0); + } + + this.newAddresses = [ + { + address: this.firstAddress, + index: 0, + addressPath: await this.getAddressPathForIndex(0), + }, + ]; + this.indexToUse = 0; + } + /** * Get the address index of a base58 address * @@ -3044,8 +3164,13 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { * @memberof HathorWalletServiceWallet * @inner */ - async hasTxOutsideFirstAddress(): Promise { - this.failIfWalletNotReady(); + async hasTxOutsideFirstAddress(ignoreWalletReady: boolean = false): Promise { + // If the user is sure the wallet service has already loaded his wallet, he can ignore the check + if (!ignoreWalletReady) { + // We should fail if the wallet is not ready because the wallet service address load mechanism is + // asynchronous, so we will get an empty or partial array of addresses if they are not all loaded. + this.failIfWalletNotReady(); + } const data = await walletApi.getHasTxOutsideFirstAddress(this); return data.hasTransactions; }