From b7f32d5f812822e29fb66a4a704424d109345de3 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Wed, 25 Feb 2026 07:27:15 -0800 Subject: [PATCH 1/4] Skip emit for already-known UTXO transactions On every start(), the UTXO engine re-queried blockbook for every address and re-emitted every returned transaction even if it already existed on disk unchanged. Skipping the emit when existingTx exists and its blockHeight hasn't changed eliminated 199+ redundant onTransactions calls per wallet on restart. Also computes confirmations directly from blockHeight in toEdgeTransaction so UTXO txs report confirmed/unconfirmed correctly without waiting for onBlockHeightChanged. --- src/common/utxobased/db/Models/TransactionData.ts | 15 +++++++++++++-- .../utxobased/engine/UtxoEngineProcessor.ts | 9 ++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/common/utxobased/db/Models/TransactionData.ts b/src/common/utxobased/db/Models/TransactionData.ts index 19e17b13..e97cbbc2 100644 --- a/src/common/utxobased/db/Models/TransactionData.ts +++ b/src/common/utxobased/db/Models/TransactionData.ts @@ -1,7 +1,11 @@ import { Transaction } from 'altcoin-js' import { lt } from 'biggystring' import BN from 'bn.js' -import { EdgeTransaction, JsonObject } from 'edge-core-js/types' +import { + EdgeConfirmationState, + EdgeTransaction, + JsonObject +} from 'edge-core-js/types' import { PluginInfo } from '../../../plugin/types' import { UtxoTxOtherParams } from '../../engine/types' @@ -91,9 +95,16 @@ export const toEdgeTransaction = async ( } } catch (e) {} + // Calculate confirmations: for UTXO chains with requiredConfirmations = 1 (default), + // any transaction in a block is immediately confirmed + let confirmations: EdgeConfirmationState = tx.confirmations ?? 'unconfirmed' + if (confirmations === 'unconfirmed' || confirmations === undefined) { + confirmations = tx.blockHeight > 0 ? 'confirmed' : 'unconfirmed' + } + return { blockHeight: tx.blockHeight, - confirmations: tx.confirmations, + confirmations, currencyCode: currencyInfo.currencyCode, date: tx.date, feeRateUsed, diff --git a/src/common/utxobased/engine/UtxoEngineProcessor.ts b/src/common/utxobased/engine/UtxoEngineProcessor.ts index 6f6b16fa..03af74d5 100644 --- a/src/common/utxobased/engine/UtxoEngineProcessor.ts +++ b/src/common/utxobased/engine/UtxoEngineProcessor.ts @@ -1261,9 +1261,12 @@ async function* processAddressForTransactions( // The tx unconfirmed or confirmed after/at the last seenTxCheckpoint (tx.blockHeight === 0 || tx.blockHeight > seenTxBlockHeight) - common.emitter.emit(EngineEvent.TRANSACTIONS, [ - { isNew, transaction: edgeTx } - ]) + // Only emit if tx is new or changed (blockHeight changed) + if (existingTx == null || existingTx.blockHeight !== tx.blockHeight) { + common.emitter.emit(EngineEvent.TRANSACTIONS, [ + { isNew, transaction: edgeTx } + ]) + } if (edgeTx.blockHeight > common.maxSeenTxBlockHeight) { common.maxSeenTxBlockHeight = edgeTx.blockHeight From 1b7cf464b4f1be1991dc07f84739833da4243afd Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Wed, 25 Feb 2026 07:28:50 -0800 Subject: [PATCH 2/4] Don't reset processedPercent on start() if already at 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start() unconditionally reset processedCount and processedPercent to zero, causing the sync ratio to walk 0%→100% on every login and emitting ADDRESSES_CHECKED for each increment. Guarding the reset prevents redundant progress walks within the same session. Also adds a high-water-mark guard to updateProgressRatio to prevent progress from going backwards when setLookAhead inflates the denominator. --- src/common/utxobased/engine/UtxoEngineProcessor.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/common/utxobased/engine/UtxoEngineProcessor.ts b/src/common/utxobased/engine/UtxoEngineProcessor.ts index 03af74d5..9882b763 100644 --- a/src/common/utxobased/engine/UtxoEngineProcessor.ts +++ b/src/common/utxobased/engine/UtxoEngineProcessor.ts @@ -157,7 +157,7 @@ export function makeUtxoEngineProcessor( **/ const processesPerAddress = 2 let processedCount = 0 - let processedPercent = 0 // last sync ratio emitted + let processedPercent = 0 // in-memory high-water-mark; resets to 0 on engine creation const updateProgressRatio = async (): Promise => { // Avoid re-sending sync ratios / sending ratios larger than 1 if (processedPercent >= 1) return @@ -174,6 +174,12 @@ export function makeUtxoEngineProcessor( if (expectedProcessCount === 0) throw new Error('No addresses to process') const percent = processedCount / expectedProcessCount + + // High-water-mark: never report progress going backwards. This can happen + // when setLookAhead discovers new addresses and inflates expectedProcessCount, + // or when start() is called again (pause/unpause) and processedCount resets. + if (percent <= processedPercent) return + if (percent - processedPercent > CACHE_THROTTLE || percent === 1) { log( `processed changed, percent: ${percent}, processedCount: ${processedCount}, totalCount: ${expectedProcessCount}` @@ -360,8 +366,11 @@ export function makeUtxoEngineProcessor( return { processedPercent, async start(): Promise { + // Reset the count so the address walk restarts, but keep processedPercent + // as an in-memory high-water-mark. This prevents the reported ratio from + // rolling backwards if start() is called again (pause/unpause) or if + // setLookAhead discovers new addresses and inflates the denominator. processedCount = 0 - processedPercent = 0 await run() serverStates.refillServers() From e0ca938b64d9bce18abb133ef0638e8b1517cee5 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Wed, 25 Feb 2026 07:30:07 -0800 Subject: [PATCH 3/4] Add typed dedup emit methods to EngineEmitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EngineEmitter had zero deduplication — BLOCK_HEIGHT_CHANGED, WALLET_BALANCE_CHANGED, and ADDRESSES_CHECKED all fired unconditionally. Adding last-value tracking via typed emit methods (emitBlockHeightChanged, emitWalletBalanceChanged, emitAddressesChecked) suppresses duplicate emissions for block height, balance, and sync ratio. Also stops forwarding BLOCK_HEIGHT_CHANGED to core-js via onBlockHeightChanged; UTXO txs now set confirmations directly so the callback is no longer needed. --- src/common/plugin/EngineEmitter.ts | 40 +++++++++++++++---- src/common/plugin/Metadata.ts | 6 +-- src/common/utxobased/engine/ServerStates.ts | 2 +- .../utxobased/engine/UtxoEngineProcessor.ts | 2 +- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/common/plugin/EngineEmitter.ts b/src/common/plugin/EngineEmitter.ts index 72d0c02e..67d9f87c 100644 --- a/src/common/plugin/EngineEmitter.ts +++ b/src/common/plugin/EngineEmitter.ts @@ -92,7 +92,36 @@ export declare interface EngineEmitter { listener: (txids: EdgeTxidMap) => Promise | void ) => this) } -export class EngineEmitter extends EventEmitter {} +export class EngineEmitter extends EventEmitter { + private lastBlockHeight?: number + private lastWalletBalance?: string + private lastAddressesCheckedRatio?: number + + emitBlockHeightChanged(uri: string, blockHeight: number): boolean { + if (this.lastBlockHeight === blockHeight) return false + this.lastBlockHeight = blockHeight + return super.emit(EngineEvent.BLOCK_HEIGHT_CHANGED, uri, blockHeight) + } + + emitWalletBalanceChanged( + currencyCode: string, + nativeBalance: string + ): boolean { + if (this.lastWalletBalance === nativeBalance) return false + this.lastWalletBalance = nativeBalance + return super.emit( + EngineEvent.WALLET_BALANCE_CHANGED, + currencyCode, + nativeBalance + ) + } + + emitAddressesChecked(progressRatio: number): boolean { + if (this.lastAddressesCheckedRatio === progressRatio) return false + this.lastAddressesCheckedRatio = progressRatio + return super.emit(EngineEvent.ADDRESSES_CHECKED, progressRatio) + } +} export enum EngineEvent { SEEN_TX_CHECKPOINT = 'seen:tx:checkpoint', @@ -116,12 +145,9 @@ export const makeEngineEmitter = ( const emitter = new EngineEmitter() emitter.on(EngineEvent.ADDRESSES_CHECKED, callbacks.onAddressesChecked) - emitter.on( - EngineEvent.BLOCK_HEIGHT_CHANGED, - (_uri: string, height: number) => { - callbacks.onBlockHeightChanged(height) - } - ) + // BLOCK_HEIGHT_CHANGED is used internally (e.g. to check unconfirmed txs) + // but is no longer forwarded to core-js. UTXO txs already set + // confirmations directly, so onBlockHeightChanged is a no-op in core-js. emitter.on(EngineEvent.SEEN_TX_CHECKPOINT, callbacks.onSeenTxCheckpoint) emitter.on(EngineEvent.TRANSACTIONS, callbacks.onTransactions) emitter.on(EngineEvent.TRANSACTIONS_CHANGED, callbacks.onTransactionsChanged) diff --git a/src/common/plugin/Metadata.ts b/src/common/plugin/Metadata.ts index bec6d8cf..3c74560e 100644 --- a/src/common/plugin/Metadata.ts +++ b/src/common/plugin/Metadata.ts @@ -46,11 +46,7 @@ export const makeMetadata = async ( ) cache.balance = cumulativeBalance await setMetadata(cache) - emitter.emit( - EngineEvent.WALLET_BALANCE_CHANGED, - currencyCode, - cumulativeBalance - ) + emitter.emitWalletBalanceChanged(currencyCode, cumulativeBalance) } emitter.on( diff --git a/src/common/utxobased/engine/ServerStates.ts b/src/common/utxobased/engine/ServerStates.ts index 18d289ab..ad936fb9 100644 --- a/src/common/utxobased/engine/ServerStates.ts +++ b/src/common/utxobased/engine/ServerStates.ts @@ -309,7 +309,7 @@ export function makeServerStates(config: ServerStateConfig): ServerStates { serverStatesCache[uri].blockHeight = blockHeight // Emit initial BLOCK_HEIGHT_CHANGED event - engineEmitter.emit(EngineEvent.BLOCK_HEIGHT_CHANGED, uri, blockHeight) + engineEmitter.emitBlockHeightChanged(uri, blockHeight) // Increment server score using response time const responseTime = Date.now() - startTime diff --git a/src/common/utxobased/engine/UtxoEngineProcessor.ts b/src/common/utxobased/engine/UtxoEngineProcessor.ts index 9882b763..758a3ca2 100644 --- a/src/common/utxobased/engine/UtxoEngineProcessor.ts +++ b/src/common/utxobased/engine/UtxoEngineProcessor.ts @@ -186,7 +186,7 @@ export function makeUtxoEngineProcessor( ) processedPercent = percent common.updateSeenTxCheckpoint() - emitter.emit(EngineEvent.ADDRESSES_CHECKED, percent) + emitter.emitAddressesChecked(percent) } } From 6a886a2bdc0a4f4b58395ebb30fbe9f49f78bfd9 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Wed, 25 Feb 2026 08:30:06 -0800 Subject: [PATCH 4/4] Add global throttle on emitAddressesChecked (500ms) Even with per-engine dedup, multiple UTXO engines syncing simultaneously produced frequent onAddressesChecked callbacks. A global rate limiter (max 1 per 500ms, ratio=1 always passes) reduces aggregate sync-progress volume across all wallets. --- src/common/plugin/EngineEmitter.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/common/plugin/EngineEmitter.ts b/src/common/plugin/EngineEmitter.ts index 67d9f87c..b77351e0 100644 --- a/src/common/plugin/EngineEmitter.ts +++ b/src/common/plugin/EngineEmitter.ts @@ -92,6 +92,9 @@ export declare interface EngineEmitter { listener: (txids: EdgeTxidMap) => Promise | void ) => this) } +// Global throttle: max 1 emitAddressesChecked per 500ms; ratio=1 always passes. +let acLastEmitTime = 0 + export class EngineEmitter extends EventEmitter { private lastBlockHeight?: number private lastWalletBalance?: string @@ -119,6 +122,13 @@ export class EngineEmitter extends EventEmitter { emitAddressesChecked(progressRatio: number): boolean { if (this.lastAddressesCheckedRatio === progressRatio) return false this.lastAddressesCheckedRatio = progressRatio + + if (progressRatio !== 1) { + const now = Date.now() + if (now - acLastEmitTime < 500) return false + acLastEmitTime = now + } + return super.emit(EngineEvent.ADDRESSES_CHECKED, progressRatio) } }