Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- changed: Migrate ondisk transaction data keyed by `currencyCode` to `tokenId`
- changed: Refactor Ethereum `EthereumNetwork.acquireUpdates` to allow partial status updates

## 4.67.0 (2025-12-09)

- changed: Index internal wallet data with tokenIds
Expand Down
81 changes: 74 additions & 7 deletions src/common/CurrencyEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,21 +154,18 @@ export class CurrencyEngine<
// Sync in-memory decoy addresses with what core has saved
this.subscribedAddresses = opts.subscribedAddresses ?? []
this.transactionEvents = []
this.transactionList = {}
this.transactionListDirty = false
this.transactionsLoaded = false
this.txIdMap = {}
this.txIdList = {}
this.walletInfo = walletInfo
this.walletId = walletInfo.id
this.currencyInfo = currencyInfo
this.otherData = undefined
this.minimumAddressBalance = '0'

// Use empty string null tokenId
this.transactionList[''] = []
this.txIdMap[''] = {}
this.txIdList[''] = []
this.transactionList = { '': [] }
this.txIdMap = { '': {} }
this.txIdList = { '': [] }

// Configure tokens:
this.builtinTokens = builtinTokens
Expand Down Expand Up @@ -321,6 +318,51 @@ export class CurrencyEngine<

protected setOtherData(raw: any): void {}

/**
* Migrates transaction data from currency code keys to tokenId keys, if necessary.
* Old format: keyed by currency codes (e.g., "ETH", "USDC")
* New format: keyed by tokenIds (e.g., "", "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
*/
private migrateCurrencyCodeToTokenId<T>(
data: Record<string, T> | undefined
): Record<string, T> | undefined {
if (data == null) {
return data
}

// Check if migration is needed - if data already has empty string key, it's already migrated
if (data[''] != null) {
return data
}

const migrated: Record<string, T> = {}

for (const [currencyCode, value] of Object.entries(data)) {
let newKey: string

// Native currency maps to empty string
if (currencyCode === this.currencyInfo.currencyCode) {
newKey = ''
} else {
// Find tokenId in allTokensMap that matches this currency code
const tokenId = Object.keys(this.allTokensMap).find(
tid => this.allTokensMap[tid]?.currencyCode === currencyCode
)

if (tokenId != null) {
newKey = tokenId
} else {
// No matching token found
continue
}
}

migrated[newKey] = value
}

return migrated
}

protected async loadTransactions(): Promise<void> {
if (this.transactionsLoaded) {
this.log('Transactions already loaded')
Expand Down Expand Up @@ -366,6 +408,31 @@ export class CurrencyEngine<
}
}

// Migrate old data from currency codes to tokenIds if needed
const needsMigration =
(txIdList != null &&
Object.keys(txIdList).length > 0 &&
txIdList[''] == null) ||
(txIdMap != null &&
Object.keys(txIdMap).length > 0 &&
txIdMap[''] == null) ||
(transactionList != null &&
Object.keys(transactionList).length > 0 &&
transactionList[''] == null)
Copy link

Choose a reason for hiding this comment

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

Bug: Re-migration destroys already-migrated token-only wallet data

The needsMigration heuristic uses data[''] == null to detect unmigrated data, assuming migrated data always has an empty string key for native currency. However, wallets with only token transactions (no native currency transactions) won't have this key after migration. On subsequent loads, the heuristic incorrectly triggers re-migration on already-migrated data. When migrateCurrencyCodeToTokenId processes tokenId keys (like contract addresses), they don't match any token's currencyCode, causing all entries to be silently dropped via the continue statement, resulting in complete transaction history loss.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor

@swansontec swansontec Dec 16, 2025

Choose a reason for hiding this comment

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

Cursor makes a fair point. Would it be possible to set txIdList[''] ?= [] and so forth to ensure we never double-migrate? Otherwise, perhaps adding a otherData.tokenIdMigrated flag could prevent double migrations.


if (needsMigration) {
this.log.warn(
'Migrating transaction data from currency codes to tokenIds'
)
txIdList = this.migrateCurrencyCodeToTokenId(txIdList) ?? this.txIdList
txIdMap = this.migrateCurrencyCodeToTokenId(txIdMap) ?? this.txIdMap
transactionList =
this.migrateCurrencyCodeToTokenId(transactionList) ??
this.transactionList
this.transactionListDirty = true
this.log.warn('Migration complete')
}

