Fix wallet sync performance cliff at ~9000 blocks, fix node switching

Yield to event loop every 5 blocks (was 20) to prevent UI freeze on
  mobile during batch processing.

  Preserve WalletSync instance across setDaemon() calls — update daemon
  reference instead of destroying sync state, listeners, and progress.
  Stop in-progress sync cleanly before switching.

  Replace bytesToHex Array.from().map().join() with pre-computed lookup
  table across all hot paths (address.js, backend-wasm.js, mining.js,
  subaddress.js). Eliminates ~400k intermediate allocations per full sync.

  Add binary Uint8Array comparison for main address (0,0) in both CN and
  CARROT scanning — skips hex conversion for 99%+ of non-owned outputs.

  Add duplicate listener check in WalletSync.on() to prevent callback
  accumulation on sync restart.
This commit is contained in:
Matt Hess
2026-02-10 18:48:13 +00:00
parent 83f56bf269
commit 293e6f6b96
8 changed files with 109 additions and 28 deletions
+10 -1
View File
@@ -400,8 +400,17 @@ export function describeAddress(address) {
* @param {Uint8Array} bytes - Bytes to convert
* @returns {string} - Hex string
*/
// Pre-computed hex lookup table — avoids Array.from().map().join() per call
const _hexLUT = /* @__PURE__ */ (() => {
const t = new Array(256);
for (let i = 0; i < 256; i++) t[i] = i.toString(16).padStart(2, '0');
return t;
})();
export function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += _hexLUT[bytes[i]];
return hex;
}
/**
+9 -6
View File
@@ -672,19 +672,22 @@ export function scanCarrotOutput(output, viewIncomingKey, accountSpendPubkey, in
// Need amount commitment for this - if not provided, use zero commitment
const commitment = amountCommitment || new Uint8Array(32);
const recoveredSpendPubkey = recoverAddressSpendPubkey(onetimeAddress, senderReceiverCtx, commitment);
const recoveredSpendPubkeyHex = bytesToHex(recoveredSpendPubkey);
// 5. Check if this matches our account or any subaddress
let subaddressIndex = null;
let isMainAddress = false;
// Check main address (account spend pubkey)
if (bytesToHex(accountSpendPubkey) === recoveredSpendPubkeyHex) {
// Check main address first with direct binary comparison (no hex allocation)
if (recoveredSpendPubkey.length === accountSpendPubkey.length &&
recoveredSpendPubkey.every((b, i) => b === accountSpendPubkey[i])) {
isMainAddress = true;
subaddressIndex = { major: 0, minor: 0 };
} else if (subaddressMap && subaddressMap.has(recoveredSpendPubkeyHex)) {
// Check subaddresses
subaddressIndex = subaddressMap.get(recoveredSpendPubkeyHex);
} else if (subaddressMap) {
// Subaddress map uses hex keys — only convert if main address didn't match
const recoveredSpendPubkeyHex = bytesToHex(recoveredSpendPubkey);
if (subaddressMap.has(recoveredSpendPubkeyHex)) {
subaddressIndex = subaddressMap.get(recoveredSpendPubkeyHex);
}
}
if (!subaddressIndex) {
+5 -1
View File
@@ -298,8 +298,12 @@ function hexToBytes(hex) {
return bytes;
}
const _hexLUT = new Array(256);
for (let i = 0; i < 256; i++) _hexLUT[i] = i.toString(16).padStart(2, '0');
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += _hexLUT[bytes[i]];
return hex;
}
function ensureBytes(v) {
+5 -3
View File
@@ -517,10 +517,12 @@ function hexToBytes(hex) {
/**
* Convert bytes to hex string
*/
const _hexLUT = new Array(256);
for (let i = 0; i < 256; i++) _hexLUT[i] = i.toString(16).padStart(2, '0');
function bytesToHex(bytes) {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += _hexLUT[bytes[i]];
return hex;
}
// =============================================================================
+8 -2
View File
@@ -336,13 +336,19 @@ export function isValidPaymentId(paymentId) {
export const SUBADDRESS_LOOKAHEAD_MAJOR = 50;
export const SUBADDRESS_LOOKAHEAD_MINOR = 200;
// Pre-computed hex lookup table — avoids Array.from().map().join() per call
const _hexLUT = new Array(256);
for (let i = 0; i < 256; i++) _hexLUT[i] = i.toString(16).padStart(2, '0');
/**
* Convert bytes to hex string
* Convert bytes to hex string (fast path using lookup table)
* @param {Uint8Array} bytes
* @returns {string}
*/
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += _hexLUT[bytes[i]];
return hex;
}
/**
+30 -9
View File
@@ -138,6 +138,14 @@ export class WalletSync {
this.carrotSubaddresses = options.carrotSubaddresses || new Map();
this.batchSize = options.batchSize || DEFAULT_BATCH_SIZE;
// Cache main spend pubkey as Uint8Array for fast binary comparison in scanning
// (avoids hex conversion for the 99%+ of outputs that aren't ours)
this._mainSpendPubKeyBytes = this.keys?.spendPublicKey
? (this.keys.spendPublicKey instanceof Uint8Array
? this.keys.spendPublicKey
: hexToBytes(this.keys.spendPublicKey))
: null;
// State
this.status = SYNC_STATUS.IDLE;
this.currentHeight = 0;
@@ -164,7 +172,13 @@ export class WalletSync {
* @param {Function} callback - Callback function
*/
on(event, callback) {
this._listeners.push({ event, callback });
// Prevent duplicate listeners (e.g., from sync restart after node switch)
const exists = this._listeners.some(
l => l.event === event && l.callback === callback
);
if (!exists) {
this._listeners.push({ event, callback });
}
}
/**
@@ -522,7 +536,8 @@ export class WalletSync {
this.currentHeight = header.height + 1;
this._emit('syncProgress', this.getProgress());
if (idx % 20 === 19) {
// Yield to event loop every 5 blocks to prevent UI freeze on mobile
if (idx % 5 === 4) {
await new Promise(r => setTimeout(r, 0));
}
}
@@ -657,7 +672,8 @@ export class WalletSync {
this.currentHeight = header.height + 1;
this._emit('syncProgress', this.getProgress());
if (idx % 20 === 19) {
// Yield to event loop every 5 blocks to prevent UI freeze on mobile
if (idx % 5 === 4) {
await new Promise(r => setTimeout(r, 0));
}
}
@@ -1645,15 +1661,20 @@ export class WalletSync {
return null;
}
const derivedSpendPubKeyHex = bytesToHex(derivedSpendPubKey);
// Look up in subaddress map
// Check main address first with binary comparison (avoids hex allocation for 99%+ of outputs)
let subaddressIndex = null;
if (this.subaddresses && this.subaddresses.has(derivedSpendPubKeyHex)) {
subaddressIndex = this.subaddresses.get(derivedSpendPubKeyHex);
if (this._mainSpendPubKeyBytes &&
derivedSpendPubKey.length === this._mainSpendPubKeyBytes.length &&
derivedSpendPubKey.every((b, i) => b === this._mainSpendPubKeyBytes[i])) {
subaddressIndex = { major: 0, minor: 0 };
} else if (this.subaddresses) {
// Fall back to hex map lookup only for non-main-address outputs
const derivedSpendPubKeyHex = bytesToHex(derivedSpendPubKey);
if (this.subaddresses.has(derivedSpendPubKeyHex)) {
subaddressIndex = this.subaddresses.get(derivedSpendPubKeyHex);
}
}
if (!subaddressIndex) {
return null; // Not our output
}
+10 -1
View File
@@ -1981,7 +1981,16 @@ export class Wallet {
*/
setDaemon(daemon) {
this._daemon = daemon;
this._walletSync = null; // reset so next sync picks up new daemon
// Update daemon reference on existing WalletSync instead of destroying it.
// Destroying _walletSync discards sync state, listeners, and cached derivations,
// forcing a full re-sync and breaking event subscriptions.
if (this._walletSync) {
// Stop any in-progress sync so it picks up the new daemon cleanly
if (this._walletSync.status === 'syncing') {
this._walletSync.stop();
}
this._walletSync.daemon = daemon;
}
}
/** @private Lazy-init MemoryStorage */
+32 -5
View File
@@ -426,15 +426,40 @@ async function runIntegrationTest() {
const transactions = await storage.getTransactions();
const syncHeight = await storage.getSyncHeight();
// Calculate balance
// Calculate balance with locked/unlocked split
const DEFAULT_UNLOCK_BLOCKS = 10;
let balance = 0n;
let unlockedBalance = 0n;
let unspentCount = 0;
let unlockedCount = 0;
for (const output of outputs) {
if (!output.isSpent) {
balance += output.amount;
const amount = typeof output.amount === 'bigint' ? output.amount : BigInt(output.amount);
balance += amount;
unspentCount++;
// Check unlock status (mirrors WalletOutput.isUnlocked logic)
const unlockTime = BigInt(output.unlockTime || 0);
let isUnlocked = false;
if (unlockTime === 0n) {
// Standard: unlocked after DEFAULT_UNLOCK_BLOCKS confirmations
isUnlocked = output.blockHeight != null &&
(syncHeight - output.blockHeight) >= DEFAULT_UNLOCK_BLOCKS;
} else if (unlockTime < 500000000n) {
// Unlock time is a block height
isUnlocked = syncHeight >= Number(unlockTime);
} else {
// Unlock time is a Unix timestamp
isUnlocked = Date.now() / 1000 >= Number(unlockTime);
}
if (isUnlocked) {
unlockedBalance += amount;
unlockedCount++;
}
}
}
const lockedBalance = balance - unlockedBalance;
// Report results
console.log('\n' + '='.repeat(60));
@@ -446,10 +471,12 @@ async function runIntegrationTest() {
console.log(`Final sync height: ${syncHeight}`);
console.log('');
console.log(`Outputs found: ${outputs.length}`);
console.log(`Unspent outputs: ${unspentCount}`);
console.log(`Unspent outputs: ${unspentCount} (${unlockedCount} unlocked, ${unspentCount - unlockedCount} locked)`);
console.log(`Transactions: ${transactions.length}`);
console.log(`Balance: ${balance} atomic units`);
console.log(`Balance (SAL): ${Number(balance) / 1e8} SAL`);
console.log('');
console.log(`Total balance: ${balance} atomic (${(Number(balance) / 1e8).toFixed(8)} SAL)`);
console.log(`Unlocked balance: ${unlockedBalance} atomic (${(Number(unlockedBalance) / 1e8).toFixed(8)} SAL)`);
console.log(`Locked balance: ${lockedBalance} atomic (${(Number(lockedBalance) / 1e8).toFixed(8)} SAL)`);
// List outputs
if (outputs.length > 0) {