Skip to content

Paul/add edge asset#207

Open
paullinator wants to merge 32 commits intomasterfrom
paul/addEdgeAsset
Open

Paul/add edge asset#207
paullinator wants to merge 32 commits intomasterfrom
paul/addEdgeAsset

Conversation

@paullinator
Copy link
Member

@paullinator paullinator commented Dec 19, 2025

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Description

none

Note

Medium Risk
Touches many partner ingestion paths and adds external currency/asset lookups and mapping logic, which could affect data correctness and query progress if mappings/caches are incomplete or APIs change.

Overview
Adds chain-aware asset metadata to partner transaction records by populating deposit/payoutChainPluginId, deposit/payoutEvmChainId, and deposit/payoutTokenId across multiple partner plugins (notably banxa, changenow, changehero, exolix, godex, letsexchange, moonpay, sideshift, bitrefill, lifi). Several partners now fetch/cache provider currency lists (and include hardcoded fallbacks) to derive token contract addresses and map provider network identifiers to Edge plugin IDs.

Standardizes logging by passing a ScopedLog through PluginParams and updating utilities/batch operations (pagination, various partner queries, and CLI tools) to use scoped log/log.warn/log.error instead of datelog. Also adds the new rango partner (including demo UI listing), expands CouchDB indexes for the new chain/token fields, updates demo API calls to use apiKey where appId was previously sent, and modernizes testpartner.ts to dynamically run any partner by ID.

Written by Cursor Bugbot for commit 6ed36d2. This will update automatically on new commits. Configure here.


