Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion src/algorand/AlgorandEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ export class AlgorandEngine extends CurrencyEngine<
}

if (detectedTokenIds.length > 0) {
this.currencyEngineCallbacks.onNewTokens(detectedTokenIds)
this.reportDetectedTokens(detectedTokenIds)
}

this.updateBlockHeight(round)
Expand Down
26 changes: 24 additions & 2 deletions src/common/CurrencyEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
EdgeLog,
EdgeMetaToken,
EdgeSpendInfo,
EdgeStakingStatus,
EdgeSubscribedAddress,
EdgeSyncStatus,
EdgeToken,
Expand Down Expand Up @@ -196,6 +197,7 @@ export class CurrencyEngine<
publicKey: '',
totalBalances: {},
numTransactions: {},
detectedTokenIds: {},
unactivatedTokenIds: [],
otherData: undefined
}
Expand Down Expand Up @@ -655,6 +657,24 @@ export class CurrencyEngine<
this.syncTracker.balanceComplete?.(tokenId)
}

private lastStakingStatusJson: string = ''

protected reportStakingStatus(status: EdgeStakingStatus): void {
const json = JSON.stringify(status)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: JSON.stringify for reportStakingStatus comparison is technically sensitive to key ordering. While V8 uses insertion order, the spec doesn't fully guarantee it across all JS engines (e.g. Hermes, JSC in React Native).

Recommendation: Consider a shallow-comparison helper or fast-deep-equal (if in the dependency tree) for more robust change detection.

if (json === this.lastStakingStatusJson) return
this.lastStakingStatusJson = json
this.currencyEngineCallbacks.onStakingStatusChanged(status)
}

reportDetectedTokens(tokenIds: string[]): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: reportDetectedTokens has implicit public visibility, while the adjacent reportStakingStatus (line 662) is protected. Inconsistent access modifiers.

Recommendation: Mark reportDetectedTokens as public explicitly (since EthereumNetwork.ts accesses it from outside the class hierarchy).

const known = this.walletLocalData.detectedTokenIds
const newTokenIds = tokenIds.filter(id => known[id] == null)
if (newTokenIds.length === 0) return
for (const id of newTokenIds) known[id] = true
this.walletLocalDataDirty = true
this.currencyEngineCallbacks.onNewTokens(Object.keys(known))
}
Comment on lines +669 to +676

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: No test coverage for reportDetectedTokens deduplication logic (only truly new token IDs trigger onNewTokens, repeated calls are no-ops, cumulative set is always emitted).

Recommendation: Add unit tests covering deduplication, cumulative emission, and walletLocalDataDirty flag.