let isEmptyTransactions = true
for (const tid of Object.keys(this.transactionList)) {
if (
Expand Down Expand Up @@ -838,7 +905,7 @@ export class CurrencyEngine<
return
}

const activeTokenIds = this.enabledTokenIds
const activeTokenIds = [null, ...this.enabledTokenIds]
const perTokenSlice = 1 / activeTokenIds.length
let totalStatus = 0
let numComplete = 0
Expand Down
3 changes: 3 additions & 0 deletions src/cosmos/engine/ThorchainEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { EdgeCurrencyEngineOptions } from 'edge-core-js/types'

import { PluginEnvironment } from '../../common/innerPlugin'
import { getRandomDelayMs } from '../../common/network'
import { snooze } from '../../common/utils'
import { CosmosTools } from '../CosmosTools'
import {
asCosmosWalletOtherData,
Expand Down Expand Up @@ -88,6 +89,8 @@ export class ThorchainEngine extends CosmosEngine {
headers
)
if (!res.ok) {
// snooze in case we're rate-limited
await snooze(1000)
const message = await res.text()
throw new Error(message)
}
Expand Down
15 changes: 0 additions & 15 deletions src/ethereum/EthereumEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,21 +530,6 @@ export class EthereumEngine extends CurrencyEngine<

setOtherData(raw: any): void {
const otherData = asEthereumWalletOtherData(raw)

// Hack otherData. To be removed once local data stops using currency codes as keys
switch (this.currencyInfo.pluginId) {
case 'zksync': {
// The USDC.e token used to be called USDC so we need to
// force a resync of transaction history to avoid showing USDC
// transactions in the now USDC.e wallet
if (!otherData.zksyncForceResyncUSDC) {
this.walletLocalData.lastTransactionQueryHeight.USDC = 0
otherData.zksyncForceResyncUSDC = true
this.walletLocalDataDirty = true
}
}
}

this.otherData = otherData
}

Expand Down
22 changes: 18 additions & 4 deletions src/ethereum/EthereumNetwork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ export class EthereumNetwork {
* This function gets the balance and transaction updates from the network.
*/
acquireUpdates = async (): Promise<void> => {
const updateFuncs = []

// The engine supports token balances batch queries if an adapter provides
// the functionality.
const isFetchTokenBalancesSupported =
Expand All @@ -373,7 +375,7 @@ export class EthereumNetwork {
// each token individually.
isFetchTokenBalancesSupported
) {
await this.acquireTokenBalances()
updateFuncs.push(async () => await this.acquireTokenBalances())
}

const tokenIds: EdgeTokenId[] = [null, ...this.ethEngine.enabledTokenIds]
Expand All @@ -383,11 +385,24 @@ export class EthereumNetwork {
// batch token balance queries.
!isFetchTokenBalancesSupported
) {
await this.acquireTokenBalance(tokenId)
updateFuncs.push(async () => await this.acquireTokenBalance(tokenId))
}

await this.acquireTxs(tokenId)
updateFuncs.push(async () => await this.acquireTxs(tokenId))
}

let firstError: Error | undefined
for (const func of updateFuncs) {
try {
await func()
} catch (error: unknown) {
if (firstError == null && error instanceof Error) {
firstError = error
}
}
}

if (firstError != null) throw firstError
}

private isAnAdapterConnected(): boolean {
Expand Down Expand Up @@ -463,7 +478,6 @@ export class EthereumNetwork {
)
let highestTxBlockHeight = 0
for (const [tokenId, tuple] of tokenTxs) {
if (tokenId == null) continue
this.ethEngine.tokenCheckTransactionsStatus.set(tokenId, 1)
for (const tx of tuple.edgeTransactions) {
this.ethEngine.addTransaction(tokenId, tx)
Expand Down
3 changes: 0 additions & 3 deletions src/ethereum/ethereumTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,6 @@ export const asEthereumWalletOtherData = asObject({
/** @deprecated use nextNonce and count the observed pending transactions instead */
unconfirmedNextNonce: asMaybe(asString, '0'),

// hacks
zksyncForceResyncUSDC: asMaybe(asBoolean, false),

/** Decoy addresses that are pending inclusion in subscribedAddresses */
pendingDecoyAddresses: asMaybe(asArray(asDecoyAddress), () => [])
})
Expand Down
20 changes: 10 additions & 10 deletions src/ripple/rippleInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,11 @@ export const builtinTokens: EdgeTokenMap = {
}
},
'USD-rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq': {
currencyCode: 'USD.gh',
currencyCode: 'USD',
displayName: 'Gatehub USD',
denominations: [
{
name: 'USD.gh',
name: 'USD',
multiplier: '1000000000000000000'
}
],
Expand All @@ -91,11 +91,11 @@ export const builtinTokens: EdgeTokenMap = {
}
},
'EUR-rhub8VRN55s94qWKDv6jmDy1pUykJzF3wq': {
currencyCode: 'EUR.gh',
currencyCode: 'EUR',
displayName: 'Gatehub EUR',
denominations: [
{
name: 'EUR.gh',
name: 'EUR',
multiplier: '1000000000000000000'
}
],
Expand All @@ -105,11 +105,11 @@ export const builtinTokens: EdgeTokenMap = {
}
},
'USD-rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B': {
currencyCode: 'USD.bs',
currencyCode: 'USD',
displayName: 'Bitstamp USD',
denominations: [
{
name: 'USD.bs',
name: 'USD',
multiplier: '1000000000000000000'
}
],
Expand All @@ -119,11 +119,11 @@ export const builtinTokens: EdgeTokenMap = {
}
},
'EUR-rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B': {
currencyCode: 'EUR.bs',
currencyCode: 'EUR',
displayName: 'Bitstamp EUR',
denominations: [
{
name: 'EUR.bs',
name: 'EUR',
multiplier: '1000000000000000000'
}
],
Expand All @@ -133,11 +133,11 @@ export const builtinTokens: EdgeTokenMap = {
}
},
'USD-rEn9eRkX25wfGPLysUMAvZ84jAzFNpT5fL': {
currencyCode: 'USD.st',
currencyCode: 'USD',
displayName: 'Stably USD',
denominations: [
{
name: 'USD.st',
name: 'USD',
multiplier: '1000000000000000000'
Copy link

Choose a reason for hiding this comment

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

Bug: Ripple migration loses data due to renamed currency codes

The Ripple token currency codes were changed from unique values (USD.gh, USD.bs, USD.st, EUR.gh, EUR.bs) to duplicated simple values (USD, EUR). The migrateCurrencyCodeToTokenId function looks up tokens by matching currencyCode against stored keys from old data. Since the old currency codes like USD.gh no longer exist in any token definition, the migration will skip these entries (continue at line 359), causing transaction history loss for users with existing Ripple token data stored under the old currency code keys.

Additional Locations (1)

Fix in Cursor Fix in Web

}
],
Expand Down
3 changes: 1 addition & 2 deletions src/zcash/ZcashEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,11 @@ export class ZcashEngine extends CurrencyEngine<
updateBalance(tokenId: EdgeTokenId, balance: string): void {
const currentBalance = this.getBalance({ tokenId })
if (currentBalance == null || !eq(balance, currentBalance)) {
this.updateBalance(tokenId, balance)
this.walletLocalData.totalBalances[''] = balance
Copy link

Choose a reason for hiding this comment

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

Bug: ZcashEngine ignores tokenId when storing balance

The updateBalance method stores the balance using a hardcoded '' key instead of using the tokenId parameter as tokenId ?? '' like the base class does. This means the tokenId parameter is ignored for storage, and any balance update would incorrectly write to the native token's key regardless of which token was specified. The callback on line 224 correctly uses tokenId, creating an inconsistency where the reported token differs from where the balance is actually stored.

Fix in Cursor Fix in Web

this.walletLocalDataDirty = true
this.warn(`${tokenId}: token Address balance: ${balance}`)
this.currencyEngineCallbacks.onTokenBalanceChanged(tokenId, balance)
}
this.tokenCheckBalanceStatus.set(tokenId, 1)
}

isSynced(): boolean {
Expand Down
Loading