6
) ?? ''} ${payoutAmount}`
)
}
Copy link

Choose a reason for hiding this comment

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

Bug: Debug console.log left in production code

A console.log statement logs detailed transaction information (orderId, currencies, chain IDs, token IDs, amounts) for every completed Lifi transaction. This appears to be debug/development code that was accidentally left in. It will pollute production logs, potentially leak sensitive transaction data, and cause unnecessary I/O overhead on every completed transaction processed.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

ok. will fix

): Promise<EdgeAssetInfo> {
if (network == null) {
throw new Error(`Missing network for asset: ${asset}`)
}
Copy link

Choose a reason for hiding this comment

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

Bug: Missing date cutoff for network field requirement

The getAssetInfo function throws an error if network is null, but unlike other partners (changehero, exolix, godex) there's no date-based cutoff to gracefully handle older transactions. The cleaner at lines 151 and 158 marks depositNetwork and settleNetwork as optional, suggesting historical transactions may not have these fields. Other partners use constants like CHAIN_FIELDS_REQUIRED_DATE to skip asset info backfill for older transactions, but sideshift throws unconditionally, which could cause the entire sync to fail when processing historical data.

Fix in Cursor Fix in Web

@paullinator paullinator force-pushed the paul/addEdgeAsset branch 3 times, most recently from 8466888 to ddfaa68 Compare December 24, 2025 22:48
const depositCurrency = firstStep.fromToken.symbol
const depositTokenId = firstStep.fromToken.address ?? null
const payoutCurrency = lastStep.toToken.symbol
const payoutTokenId = lastStep.toToken.address ?? null
Copy link

Choose a reason for hiding this comment

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

TokenId uses raw address without proper formatting

The rango.ts partner uses raw contract addresses directly as tokenId values (firstStep.fromToken.address ?? null), while all other partners consistently use createTokenId() to properly format token addresses. For EVM chains, createTokenId() removes the '0x' prefix and lowercases the address. Without this normalization, the tokenId format will be inconsistent with the rest of the codebase, potentially causing token lookup/matching failures.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Collaborator

Choose a reason for hiding this comment

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

Valid point.

} else if (tx.cardType === undefined) {
paymentMethod = 'applepay'
}
break
Copy link

Choose a reason for hiding this comment

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

Unhandled cardType 'card' causes error for mobile_wallet payments

The getFiatPaymentType function doesn't handle the 'card' value for cardType when paymentMethod is 'mobile_wallet'. The cleaner on line 147 allows cardType to be 'apple_pay', 'google_pay', or 'card', but the switch statement only handles the first two plus undefined. If a transaction has paymentMethod: 'mobile_wallet' with cardType: 'card', paymentMethod remains null and the function throws an error.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

ok. need to remove 'card' completely.

Copy link
Collaborator

@samholmes samholmes left a comment

Choose a reason for hiding this comment

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

Partial review. I included an idea in my fixup! commit too.

currency: asMoonpayCurrency,
id: asString,
// Common amount field (used by both buy and sell)
quoteCurrencyAmount: asOptional(asNumber),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why does the comment mention that it's used for both buy and sell yet is optional? It wasn't optional before.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not entirely sure merging the types makes sense because we then lose the type strictness. But w/e

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch optional probably was a mistake. However, we need one single type since the process*Tx. Could be run on either buy or sell.

The better solution is to have one cleaner that can validate whether the transaction is a buy or a sell. And then the independent buyer sell cleaners to validate it afterwards. Basically just check this condition

tx.paymentMethod != null

and if true consider it a buy and use an asMoonpayBuyTx cleaner, good one and a false use asMoonpaySellTx.

I'll refactor to implement this

} else if (tx.cardType === 'google_pay') {
paymentMethod = 'googlepay'
} else if (tx.cardType === undefined) {
paymentMethod = 'applepay'
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why do we assume applypay by default?

Copy link
Member Author

Choose a reason for hiding this comment

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

will fix

if (chainPluginId != null) {
if (
contractAddress != null &&
contractAddress !== '0x0000000000000000000000000000000000000000'
Copy link
Collaborator

Choose a reason for hiding this comment

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

No provider follows Native Asset Address Convention I hope? (https://eips.ethereum.org/EIPS/eip-7528)

Copy link
Member Author

Choose a reason for hiding this comment

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

Some do and they are special cased. See Exolix and Let's Exchange. Seems like many providers have their own special nomenclature that we have to sift through.

paymentMethod = 'googlepay'
} else if (tx.cardType === undefined) {
paymentMethod = 'applepay'
}
Copy link

Choose a reason for hiding this comment

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

Unjustified default to Apple Pay when cardType undefined

Medium Severity

When tx.paymentMethod is 'mobile_wallet' and tx.cardType is undefined, the code arbitrarily defaults paymentMethod to 'applepay'. As highlighted by the reviewer's comment "Why do we assume applypay by default?", there's no justification for this assumption. When the data doesn't indicate the payment type, defaulting to Apple Pay could lead to incorrect reporting. Additionally, if cardType is 'card' (a valid type per the cleaner), paymentMethod remains null and the function throws an error.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

This is fine as that is the default for a mobile wallet.


// Determine direction based on paymentMethod vs payoutMethod
// Buy transactions have paymentMethod, sell transactions have payoutMethod
const direction = tx.paymentMethod != null ? 'buy' : 'sell'
Copy link

Choose a reason for hiding this comment

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

Sell transactions with paymentMethod are misclassified as buy

High Severity

The direction determination logic tx.paymentMethod != null ? 'buy' : 'sell' can misclassify transactions. The old asMoonpaySellTx cleaner included paymentMethod: asOptional(asString), meaning sell transactions could have this field. The old code hardcoded direction: 'sell' regardless of paymentMethod presence. Now, if a sell transaction has paymentMethod set, it would be incorrectly classified as buy, causing the code to look for tx.currency (buy-specific) instead of tx.quoteCurrency (sell-specific), likely resulting in a "Missing payout currency" error or incorrect data. This relates to the reviewer's concern about losing type strictness when merging the cleaners.

Fix in Cursor Fix in Web

Copy link
Collaborator

@samholmes samholmes left a comment

Choose a reason for hiding this comment

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

Finally finished with this. Whew

pluginParams?: PluginParams
): Promise<StandardTx> {
// Load currency cache before processing transactions
await loadCurrencyCache()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why make this an async function and why not just call this once before the processChangeNowTx calls?


export async function processChangeNowTx(
rawTx: unknown,
pluginParams?: PluginParams
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can be removed if loadCurrencyCache is moved out of processChangeNowTx

const evmChainId = EVM_CHAIN_IDS[chainPluginId]

// Get contract address from cache
const coinsCache = await fetchSideshiftCoins()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same suggestion here: call the async function outside of the processSideshiftTx routine and pass in the cache state as a parameter. This prevents turning this method into an async function. I suppose the main motivation for this is to avoid the overhead of promises when the async is only needed. Just makes sense to keep it sync if we can for performance and lesser complexity.

}

export function processBanxaTx(rawTx: unknown): StandardTx {
export async function processBanxaTx(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same here, can we keep processBanxaTx sync to be consistent with other plugins. This is a internal pattern.

const checkUpdateTx = (
oldTx: StandardTx,
newTx: StandardTx
): string[] | undefined => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is undefined needed for the return type?

Comment on lines +47 to +51
/** Local datelog for engine-level logs not associated with a specific app/partner */
const datelog = (...args: unknown[]): void => {
const date = new Date().toISOString()
console.log(date, ...args)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can't you just import this from ./util like before?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm happy to hear that the rates server bookmarking is fixed. Not sure what was broken about it. It looks like it has something to do with concurrency, but it's not clear.

currencyB = isFiatCurrency(currencyB) ? `iso:${currencyB}` : currencyB

const url = `https://rates2.edge.app/v2/exchangeRate?currency_pair=${currencyA}_${currencyB}&date=${hourDate}`
const server = RATES_SERVERS[Math.floor(Math.random() * RATES_SERVERS.length)]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Useful abstraction would be a pickRandomRatesServer function. Optional.

Also this isn't round-robin as the commit message suggests.

promises.push(promise)
}
datelog(partnerStatus)
await Promise.all(promises)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why are we awaiting all the promises that have already resolved?

I suppose there could be 2 promises unresolved, and we want to wait for those two promises to be resolved before doing 3 more promises for the next app in apps?

This sounds like the thing you're trying to avoid, although limited to one edge case (the end of the plugins array), it seems like it's still possible to be blocked on 3 until they all resolve before doing the next 3.