updateConfirmations(tx: EdgeTransaction): boolean {
// No update needed for these status
switch (tx.confirmations) {
Expand Down Expand Up @@ -717,10 +737,12 @@ export class CurrencyEngine<

this.walletLocalData.blockHeight = blockHeight
this.walletLocalDataDirty = true
this.currencyEngineCallbacks.onBlockHeightChanged(blockHeight)

// Update confirmations directly on all in-memory transactions and emit
// any that changed via onTransactions. Confirmations are owned by the engine;
// core-js learns of changes only when we send txs with updated confirmations
// via onTransactions (i.e. the deprecated onBlockHeightChanged is not called).
const activeTokenIds = [null, ...this.enabledTokenIds]

for (const tokenId of activeTokenIds) {
const txList = this.transactionList[tokenId ?? ''] ?? []
for (let i = 0; i < txList.length; i++) {
Expand Down
31 changes: 27 additions & 4 deletions src/common/SyncTracker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { EdgeSyncStatus, EdgeTokenId } from 'edge-core-js/types'

// Global throttle: max 1 sendSyncStatus per 500ms; totalRatio=1 always passes.
let ssLastEmitTime = 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: Global throttle shared across all wallet engines. ssLastEmitTime is module-level, so if wallet A emits at T=0, wallet B is blocked until T=500ms. This couples independent wallets' sync status reporting and can cause stale progress bars during multi-wallet sync (e.g. initial login with many wallets).

Recommendation: Move ssLastEmitTime into the per-tracker closure (next to lastSyncStatus on line 49), so each wallet engine has its own independent 500ms throttle. If a global cross-wallet limit is truly desired, document it explicitly.


/**
* Abstracts the ability to return a sync status,
* since different chains track their sync status in different ways.
Expand Down Expand Up @@ -43,6 +46,7 @@ export function makeTokenSyncTracker(engine: SyncEngine): TokenSyncTracker {
// Each tokenId can be a 0-1 value:
const balanceRatios = new Map<EdgeTokenId, number>()
const historyRatios = new Map<EdgeTokenId, number>()
let lastSyncStatus: EdgeSyncStatus | undefined

function getSyncStatus(): EdgeSyncStatus {
const activeTokenIds = [null, ...engine.enabledTokenIds]
Expand All @@ -66,10 +70,29 @@ export function makeTokenSyncTracker(engine: SyncEngine): TokenSyncTracker {
return { totalRatio }
}

function sendSyncStatusIfChanged(): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: No tests cover the new sendSyncStatusIfChanged deduplication logic or the 500ms global throttle. The module-level ssLastEmitTime variable persists across test cases and may make existing SyncTracker tests flaky when they run in rapid succession.

Recommendation: Add test cases with fake timers covering: (1) duplicate ratios are suppressed, (2) totalRatio=1 bypasses the throttle, (3) rapid non-1 updates within 500ms are throttled. Export a test-only reset function for ssLastEmitTime or make the clock injectable.

const currentStatus = getSyncStatus()
if (
lastSyncStatus == null ||
lastSyncStatus.totalRatio !== currentStatus.totalRatio
) {
lastSyncStatus = currentStatus

if (currentStatus.totalRatio !== 1) {
const now = Date.now()
if (now - ssLastEmitTime < 500) return
ssLastEmitTime = now
}
Comment on lines +79 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: Silent data loss from throttle ordering: lastSyncStatus is updated (line 79) before the throttle check (lines 81-84). If the call is throttled via return, the status is recorded but never sent. Subsequent calls with the same ratio will be deduplicated against the unsent value and also never sent. Example: ratio goes 0.3→0.5 (throttled, saved but not sent) → 0.5 again (deduped, never sent). The UI would remain stuck at 0.3.

Recommendation: Move lastSyncStatus = currentStatus to just before engine.sendSyncStatus(currentStatus) (after the throttle gate), so only actually-emitted statuses are recorded for deduplication.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throttled sync status updates are permanently lost

Medium Severity

lastSyncStatus is assigned at line 79 before the global throttle check at line 83. When the throttle triggers an early return, the dedup state records the status as "already sent" even though engine.sendSyncStatus was never called. On subsequent calls with the same totalRatio, the dedup guard at line 77 filters it out, so the value is permanently dropped. Moving lastSyncStatus = currentStatus to just before engine.sendSyncStatus(currentStatus) would ensure throttled values are retried on the next invocation.

Fix in Cursor Fix in Web


engine.sendSyncStatus(currentStatus)
}
}

const out: TokenSyncTracker = {
resetSync() {
balanceRatios.clear()
historyRatios.clear()
lastSyncStatus = undefined
},

balanceComplete(tokenId) {
Expand All @@ -78,12 +101,12 @@ export function makeTokenSyncTracker(engine: SyncEngine): TokenSyncTracker {

setBalanceRatios(tokenIds, ratio) {
for (const tokenId of tokenIds) balanceRatios.set(tokenId, ratio)
engine.sendSyncStatus(getSyncStatus())
sendSyncStatusIfChanged()
},

setHistoryRatios(tokenIds, ratio) {
for (const tokenId of tokenIds) historyRatios.set(tokenId, ratio)
engine.sendSyncStatus(getSyncStatus())
sendSyncStatusIfChanged()
},

updateBalanceRatio(tokenId, ratio) {
Expand All @@ -94,7 +117,7 @@ export function makeTokenSyncTracker(engine: SyncEngine): TokenSyncTracker {
if (ratio <= lastRatio) return

balanceRatios.set(tokenId, ratio)
engine.sendSyncStatus(getSyncStatus())
sendSyncStatusIfChanged()
},

updateHistoryRatio(tokenId, ratio, minStep) {
Expand All @@ -108,7 +131,7 @@ export function makeTokenSyncTracker(engine: SyncEngine): TokenSyncTracker {
if (minStep != null && ratio - lastRatio < minStep && ratio < 1) return

historyRatios.set(tokenId, ratio)
engine.sendSyncStatus(getSyncStatus())
sendSyncStatusIfChanged()
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
asArray,
asBoolean,
asCodec,
asEither,
asMaybe,
Expand Down Expand Up @@ -60,6 +61,7 @@ export const asWalletLocalData = asObject({
asObject(asNumber),
() => ({})
),
detectedTokenIds: asMaybe(asObject(asBoolean), () => ({})),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: asObject(asBoolean) works but is semantically misleading. Values are only ever set to true; the boolean value itself is never inspected (lookup checks known[id] == null). This is effectively a Set<string> serialized as a record.

Recommendation: Consider using asObject(asTrue) from cleaners to narrow the type, or document that values are always true. Minor style concern — not a correctness issue.

unactivatedTokenIds: asMaybe(asArray(asString), () => []),
otherData: asOptional(asUnknown, () => ({}))
})
Expand Down
4 changes: 2 additions & 2 deletions src/cosmos/engine/CosmosEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@ export class CosmosEngine extends CurrencyEngine<
})

if (detectedTokenIds.length > 0) {
this.currencyEngineCallbacks.onNewTokens(detectedTokenIds)
this.reportDetectedTokens(detectedTokenIds)
}

if (this.stakingSupported) {
Expand All @@ -656,7 +656,7 @@ export class CosmosEngine extends CurrencyEngine<
}
]
}
this.currencyEngineCallbacks.onStakingStatusChanged(stakingStatus)
this.reportStakingStatus(stakingStatus)
this.stakedBalanceCache = stakedBalance.amount
}
} catch (e) {
Expand Down
4 changes: 2 additions & 2 deletions src/ethereum/EthereumNetwork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ export class EthereumNetwork {
for (const [tokenId, bal] of tokenBal) {
this.ethEngine.updateBalance(tokenId, bal)
}
this.ethEngine.currencyEngineCallbacks.onNewTokens(
this.ethEngine.reportDetectedTokens(
ethereumNetworkUpdate.detectedTokenIds ?? []
)
}
Expand Down Expand Up @@ -723,7 +723,7 @@ function makeThrottledFunction<Args extends any[], Rtn>(
fn: (...args: Args) => Promise<Rtn>
): () => Promise<Rtn> {
let lastTime = 0
let lastTimeout: NodeJS.Timeout | undefined
let lastTimeout: ReturnType<typeof setTimeout> | undefined
return async (...args: Args) => {
return await new Promise((resolve, reject) => {
const timeSinceLast = Date.now() - lastTime
Expand Down
8 changes: 2 additions & 6 deletions src/fio/FioEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,7 @@ export class FioEngine extends CurrencyEngine<
super.doInitialBalanceCallback()

try {
this.currencyEngineCallbacks.onStakingStatusChanged({
...this.otherData.stakingStatus
})
this.reportStakingStatus({ ...this.otherData.stakingStatus })
} catch (e: any) {
this.error(`doInitialBalanceCallback onStakingStatusChanged`, e)
}
Expand Down Expand Up @@ -428,9 +426,7 @@ export class FioEngine extends CurrencyEngine<
}
this.localDataDirty()
try {
this.currencyEngineCallbacks.onStakingStatusChanged({
...this.otherData.stakingStatus
})
this.reportStakingStatus({ ...this.otherData.stakingStatus })
} catch (e: any) {
this.error('onStakingStatusChanged error')
}
Expand Down
2 changes: 1 addition & 1 deletion src/ripple/RippleEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,7 @@ export class XrpEngine extends CurrencyEngine<
})

if (detectedTokenIds.length > 0) {
this.currencyEngineCallbacks.onNewTokens(detectedTokenIds)
this.reportDetectedTokens(detectedTokenIds)
}

// If get here, we've checked balances for all possible tokens the user
Expand Down
2 changes: 1 addition & 1 deletion src/solana/SolanaEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ export class SolanaEngine extends CurrencyEngine<
}

if (detectedTokenIds.length > 0) {
this.currencyEngineCallbacks.onNewTokens(detectedTokenIds)
this.reportDetectedTokens(detectedTokenIds)
}
} catch (e: any) {
// Nodes will return 0 for uninitiated accounts so thrown errors should be logged
Expand Down
2 changes: 1 addition & 1 deletion src/sui/SuiEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export class SuiEngine extends CurrencyEngine<
}

if (detectedTokenIds.length > 0) {
this.currencyEngineCallbacks.onNewTokens(detectedTokenIds)
this.reportDetectedTokens(detectedTokenIds)
}

this.syncTracker.setBalanceRatios([null, ...this.enabledTokenIds], 1)
Expand Down
6 changes: 2 additions & 4 deletions src/tron/TronEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export class TronEngine extends CurrencyEngine<
}

if (detectedTokenIds.length > 0) {
this.currencyEngineCallbacks.onNewTokens(detectedTokenIds)
this.reportDetectedTokens(detectedTokenIds)
}
} catch (e) {
this.log.error('checkTokenBalances error', e)
Expand Down Expand Up @@ -345,9 +345,7 @@ export class TronEngine extends CurrencyEngine<
}

this.stakingStatus = { stakedAmounts }
this.currencyEngineCallbacks.onStakingStatusChanged({
...this.stakingStatus
})
this.reportStakingStatus({ ...this.stakingStatus })
} catch (e: any) {
this.log.error('Error checking TRX address balance: ', e)
}
Expand Down
2 changes: 1 addition & 1 deletion src/zano/ZanoEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export class ZanoEngine extends CurrencyEngine<
}

if (detectedTokenIds.length > 0) {
this.currencyEngineCallbacks.onNewTokens(detectedTokenIds)
this.reportDetectedTokens(detectedTokenIds)
}

this.syncTracker.updateBalanceRatio(1)
Expand Down
Loading