Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
6d26b76
feat: single address policy
r4mmer Mar 18, 2026
66c3c0c
chore: linter changes
r4mmer Mar 18, 2026
48a221e
Merge branch 'master' into feat/single-address-policy-2
r4mmer Mar 18, 2026
e673ced
feat: single address mode integration tests
r4mmer Mar 19, 2026
267fcae
Merge branch 'master' into feat/single-address-policy-2
r4mmer Mar 19, 2026
ff03e0a
tests: fix test mocks
r4mmer Mar 19, 2026
ea66393
tests: add mock for address path
r4mmer Mar 19, 2026
af7e1c8
tests: check against correct address set
r4mmer Mar 19, 2026
8cd9a94
feat: do not start single address wallet without the guard
r4mmer Mar 20, 2026
8066d30
feat: single address scanning policy is default
r4mmer Mar 23, 2026
f9ff0c9
chore: linter changes
r4mmer Mar 23, 2026
054d0e4
tests(integration): new method tests for fullnode facade
r4mmer Mar 23, 2026
579369a
chore: linter changes
r4mmer Mar 23, 2026
d6e67b9
tests(integration): move tests to make genesis injection work
r4mmer Mar 23, 2026
1a5ef0f
feat: fullnode facade also rejects starting single mode with tx outsi…
r4mmer Mar 23, 2026
bddcd94
tests: has tx outside index 0 tests
r4mmer Mar 23, 2026
3da5c83
Merge remote-tracking branch 'origin/master' into feat/single-address…
r4mmer Mar 24, 2026
2a75a7b
feat: do not drift storage and wallet states
r4mmer Mar 24, 2026
6cdf0f3
Merge remote-tracking branch 'origin/master' into feat/single-address…
r4mmer Mar 26, 2026
9c6c3e0
tests(integration): old tests should start in multi address mode
r4mmer Mar 26, 2026
ba6557a
tests(integration): linter changes
r4mmer Mar 26, 2026
179c724
tests(integration): start old tests in multi address mode
r4mmer Mar 31, 2026
04c1025
chore: check wallet is in multi address mode
r4mmer Mar 31, 2026
5482a39
chore: add multi address mode option
r4mmer Mar 31, 2026
476c21f
Merge branch 'master' into feat/single-address-policy-2
r4mmer Mar 31, 2026
fa07cf3
tests(integration): actually use single address mode off on common wa…
r4mmer Mar 31, 2026
5a96ca4
tests: gap limit scan policy helper
r4mmer Apr 1, 2026
963e74a
feat: use GAP_LIMIT constant
r4mmer Apr 1, 2026
fb247e2
tests(integration): shared tests for single address mode
r4mmer Apr 1, 2026
fb7298e
chore: linter changes
r4mmer Apr 1, 2026
0037cb3
tests(integration): test wallet does not leave single address mode
r4mmer Apr 1, 2026
dd5fba1
tests(integration): remove test receiving tx in unexpected address
r4mmer Apr 1, 2026
03ede79
Merge branch 'master' into feat/single-address-policy-2
r4mmer Apr 2, 2026
cd25752
tests(integration): remove duplicated test
r4mmer Apr 2, 2026
3cd8fc0
Merge branch 'master' into feat/single-address-policy-2
r4mmer Apr 2, 2026
ea94e83
chore: remove unused imports
r4mmer Apr 6, 2026
a00b7a7
Merge remote-tracking branch 'origin/master' into feat/single-address…
r4mmer Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion __tests__/integration/adapters/fullnode.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
};
}
}
1 change: 1 addition & 0 deletions __tests__/integration/adapters/service.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions __tests__/integration/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface CreateWalletOptions {
numSignatures: number;
};
tokenUid?: string;
singleAddressMode?: boolean;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion __tests__/integration/fullnode-specific/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -211,6 +213,7 @@ describe('[Fullnode-specific] start', () => {
pubkeys: multisigWalletsData.pubkeys,
numSignatures: 3,
},
scanPolicy: getGapLimitConfig(),
};

const hWallet = new HathorWallet(walletConfig);
Expand Down
3 changes: 2 additions & 1 deletion __tests__/integration/helpers/genesis-wallet.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -53,6 +53,7 @@ export class GenesisWalletHelper {
pinCode: pin,
multisig: null,
preCalculatedAddresses: WALLET_CONSTANTS.genesis.addresses,
scanPolicy: getGapLimitConfig(),
});
await this.hWallet.start();

Expand Down
2 changes: 2 additions & 0 deletions __tests__/integration/helpers/service-facade.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function buildWalletInstance({
words = '',
xpub = '',
passwordForRequests = 'test-password',
singleAddressMode = false,
} = {}) {
let addresses: string[] = [];

Expand Down Expand Up @@ -82,6 +83,7 @@ export function buildWalletInstance({
network,
storage,
enableWs,
singleAddressMode,
});

return { wallet: newWallet, store, storage, words, addresses };
Expand Down
5 changes: 4 additions & 1 deletion __tests__/integration/helpers/wallet.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
162 changes: 161 additions & 1 deletion __tests__/integration/shared/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 ---
Expand All @@ -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();
}
});
});
});
2 changes: 2 additions & 0 deletions __tests__/integration/storage/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand Down Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions __tests__/integration/utils/core.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 };
}
Loading
Loading