The only other way I can think of solving this is to remove this await Promise.all(promise) and move the semaphore to outside of the for-loop block for the apps.

const depositCurrency = firstStep.fromToken.symbol
const depositTokenId = firstStep.fromToken.address ?? null
const payoutCurrency = lastStep.toToken.symbol
const payoutTokenId = lastStep.toToken.address ?? null
Copy link
Collaborator

Choose a reason for hiding this comment

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

Valid point.

const depositCurrency = firstStep.fromToken.symbol
const depositTokenId = firstStep.fromToken.address ?? null
const payoutCurrency = lastStep.toToken.symbol
const payoutTokenId = lastStep.toToken.address ?? null
Copy link
Collaborator

Choose a reason for hiding this comment

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

Inconsistent tokenId format

The tokenId for Rango assets uses raw contract addresses directly without transformation via createTokenId(). Consider using createTokenId(tokenTypes[chainPluginId], currencyCode, address) for consistency with other partner plugins.

contractAddress: string | null
pluginId: string | undefined
}
let banxaCoinsCache: Map<string, CachedAssetInfo> | null = null
Copy link
Collaborator

Choose a reason for hiding this comment

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

Global cache could become stale

The module-level cache banxaCoinsCache persists for the process lifetime. Since the query engine runs in an infinite loop, this cache never refreshes after the first successful fetch. Consider adding TTL-based invalidation or clearing caches periodically.

This pattern also appears in sideshift, changenow, and letsexchange plugins.

Copy link
Member Author

Choose a reason for hiding this comment

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

Will fix.

// transactions back to approximately Feb 23, 2022.
// Transactions before this date may be missing network fields and won't be backfilled.
// Transactions on or after this date MUST have network fields or will throw.
const NETWORK_FIELDS_AVAILABLE_DATE = '2022-02-24T00:00:00.000Z'
Copy link
Collaborator

Choose a reason for hiding this comment

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

Unused constant

The constant NETWORK_FIELDS_AVAILABLE_DATE is defined but never used in the code. If this date is important for backwards compatibility (e.g., skipping asset info for older transactions), consider implementing that check. Otherwise, this unused constant should be removed.

})
// Map Moonpay status to Edge status
// Only 'completed' and 'pending' were found in 3 years of API data
const statusMap: Record<string, Status> = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Limited statusMap coverage

The statusMap only handles completed and pending statuses. While the code comment notes these are the only two statuses found in 3 years of API data, Moonpay docs list additional statuses like failed, waitingAuthorization, etc.

Consider either:

  1. Adding mappings for documented Moonpay statuses to prevent future data loss
  2. Logging when an unknown status is encountered (currently defaults silently to other)

Copy link
Member Author

Choose a reason for hiding this comment

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

There's never any data loss, since we save the rawTx, we can always rerun the processTx and backfill the data. Good future todo

}
const { swap } = tx.metadata
if (swap?.affiliateAddress !== affiliateAddress) {
return null
Copy link
Collaborator

Choose a reason for hiding this comment

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

Silent null returns hinder debugging

The makeThorchainProcessTx function silently returns null for several conditions without any logging:

  • affiliateAddress mismatch
  • status is not success
  • no affiliate output found
  • source/dest asset match (refund)
  • missing output when pools.length === 2

Consider adding debug-level logging to indicate why transactions are being filtered out, especially for less obvious cases like refund detection.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 7 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 7 issues found in the latest run.

  • ✅ Fixed: Debug console.log left in production code
    • Replaced the raw console.log in LiFi transaction processing with the scoped log() call used elsewhere in the plugin.
  • ✅ Fixed: Rango tokenId uses raw addresses without normalization
    • Rango now derives both deposit and payout tokenId values through createTokenId(...) with tokenTypes instead of storing raw addresses.
  • ✅ Fixed: LetsExchange status cleaner lacks fallback for unknown values
    • Wrapped asLetsExchangeStatus with asMaybe(..., 'other') and added 'other' to statusMap so unknown statuses no longer throw in the cleaner.
  • ✅ Fixed: LetsExchange asValue has duplicate status entries
    • Removed duplicate 'overdue' and 'refund' entries from the LetsExchange status cleaner value list.
  • ✅ Fixed: ChangeNow conflates missing cache entry with native token
    • Updated ChangeNow asset lookup to treat only null as native and throw on undefined cache misses instead of mapping both to tokenId: null.
  • ✅ Fixed: Unused constant NETWORK_FIELDS_AVAILABLE_DATE defined
    • Integrated NETWORK_FIELDS_AVAILABLE_DATE into LetsExchange asset resolution to allow missing network fields only before the cutoff and throw afterward.
  • ✅ Fixed: Godex coins cache persists incomplete state on API failure
    • Godex now caches coin data only on successful API responses and rethrows fetch errors so an incomplete fallback cache is not persisted.

Create PR

Or push these changes by commenting:

@cursor push ecf51debb2
Preview (ecf51debb2)
diff --git a/src/partners/changenow.ts b/src/partners/changenow.ts
--- a/src/partners/changenow.ts
+++ b/src/partners/changenow.ts
@@ -344,14 +344,17 @@
   // Look up contract address from cache
   const contractAddress = getContractFromCache(currencyCode, network)
 
-  // If not in cache or no contract address, it's a native token
-  if (contractAddress == null) {
+  // null means native token, undefined means cache miss
+  if (contractAddress === null) {
     return {
       chainPluginId,
       evmChainId,
       tokenId: null
     }
   }
+  if (contractAddress === undefined) {
+    throw new Error(`Currency info not found for ${currencyCode} on ${network}`)
+  }
 
   // Create tokenId from contract address
   const tokenType = tokenTypes[chainPluginId]

diff --git a/src/partners/godex.ts b/src/partners/godex.ts
--- a/src/partners/godex.ts
+++ b/src/partners/godex.ts
@@ -132,6 +132,10 @@
   try {
     const url = 'https://api.godex.io/api/v1/coins'
     const result = await retryFetch(url, { method: 'GET' })
+    if (!result.ok) {
+      const text = await result.text()
+      throw new Error(`Failed to fetch Godex coins: ${text}`)
+    }
     const json = await result.json()
     const coins = asGodexCoinsResponse(json)
 
@@ -149,11 +153,12 @@
       }
     }
     log(`Coins cache loaded: ${cache.size} entries`)
+    godexCoinsCache = cache
+    return cache
   } catch (e) {
-    log.error('Error loading coins cache:', e)
+    log.error(`Error loading coins cache: ${String(e)}`)
+    throw e
   }
-  godexCoinsCache = cache
-  return cache
 }
 
 interface GodexEdgeAssetInfo {

diff --git a/src/partners/letsexchange.ts b/src/partners/letsexchange.ts
--- a/src/partners/letsexchange.ts
+++ b/src/partners/letsexchange.ts
@@ -45,22 +45,23 @@
   })
 })
 
-const asLetsExchangeStatus = asValue(
-  'wait',
-  'confirmation',
-  'confirmed',
-  'exchanging',
-  'overdue',
-  'refund',
-  'sending',
-  'transferring',
-  'sending_confirmation',
-  'success',
-  'aml_check_failed',
-  'overdue',
-  'error',
-  'canceled',
-  'refund'
+const asLetsExchangeStatus = asMaybe(
+  asValue(
+    'wait',
+    'confirmation',
+    'confirmed',
+    'exchanging',
+    'overdue',
+    'refund',
+    'sending',
+    'transferring',
+    'sending_confirmation',
+    'success',
+    'aml_check_failed',
+    'error',
+    'canceled'
+  ),
+  'other'
 )
 
 // Cleaner for the new v2 API response
@@ -128,7 +129,8 @@
   success: 'complete',
   aml_check_failed: 'blocked',
   canceled: 'cancelled',
-  error: 'failed'
+  error: 'failed',
+  other: 'other'
 }
 
 // Map LetsExchange network codes to Edge pluginIds
@@ -289,14 +291,15 @@
   initialNetwork: string | null,
   currencyCode: string,
   contractAddress: string | null,
-  log: ScopedLog
+  isoDate: string
 ): AssetInfo | undefined {
-  let network = initialNetwork
-  if (network == null) {
-    // Try using the currencyCode as the network
-    network = currencyCode
-    log(`Using currencyCode as network: ${network}`)
+  if (initialNetwork == null) {
+    if (isoDate < NETWORK_FIELDS_AVAILABLE_DATE) {
+      return undefined
+    }
+    throw new Error(`Missing network for currency ${currencyCode}`)
   }
+  const network = initialNetwork
 
   const networkUpper = network.toUpperCase()
   const chainPluginId = LETSEXCHANGE_NETWORK_TO_PLUGIN_ID[networkUpper]
@@ -500,14 +503,14 @@
     tx.coin_from_network ?? tx.network_from_code,
     tx.coin_from,
     tx.coin_from_contract_address,
-    log
+    isoDate
   )
   // Get payout asset info using contract address from API response
   const payoutAsset = getAssetInfo(
     tx.coin_to_network ?? tx.network_to_code,
     tx.coin_to,
     tx.coin_to_contract_address,
-    log
+    isoDate
   )
 
   const status = statusMap[tx.status]

diff --git a/src/partners/lifi.ts b/src/partners/lifi.ts
--- a/src/partners/lifi.ts
+++ b/src/partners/lifi.ts
@@ -297,7 +297,7 @@
     }
     if (statusMap[tx.status] === 'complete') {
       const { orderId, depositCurrency, payoutCurrency } = standardTx
-      console.log(
+      log(
         `${orderId} ${depositCurrency} ${depositChainPluginId} ${depositEvmChainId} ${depositTokenId?.slice(
           0,
           6

diff --git a/src/partners/rango.ts b/src/partners/rango.ts
--- a/src/partners/rango.ts
+++ b/src/partners/rango.ts
@@ -19,6 +19,7 @@
   Status
 } from '../types'
 import { retryFetch } from '../util'
+import { createTokenId, tokenTypes } from '../util/asEdgeTokenId'
 import { EVM_CHAIN_IDS } from '../util/chainIds'
 
 // Start date for Rango transactions (first Edge transaction was 2024-06-23)
@@ -268,9 +269,17 @@
 
   const dateStr = isoDate.split('T')[0]
   const depositCurrency = firstStep.fromToken.symbol
-  const depositTokenId = firstStep.fromToken.address ?? null
+  const depositTokenId = createTokenId(
+    tokenTypes[depositChainPluginId],
+    depositCurrency,
+    firstStep.fromToken.address ?? undefined
+  )
   const payoutCurrency = lastStep.toToken.symbol
-  const payoutTokenId = lastStep.toToken.address ?? null
+  const payoutTokenId = createTokenId(
+    tokenTypes[payoutChainPluginId],
+    payoutCurrency,
+    lastStep.toToken.address ?? undefined
+  )
 
   log(
     `${dateStr} ${depositCurrency} ${depositAmount} ${depositChainPluginId}${
@@ -299,7 +308,7 @@
     payoutCurrency: lastStep.toToken.symbol,
     payoutChainPluginId,
     payoutEvmChainId,
-    payoutTokenId: lastStep.toToken.address ?? null,
+    payoutTokenId,
     payoutAmount,
     timestamp,
     isoDate,

6
) ?? ''} ${payoutAmount}`
)
}
Copy link

Choose a reason for hiding this comment

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

Debug console.log left in production code

Medium Severity

A raw console.log statement is used for every completed LiFi transaction instead of the scoped log() function. Every other log call in this file and across all partner plugins uses the scoped logger, which prefixes messages with timestamps and app/partner identifiers. This bypasses the structured logging pattern and will produce unformatted output in production logs.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

will fix

const depositCurrency = firstStep.fromToken.symbol
const depositTokenId = firstStep.fromToken.address ?? null
const payoutCurrency = lastStep.toToken.symbol
const payoutTokenId = lastStep.toToken.address ?? null
Copy link

Choose a reason for hiding this comment

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

Rango tokenId uses raw addresses without normalization

Medium Severity

The tokenId for Rango assets is set directly from raw contract addresses (firstStep.fromToken.address) without transformation via createTokenId(). All other partner plugins use createTokenId(tokenType, currencyCode, address) which normalizes addresses (e.g., lowercasing and removing the 0x prefix for EVM tokens). This produces inconsistent tokenId values in the database compared to every other plugin.

Additional Locations (1)

Fix in Cursor Fix in Web

'overdue',
'error',
'canceled',
'refund'
Copy link

Choose a reason for hiding this comment

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

LetsExchange status cleaner lacks fallback for unknown values

Medium Severity

The asLetsExchangeStatus cleaner was changed from using asMaybe(asValue(...), 'other') to a bare asValue(...). Without the asMaybe fallback, any new or unknown status value from the LetsExchange API will cause the transaction cleaner to throw, halting processing for the entire batch instead of gracefully mapping to 'other'.

Fix in Cursor Fix in Web

'overdue',
'error',
'canceled',
'refund'
Copy link

Choose a reason for hiding this comment

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

LetsExchange asValue has duplicate status entries

Low Severity

The asLetsExchangeStatus definition lists 'overdue' twice (lines 53 and 60) and 'refund' twice (lines 54 and 63). While not a runtime error, duplicates in asValue indicate copy-paste carelessness and make the value list harder to audit for completeness.

Fix in Cursor Fix in Web

evmChainId,
tokenId: null
}
}
Copy link

Choose a reason for hiding this comment

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

ChangeNow conflates missing cache entry with native token

Medium Severity

getContractFromCache explicitly returns undefined for currencies not in the cache (vs. null for native tokens), but getAssetInfo checks contractAddress == null which treats both identically as native tokens with tokenId: null. Tokens missing from the API cache (e.g., delisted or newly added) will silently get incorrect native-token tokenId values.

Additional Locations (1)

Fix in Cursor Fix in Web

// transactions back to approximately Feb 23, 2022.
// Transactions before this date may be missing network fields and won't be backfilled.
// Transactions on or after this date MUST have network fields or will throw.
const NETWORK_FIELDS_AVAILABLE_DATE = '2022-02-24T00:00:00.000Z'
Copy link

Choose a reason for hiding this comment

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

Unused constant NETWORK_FIELDS_AVAILABLE_DATE defined

Low Severity

The constant NETWORK_FIELDS_AVAILABLE_DATE is defined but never referenced anywhere in the codebase. Similar date-based cutoff constants in other plugins (like CHAIN_FIELDS_REQUIRED_DATE in changehero.ts and NETWORK_FIELDS_REQUIRED_DATE in exolix.ts) are actively used to gate asset-info lookups for older transactions.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

will fix

log.error('Error loading coins cache:', e)
}
godexCoinsCache = cache
return cache
Copy link

Choose a reason for hiding this comment

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

Godex coins cache persists incomplete state on API failure

Medium Severity

getGodexCoinsCache sets godexCoinsCache = cache unconditionally after the try/catch block, even when the API fetch fails. On failure, the cache contains only DELISTED_TOKENS entries. Because the guard if (godexCoinsCache != null) return prevents re-fetching, the incomplete cache persists for the entire process lifetime. Every other partner plugin with a similar cache pattern either re-throws the error or only sets the cache inside the success path.

Fix in Cursor Fix in Web

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for 3 of the 4 issues found in the latest run.

  • ✅ Fixed: Moonpay sell transactions misclassified as buy transactions
    • I changed Moonpay direction detection to use sell-specific fields (quoteCurrency, payoutMethod, depositHash) so sell transactions are no longer misclassified when paymentMethod is present.
  • ✅ Fixed: LetsExchange required fields make null guard dead code
    • I restored affiliateId and apiKey to optional cleaner fields so the existing null guard can gracefully return empty results when config keys are missing.
  • ✅ Fixed: Bitrefill deposits for bitcoin miss altcoin amount edge case
    • I added a fallback to btcPrice when non-bitcoin transactions unexpectedly lack altcoinPrice, preventing unnecessary transaction-processing failures.

Create PR

Or push these changes by commenting:

@cursor push 0da1faad74
Preview (0da1faad74)
diff --git a/src/partners/bitrefill.ts b/src/partners/bitrefill.ts
--- a/src/partners/bitrefill.ts
+++ b/src/partners/bitrefill.ts
@@ -279,10 +279,9 @@
   const timestamp = tx.invoiceTime / 1000
 
   const { paymentMethod } = tx
-  let depositAmountStr: string | undefined
-  if (paymentMethod === 'bitcoin') {
-    depositAmountStr = tx.btcPrice
-  } else if (tx.altcoinPrice != null) {
+  // Fallback to btcPrice when altcoinPrice is unexpectedly missing.
+  let depositAmountStr: string | undefined = tx.btcPrice
+  if (paymentMethod !== 'bitcoin' && tx.altcoinPrice != null) {
     depositAmountStr = tx.altcoinPrice
   }
   if (depositAmountStr == null) {

diff --git a/src/partners/letsexchange.ts b/src/partners/letsexchange.ts
--- a/src/partners/letsexchange.ts
+++ b/src/partners/letsexchange.ts
@@ -40,8 +40,8 @@
     latestIsoDate: asOptional(asString, LETSEXCHANGE_START_DATE)
   }),
   apiKeys: asObject({
-    affiliateId: asString,
-    apiKey: asString
+    affiliateId: asOptional(asString),
+    apiKey: asOptional(asString)
   })
 })
 
@@ -484,6 +484,10 @@
   const { apiKey } = apiKeys
   const { log } = pluginParams
 
+  if (apiKey == null) {
+    throw new Error('Missing LetsExchange apiKey')
+  }
+
   await fetchCoinCache(apiKey, log)
 
   const tx = asLetsExchangeTx(rawTx)

diff --git a/src/partners/moonpay.ts b/src/partners/moonpay.ts
--- a/src/partners/moonpay.ts
+++ b/src/partners/moonpay.ts
@@ -312,9 +312,14 @@
   // Map Moonpay status to Edge status
   const status: Status = statusMap[tx.status] ?? 'other'
 
-  // Determine direction based on paymentMethod vs payoutMethod
-  // Buy transactions have paymentMethod, sell transactions have payoutMethod
-  const direction = tx.paymentMethod != null ? 'buy' : 'sell'
+  // Determine direction based on sell-specific fields.
+  // Sell transactions can also include paymentMethod, so that field alone is insufficient.
+  const direction =
+    tx.quoteCurrency != null ||
+    tx.payoutMethod != null ||
+    tx.depositHash != null
+      ? 'sell'
+      : 'buy'
 
   // Get the payout currency - different field names for buy vs sell
   const payoutCurrency = direction === 'buy' ? tx.currency : tx.quoteCurrency

orderId: tx.id,
// Determine direction based on paymentMethod vs payoutMethod
// Buy transactions have paymentMethod, sell transactions have payoutMethod
const direction = tx.paymentMethod != null ? 'buy' : 'sell'
Copy link

Choose a reason for hiding this comment

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

Moonpay sell transactions misclassified as buy transactions

High Severity

The direction detection logic tx.paymentMethod != null ? 'buy' : 'sell' is incorrect because sell transactions from the Moonpay API also have a paymentMethod field (the old asMoonpaySellTx cleaner explicitly included it). Sell transactions with paymentMethod set would be misclassified as 'buy', causing payoutCurrency to read from tx.currency (which is undefined for sell txs), immediately throwing "Missing payout currency". This breaks all sell transaction processing.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

Not an issue

The old asMoonpaySellTx cleaner had it as optional and it never showed up since those were for buy transactions only.

affiliateId: asOptional(asString),
apiKey: asOptional(asString)
affiliateId: asString,
apiKey: asString
Copy link

Choose a reason for hiding this comment

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

LetsExchange required fields make null guard dead code

Medium Severity

The asLetsExchangePluginParams cleaner changed affiliateId and apiKey from asOptional(asString) to asString, making them required. The subsequent null guard if (apiKey == null || affiliateId == null) is now dead code — the cleaner throws before reaching it. This means partners missing these config keys will throw an error instead of gracefully returning empty results, changing the error-handling behavior.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay. We'll clean up the dead code.

if (paymentMethod === 'bitcoin') {
depositAmountStr = tx.btcPrice
} else if (tx.altcoinPrice != null) {
depositAmountStr = tx.altcoinPrice
Copy link

Choose a reason for hiding this comment

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

Bitrefill deposits for bitcoin miss altcoin amount edge case

Low Severity

The deposit amount logic checks paymentMethod === 'bitcoin' to use btcPrice, otherwise falls back to altcoinPrice. If a non-bitcoin payment method has altcoinPrice as undefined (since it's asOptional), depositAmountStr would be undefined and the function throws. The old code used satoshiPrice and receivedPaymentAltcoin with different semantics; the new logic assumes btcPrice or altcoinPrice always covers all cases, but altcoinPrice is optional and may be missing for some payment methods.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

It is only missing because prior calls to the API might exclude it, but it should always be present at this point.

Copy link

@eddy-edge eddy-edge left a comment

Choose a reason for hiding this comment

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

PR #207 Review Summary: Add Edge Asset Info to Partner Plugins

PR: #207
Author: paullinator (Paul V Puey)
Branch: paul/addEdgeAssetmaster
Status: CHANGES_REQUESTED by samholmes
Files changed: 55 | Commits: ~30+


Overview

Adds chain-aware asset metadata (depositChainPluginId, depositEvmChainId, depositTokenId, and payout equivalents) across ~15 partner plugins. Introduces scoped logging (ScopedLog), a new rango partner, semaphore-based concurrency in the query engine, and CouchDB index updates for new fields.


Critical Issues

1. Reviewer Feedback Not Addressed: Async processTx Functions

Risk: Correctness / Performance | Status: UNRESOLVED

samholmes explicitly requested that processTx functions remain synchronous, with cache loading moved outside:

Reviewer comments:

  • sideshift.ts:309: "call the async function outside of the processSideshiftTx routine and pass in the cache state as a parameter"
  • banxa.ts:484: "can we keep processBanxaTx sync to be consistent with other plugins"
  • changenow.ts: "Why make this an async function and why not just call this once before the processChangeNowTx calls?"

Impact: Promise overhead on every single transaction; inconsistent with sync plugins like moonpay and letsexchange. The Mutex/loaded-flag guards mitigate redundant fetches but the async pattern is unnecessary.

2. Moonpay: Silent Default to applepay

Risk: Data Correctness | Status: UNRESOLVED

When cardType is undefined for mobile_wallet payment method, it defaults to 'applepay' (moonpay.ts diff ~L396).

Reviewer comment: "Why do we assume applePay by default?" — paullinator replied "will fix" but the code still has the default.

3. Moonpay: Reduced Type Safety from Merged Cleaners

Risk: Correctness | Severity: Medium

The separate asMoonpayTx and asMoonpaySellTx cleaners were merged into a single asMoonpayTx with many optional fields (cryptoTransactionId, currency, walletAddress, depositHash, quoteCurrency, payoutMethod). This means the cleaner no longer validates that buy txs have buy-required fields and sell txs have sell-required fields.

Reviewer comment (moonpay.ts:153): "merging the types ... we then lose the type strictness"

4. Moonpay: All Statuses Now Ingested (Previously Only completed)

Risk: Data Regression | Severity: Medium

Previously, only status === 'completed' transactions were ingested. Now ALL transactions are pushed to standardTxs regardless of status, with unknown statuses silently mapped to 'other'. The statusMap only covers completed and pending.

This is intentional (commit "Include all moonpay txs") but could flood the database with incomplete/failed transactions that were previously excluded.


Addressed Issues (Fixed in Commit 6ed36d2)

The Cursor Bugbot autofix commit addressed several issues:

Issue File Status
console.log in production lifi.ts:299 Fixed → uses log()
Rango raw tokenId addresses rango.ts:271-283 Fixed → uses createTokenId()
LetsExchange duplicate status values letsexchange.ts:36-47 Fixed → asMaybe + deduped
ChangeNow null vs undefined cache changenow.ts:344-360 Fixed → === null / === undefined
Godex cache persistence on failure godex.ts:132-153 Fixed → only caches on success
NETWORK_FIELDS_AVAILABLE_DATE unused letsexchange.ts:117 Fixed → integrated into getAssetInfo

Other Notable Review Items

5. Global Caches Without TTL

Risk: Stale Data | Severity: Low-Medium

Module-level caches in banxa, changenow, sideshift, godex, letsexchange persist for the entire process lifetime. Since the query engine loops infinitely, caches never refresh after first load.

Reviewer comment (banxa.ts:95): "module-level cache persists for the process lifetime ... consider adding TTL-based invalidation"

6. QueryEngine: console.log in datelog Wrapper

Severity: Low

The moved datelog function in queryEngine.ts still uses raw console.log (L39). The old console.log(e) error handler was properly replaced with log.error (L341). The datelog usage is expected since it's a standalone utility.

7. Rates Engine: Not Actually Round-Robin

Severity: Low

Commit message says "round-robin" but implementation uses hardcoded server URLs (rates3.edge.app for v3, rates2.edge.app for v2). No rotation or random selection.

Reviewer comment (ratesEngine.ts:442): "this isn't round-robin as the commit message suggests"

8. disablePartnerQuery Field Undocumented

Severity: Low

New boolean field added to plugin settings but no comment explaining semantics.

Reviewer comment (types.ts:255): "Add comment explaining the semantics"

9. Thorchain: Silent Null Returns

Severity: Low

makeThorchainProcessTx silently returns null for multiple conditions without logging.

Reviewer comment (thorchain.ts:317): "Consider adding debug-level logging to indicate why transactions are being filtered out"


Security Review

  • No injection risks: API keys come from config, not user input.
  • No secrets in code: API keys passed via pluginParams.apiKeys.
  • Cache poisoning: If a ChangeNow/Godex/etc API returns malformed data, it could populate caches with incorrect token mappings. The godex.ts fix (only cache on success + rethrow) mitigates this for Godex. Other plugins have similar risk but use cleaners for validation.
  • No new endpoints exposed to external callers.

Recommendation

Do not merge as-is. The following should be addressed before merge:

  1. Must fix: Make processChangeNowTx, processSideshiftTx sync per reviewer request — load caches once before the tx processing loop
  2. Must fix: Remove or justify the applepay default in moonpay
  3. Should fix: Document the behavior change of ingesting all moonpay statuses (not just completed) — confirm this is desired
  4. Should fix: Add comment for disablePartnerQuery semantics
  5. Nice to have: Add cache TTL or periodic refresh mechanism

Copy link

@eddy-edge eddy-edge left a comment

Choose a reason for hiding this comment

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

Thanks for the updates. Requesting changes because there are still blocking items that should be addressed before merge. Inline comments call out specific places.

}
for (const rawTx of txs) {
const standardTx = processChangeNowTx(rawTx)
const standardTx = await processChangeNowTx(rawTx, pluginParams)

Choose a reason for hiding this comment

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

This is still processing each tx asynchronously and loading dependencies per-tx via processChangeNowTx. Prior review requested keeping this path synchronous by loading cache once at query start and passing it through. Please preload once and make tx processing sync in the hot loop.

Copy link
Member Author

@paullinator paullinator Mar 12, 2026

Choose a reason for hiding this comment

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

This is necessary since we have upcoming the scripts which rely on a standard process transaction type function and will only be able to pass a raw tx and plugin parameters and cannot pass in a cache.

I will add a comment that describes this. In line with the declaration of the process*Tx

const evmChainId = EVM_CHAIN_IDS[chainPluginId]

// Get contract address from cache
const coinsCache = await fetchSideshiftCoins()

Choose a reason for hiding this comment

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

fetchSideshiftCoins() is called inside per-tx asset resolution, which makes tx processing async and potentially re-fetches the same data repeatedly. Please move cache fetch outside the tx loop and pass the cache/object into processing helpers so this remains synchronous and predictable.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is necessary since we have upcoming the scripts which rely on a standard process transaction type function and will only be able to pass a raw tx and plugin parameters and cannot pass in a cache.

let standardTx: StandardTx
try {
standardTx = processBanxaTx(rawTx)
standardTx = await processBanxaTx(rawTx, pluginParams)

Choose a reason for hiding this comment

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

processBanxaOrders still awaits per-tx processing here. Prior feedback requested keeping this processing sync by preparing lookup data once and passing it into tx conversion. Please avoid async work in the inner loop unless strictly necessary.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is necessary since we have upcoming the scripts which rely on a standard process transaction type function and will only be able to pass a raw tx and plugin parameters and cannot pass in a cache.

const standardTx = processMoonpayTx(rawTx)
standardTxs.push(standardTx)
}
const standardTx = processTx(rawTx)

Choose a reason for hiding this comment

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

We now append every Moonpay buy tx regardless of status. Previously this path only persisted completed txs, which kept analytics datasets cleaner. Please restore explicit status filtering (or document why non-complete records are required and adjust downstream queries/indexes).

Copy link
Member Author

Choose a reason for hiding this comment

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

This is correct, as we want to collect all transactions, so we have insight into those that don't complete.

} else if (tx.cardType === 'google_pay') {
paymentMethod = 'googlepay'
} else if (tx.cardType === undefined) {
paymentMethod = 'applepay'

Choose a reason for hiding this comment

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

Defaulting unknown mobile_wallet transactions to applepay when cardType is undefined can misclassify data. This should be removed or mapped to a neutral/unknown type instead of forcing applepay.

Copy link
Member Author

Choose a reason for hiding this comment

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

Apple Pay is the default mobile wallet, so this should be okay.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants