Skip to content

Paul/add edge asset#207

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

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

Conversation

@paullinator
Copy link
Copy Markdown
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
Moderate risk because it changes transaction normalization across multiple partner plugins (chain pluginIds, EVM chainIds, tokenIds) and adds new CouchDB indexes and bulk-mutation scripts, which could affect downstream analytics and data correctness.

Overview
Adds chain/token asset attribution across partner ingesters. Multiple partner plugins now populate deposit/payoutChainPluginId, deposit/payoutEvmChainId, and deposit/payoutTokenId by mapping provider network/chain fields and (when needed) fetching/caching provider coin metadata (notably banxa, changenow, changehero, godex, letsexchange, exolix, moonpay, sideshift; plus improved token-id creation/logging in lifi).

Introduces a new partner + operational tooling. Adds a new rango partner plugin, adds new CouchDB design-doc indexes for chain/token fields, and introduces two maintenance scripts (fixTokenIds and resetUsdValues) to bulk-normalize tokenIds / force usdValue recalculation; also updates CLI utilities to use scoped logging and improves testpartner to dynamically load a partner by id. Demo endpoints were updated to use apiKey instead of appId when requesting plugin ids/analytics.

Reviewed by Cursor Bugbot for commit b5ae82a. Bugbot is set up for automated code reviews on this repo. Configure here.


Comment thread src/partners/lifi.ts
Comment thread src/partners/sideshift.ts
Comment thread src/partners/letsexchange.ts Outdated
@paullinator paullinator force-pushed the paul/addEdgeAsset branch 3 times, most recently from 8466888 to ddfaa68 Compare December 24, 2025 22:48
Comment thread src/partners/rango.ts Outdated
Comment thread src/partners/letsexchange.ts
Comment thread src/partners/moonpay.ts
Copy link
Copy Markdown
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.

Comment thread src/partners/moonpay.ts
Comment thread src/partners/moonpay.ts Outdated
Comment thread src/partners/moonpay.ts
Comment thread src/partners/moonpay.ts
Comment thread src/partners/moonpay.ts Outdated
Copy link
Copy Markdown
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

Comment thread src/partners/changenow.ts Outdated
Comment thread src/partners/changenow.ts Outdated
Comment thread src/partners/sideshift.ts
Comment thread src/partners/banxa.ts
Comment thread src/queryEngine.ts Outdated
Comment thread src/queryEngine.ts Outdated
Comment thread src/ratesEngine.ts
Comment thread src/ratesEngine.ts Outdated
Comment thread src/queryEngine.ts
Comment thread src/partners/rango.ts Outdated
Comment thread src/partners/rango.ts Outdated
Comment thread src/partners/banxa.ts Outdated
Comment thread src/partners/letsexchange.ts
Comment thread src/partners/moonpay.ts
Comment thread src/partners/thorchain.ts
Copy link
Copy Markdown

@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,

Comment thread src/partners/lifi.ts
Comment thread src/partners/rango.ts Outdated
Comment thread src/partners/letsexchange.ts Outdated
Comment thread src/partners/letsexchange.ts Outdated
Comment thread src/partners/changenow.ts
Comment thread src/partners/letsexchange.ts
Comment thread src/partners/godex.ts Outdated
Copy link
Copy Markdown

@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.

Autofix Details

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

Comment thread src/partners/moonpay.ts Outdated
Comment thread src/partners/letsexchange.ts
Comment thread src/partners/exolix.ts
Comment thread src/partners/bitrefill.ts
Copy link
Copy Markdown

@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
Copy Markdown

@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.

Comment thread src/partners/changenow.ts
Comment thread src/partners/sideshift.ts
Comment thread src/partners/banxa.ts
Comment thread src/partners/moonpay.ts Outdated
Comment thread src/partners/moonpay.ts Outdated
Comment thread src/partners/changenow.ts
Comment thread src/partners/godex.ts
Comment thread src/partners/rango.ts
Comment thread src/partners/banxa.ts
Comment thread src/partners/letsexchange.ts Outdated
@j0ntz
Copy link
Copy Markdown

j0ntz commented Apr 20, 2026

All inline threads from this review have been addressed — either via code changes (5 fixup commits: 685dc66, 351d711, 794e0ce, c2f93f8, 0f391d9) or by replies explaining the rationale where we're keeping the current approach (per-tx async process*Tx for backfill-script reuse, append-all Moonpay txs, throw-on-unknown for card/chain types). Please re-review.

@j0ntz
Copy link
Copy Markdown

j0ntz commented Apr 20, 2026

Review response — all 38 threads addressed

Summary of what changed in code vs. what stays:

Fixup commits (5)

  • 685dc66 sideshift: Added NETWORK_FIELDS_REQUIRED_DATE = '2023-01-01' cutoff in getAssetInfo so older txs with null network don't throw (matches exolix/changehero/letsexchange pattern).
  • 351d711 moonpay: processTx now takes explicit direction: 'buy' | 'sell' passed from the call site. Removes the paymentMethod != null heuristic that could misclassify sell txs as buy.
  • 794e0ce thorchain: Each silent null return in makeThorchainProcessTx now logs txId and reason (affiliate mismatch, non-success, no affiliate output, refund, incomplete 2-pool).
  • c2f93f8 letsexchange: newTxStart uses -1 sentinel and is set to standardTxs.length - 1 on first new tx. Fixes regression where old rollback-window txs could trigger MAX_NEW_TRANSACTIONS when no new txs were found.
  • 0f391d9 queryEngine: Refactored checkUpdateTx to use a fields array with as const and a for loop.

Already addressed in prior commits

  • lifi console.log → scoped log() gated on complete status
  • rango tokenId → now uses createTokenId()
  • moonpay cardType'card' removed; applepay-default removed (throws on unknown)
  • moonpay buy/sell split → asMoonpayTxBase + asMoonpayBuyFields + asMoonpaySellFields
  • ratesEngine fiat branch refactor → embedded USD/non-USD split
  • pickRandomRatesServer extracted (random intentional — simpler than round-robin for concurrent requests)
  • asDisablePartnerQuery JSDoc comment added
  • banxa / sideshift / changenow / letsexchange caches gated by 24h TTL
  • NETWORK_FIELDS_AVAILABLE_DATE now used in letsexchange asset-info skip
  • checkUpdateTx no longer returns undefined
  • rates bookmarking concurrency bug fixed (indexed by query)

Intentionally kept (with reasoning)

  • Per-tx process*Tx async (changenow, sideshift, banxa, letsexchange): Upcoming backfill scripts invoke these directly with only (rawTx, pluginParams) and can't preload cache. 24h cache TTL makes post-warmup calls no-ops.
  • Moonpay keeps all txs (not just complete): We want visibility into failures and drop-offs. Downstream queries that only want completes filter on status === 'complete'.
  • Throw on unknown cardType / chain / coin / paymentMethod: Surfacing unknown enums so we add explicit mapping beats silent mis-labeling. Applies to moonpay cardType, bitrefill paymentMethod, banxa chain mapping, etc.
  • Moonpay statusMap minimal: Unknown statuses → 'other'. rawTx preserved so future backfill via processTx is possible.
  • await Promise.all(promises) per-app in queryEngine: Serves as a per-app barrier so combined partner status logs are consistent before moving to the next app.

Ready for re-review.

Copy link
Copy Markdown

@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.

Autofix Details

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

  • ✅ Fixed: Moonpay sell transactions missing payment type information
    • Updated Moonpay sell transaction processing to use getFiatPaymentType(tx) so payment metadata is preserved.
  • ✅ Fixed: ChangeHero module-level cache shared across all partners
    • Added a 24-hour TTL with timestamp tracking to refresh the ChangeHero currency cache during long-running processes.
  • ✅ Resolved by another fix: ChangeHero currency cache has no TTL refresh
    • This was resolved by the same ChangeHero cache TTL refresh implementation added for the related cache staleness issue.

Create PR

Or push these changes by commenting:

@cursor push 9bef4f3bc8
Preview (9bef4f3bc8)
diff --git a/src/partners/changehero.ts b/src/partners/changehero.ts
--- a/src/partners/changehero.ts
+++ b/src/partners/changehero.ts
@@ -125,6 +125,8 @@
   contractAddress: string | null
 }
 let currencyCache: Map<string, CurrencyInfo> | null = null
+let currencyCacheTimestamp = 0
+const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
 
 function makeCurrencyCacheKey(ticker: string, chain: string): string {
   return `${ticker.toUpperCase()}_${chain.toLowerCase()}`
@@ -180,7 +182,12 @@
   apiKey: string,
   log: ScopedLog
 ): Promise<void> {
-  if (currencyCache != null) return
+  if (
+    currencyCache != null &&
+    Date.now() - currencyCacheTimestamp < CACHE_TTL_MS
+  ) {
+    return
+  }
 
   try {
     const response = await retryFetch(API_URL, {
@@ -213,6 +220,7 @@
         currencyCache.set(key, info)
       }
     }
+    currencyCacheTimestamp = Date.now()
 
     log(`Cached ${currencyCache.size} currency entries`)
   } catch (e) {

diff --git a/src/partners/moonpay.ts b/src/partners/moonpay.ts
--- a/src/partners/moonpay.ts
+++ b/src/partners/moonpay.ts
@@ -372,7 +372,7 @@
       depositAmount: tx.baseCurrencyAmount,
       direction,
       exchangeType: 'fiat',
-      paymentType: null,
+      paymentType: getFiatPaymentType(tx),
       payoutTxid: undefined,
       payoutAddress: undefined,
       payoutCurrency: sellFields.quoteCurrency.code.toUpperCase(),

You can send follow-ups to the cloud agent here.

Comment thread src/partners/moonpay.ts Outdated
Comment thread src/partners/bitrefill.ts Outdated
Comment thread src/partners/changehero.ts Outdated
Comment thread src/partners/changehero.ts Outdated
Comment thread src/partners/banxa.ts
Comment thread src/partners/changenow.ts
Comment thread src/partners/changehero.ts
Copy link
Copy Markdown

@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 2 potential issues.

Autofix Details

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

  • ✅ Fixed: ChangeNow currency cache shared as global singleton
    • I replaced the singleton ChangeNow currency cache with a key-scoped cache map (using API key/public key) and threaded the selected cache through transaction processing.
  • ✅ Fixed: Exported function processTx name is too generic
    • I renamed Moonpay’s exported processor to processMoonpayTx and updated its internal call sites to match the established partner naming convention.

Create PR

Or push these changes by commenting:

@cursor push 3854e5320d
Preview (3854e5320d)
diff --git a/src/partners/changenow.ts b/src/partners/changenow.ts
--- a/src/partners/changenow.ts
+++ b/src/partners/changenow.ts
@@ -117,14 +117,13 @@
 // Key format: "ticker:network" -> tokenContract
 interface CurrencyCache {
   currencies: Map<string, string | null> // ticker:network -> tokenContract
-  loaded: boolean
 }
-
-const currencyCache: CurrencyCache = {
-  currencies: new Map(),
-  loaded: false
+interface ChangeNowCacheEntry {
+  cache: CurrencyCache
+  timestamp: number
 }
-let currencyCacheTimestamp = 0
+const currencyCacheByKey: Map<string, ChangeNowCacheEntry> = new Map()
+const PUBLIC_CACHE_KEY = '__public__'
 const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
 
 /**
@@ -133,15 +132,16 @@
 async function loadCurrencyCache(
   log: ScopedLog,
   apiKey?: string
-): Promise<void> {
-  if (
-    currencyCache.loaded &&
-    Date.now() - currencyCacheTimestamp < CACHE_TTL_MS
-  ) {
-    return
+): Promise<CurrencyCache> {
+  const cacheKey = apiKey ?? PUBLIC_CACHE_KEY
+  const existing = currencyCacheByKey.get(cacheKey)
+  if (existing != null && Date.now() - existing.timestamp < CACHE_TTL_MS) {
+    return existing.cache
   }
 
   try {
+    const cache: CurrencyCache = { currencies: new Map() }
+
     // The exchange/currencies endpoint doesn't require authentication
     const url = 'https://api.changenow.io/v2/exchange/currencies?active=true'
     const response = await retryFetch(url, {
@@ -158,7 +158,7 @@
 
     for (const currency of currencies) {
       const key = `${currency.ticker.toLowerCase()}:${currency.network.toLowerCase()}`
-      currencyCache.currencies.set(key, currency.tokenContract ?? null)
+      cache.currencies.set(key, currency.tokenContract ?? null)
 
       // Also cache by legacyTicker if different from ticker
       if (
@@ -166,13 +166,13 @@
         currency.legacyTicker !== currency.ticker
       ) {
         const legacyKey = `${currency.legacyTicker.toLowerCase()}:${currency.network.toLowerCase()}`
-        currencyCache.currencies.set(legacyKey, currency.tokenContract ?? null)
+        cache.currencies.set(legacyKey, currency.tokenContract ?? null)
       }
     }
 
-    currencyCache.loaded = true
-    currencyCacheTimestamp = Date.now()
+    currencyCacheByKey.set(cacheKey, { cache, timestamp: Date.now() })
     log(`Currency cache loaded with ${currencies.length} entries`)
+    return cache
   } catch (e) {
     log.error(`Error loading currency cache: ${e}`)
     throw e
@@ -183,6 +183,7 @@
  * Look up contract address from cache
  */
 function getContractFromCache(
+  currencyCache: CurrencyCache,
   ticker: string,
   network: string
 ): string | null | undefined {
@@ -338,7 +339,11 @@
  * Get the Edge asset info for a given network and currency code.
  * Uses the cached currency data from the ChangeNow API.
  */
-function getAssetInfo(network: string, currencyCode: string): EdgeAssetInfo {
+function getAssetInfo(
+  currencyCache: CurrencyCache,
+  network: string,
+  currencyCode: string
+): EdgeAssetInfo {
   // Map network to pluginId
   const chainPluginId = CHANGENOW_NETWORK_TO_PLUGIN_ID[network.toLowerCase()]
   if (chainPluginId == null) {
@@ -348,7 +353,11 @@
   const evmChainId = EVM_CHAIN_IDS[chainPluginId]
 
   // Look up contract address from cache
-  const contractAddress = getContractFromCache(currencyCode, network)
+  const contractAddress = getContractFromCache(
+    currencyCache,
+    currencyCode,
+    network
+  )
 
   // null means native token, undefined means cache miss
   if (contractAddress === null) {
@@ -388,7 +397,8 @@
 ): Promise<StandardTx> {
   const { log } = pluginParams
   // Load currency cache before processing transactions
-  await loadCurrencyCache(log)
+  const { apiKeys } = asChangeNowPluginParams(pluginParams)
+  const currencyCache = await loadCurrencyCache(log, apiKeys.apiKey)
 
   const tx: ChangeNowTx = asChangeNowTx(rawTx)
   const date = new Date(
@@ -397,10 +407,18 @@
   const timestamp = date.getTime() / 1000
 
   // Get deposit asset info
-  const depositAsset = getAssetInfo(tx.payin.network, tx.payin.currency)
+  const depositAsset = getAssetInfo(
+    currencyCache,
+    tx.payin.network,
+    tx.payin.currency
+  )
 
   // Get payout asset info
-  const payoutAsset = getAssetInfo(tx.payout.network, tx.payout.currency)
+  const payoutAsset = getAssetInfo(
+    currencyCache,
+    tx.payout.network,
+    tx.payout.currency
+  )
 
   const standardTx: StandardTx = {
     status: statusMap[tx.status],

diff --git a/src/partners/moonpay.ts b/src/partners/moonpay.ts
--- a/src/partners/moonpay.ts
+++ b/src/partners/moonpay.ts
@@ -229,7 +229,7 @@
         const txs = asMoonpayResult(await result.json())
 
         for (const rawTx of txs) {
-          const standardTx = processTx(rawTx, 'sell')
+          const standardTx = processMoonpayTx(rawTx, 'sell')
           standardTxs.push(standardTx)
         }
 
@@ -261,7 +261,7 @@
         // in bulk update it throws an error for document update conflict because of this.
 
         for (const rawTx of txs) {
-          const standardTx = processTx(rawTx, 'buy')
+          const standardTx = processMoonpayTx(rawTx, 'buy')
           standardTxs.push(standardTx)
         }
         if (txs.length > 0) {
@@ -309,7 +309,7 @@
   pluginId: 'moonpay'
 }
 
-export function processTx(
+export function processMoonpayTx(
   rawTx: unknown,
   direction: 'buy' | 'sell'
 ): StandardTx {

You can send follow-ups to the cloud agent here.

Comment thread src/partners/changenow.ts
Comment thread src/partners/moonpay.ts
Copy link
Copy Markdown

@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 3 potential issues.

Autofix Details

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

  • ✅ Fixed: ChangeNow cache never clears stale entries on refresh
    • loadCurrencyCache now builds a fresh Map and atomically replaces currencyCache.currencies, removing delisted entries on refresh.
  • ✅ Fixed: Bitrefill deposits with bitcoin use raw division not smartIsoDateFromTimestamp timestamp
    • processBitrefillTx now uses the normalized timestamp returned by smartIsoDateFromTimestamp(tx.invoiceTime) instead of manually dividing by 1000.
  • ✅ Fixed: Godex getGodexCoinsCache returns stale data after API failure
    • getGodexCoinsCache now falls back to existing or delisted-token cache data on fetch failure so repeated calls do not keep re-hitting a failing API.

Create PR

Or push these changes by commenting:

@cursor push 70aa0556a5
Preview (70aa0556a5)
diff --git a/src/partners/bitrefill.ts b/src/partners/bitrefill.ts
--- a/src/partners/bitrefill.ts
+++ b/src/partners/bitrefill.ts
@@ -254,7 +254,7 @@
   } catch (e) {
     throw new Error(`${String(e)}: ${JSON.stringify(rawTx)}`)
   }
-  const { isoDate } = smartIsoDateFromTimestamp(tx.invoiceTime)
+  const { isoDate, timestamp } = smartIsoDateFromTimestamp(tx.invoiceTime)
   const countryCode = countryCodeMap[tx.country]
 
   if (tx.altcoinPrice != null) {
@@ -276,8 +276,6 @@
   }
   const evmChainId = EVM_CHAIN_IDS[edgeAsset.pluginId]
 
-  const timestamp = tx.invoiceTime / 1000
-
   const { paymentMethod } = tx
   let depositAmountStr: string | undefined
   if (paymentMethod === 'bitcoin') {

diff --git a/src/partners/changenow.ts b/src/partners/changenow.ts
--- a/src/partners/changenow.ts
+++ b/src/partners/changenow.ts
@@ -155,10 +155,11 @@
 
     const result = await response.json()
     const currencies = asChangeNowCurrencyArray(result)
+    const cache = new Map<string, string | null>()
 
     for (const currency of currencies) {
       const key = `${currency.ticker.toLowerCase()}:${currency.network.toLowerCase()}`
-      currencyCache.currencies.set(key, currency.tokenContract ?? null)
+      cache.set(key, currency.tokenContract ?? null)
 
       // Also cache by legacyTicker if different from ticker
       if (
@@ -166,10 +167,11 @@
         currency.legacyTicker !== currency.ticker
       ) {
         const legacyKey = `${currency.legacyTicker.toLowerCase()}:${currency.network.toLowerCase()}`
-        currencyCache.currencies.set(legacyKey, currency.tokenContract ?? null)
+        cache.set(legacyKey, currency.tokenContract ?? null)
       }
     }
 
+    currencyCache.currencies = cache
     currencyCache.loaded = true
     currencyCacheTimestamp = Date.now()
     log(`Currency cache loaded with ${currencies.length} entries`)

diff --git a/src/partners/godex.ts b/src/partners/godex.ts
--- a/src/partners/godex.ts
+++ b/src/partners/godex.ts
@@ -163,7 +163,16 @@
     return cache
   } catch (e) {
     log.error(`Error loading coins cache: ${String(e)}`)
-    throw e
+    if (godexCoinsCache != null) {
+      // Keep using stale cache if refresh fails to avoid repeated failing requests.
+      godexCoinsCacheTimestamp = Date.now()
+      return godexCoinsCache
+    }
+
+    // Fall back to delisted-token cache when the first API fetch fails.
+    godexCoinsCache = cache
+    godexCoinsCacheTimestamp = Date.now()
+    return cache
   }
 }

You can send follow-ups to the cloud agent here.

Comment thread src/partners/changenow.ts
Comment thread src/partners/bitrefill.ts Outdated
Comment thread src/partners/godex.ts
Copy link
Copy Markdown

@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 2 potential issues.

Autofix Details

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

  • ✅ Fixed: Chain lookup missing case normalization in ChangeHero
    • Updated ChangeHero chain map lookup to use chain.toLowerCase() so mixed-case API chain names resolve correctly.
  • ✅ Fixed: Exolix gas token check uses case-sensitive comparison
    • Changed Exolix gas-token contract matching to compare lowercase values so address casing differences no longer break native-token detection.

Create PR

Or push these changes by commenting:

@cursor push cb43a5fe3a
Preview (cb43a5fe3a)
diff --git a/src/partners/changehero.ts b/src/partners/changehero.ts
--- a/src/partners/changehero.ts
+++ b/src/partners/changehero.ts
@@ -256,7 +256,7 @@
     throw new Error(`Missing chain for currency ${currencyCode}`)
   }
 
-  const chainPluginId = CHANGEHERO_CHAIN_TO_PLUGIN_ID[chain]
+  const chainPluginId = CHANGEHERO_CHAIN_TO_PLUGIN_ID[chain.toLowerCase()]
   if (chainPluginId == null) {
     throw new Error(
       `Unknown Changehero chain "${chain}" for currency ${currencyCode}. Add mapping to CHANGEHERO_CHAIN_TO_PLUGIN_ID.`

diff --git a/src/partners/exolix.ts b/src/partners/exolix.ts
--- a/src/partners/exolix.ts
+++ b/src/partners/exolix.ts
@@ -204,7 +204,13 @@
   // Look up tokenId from contract address
   let tokenId: EdgeTokenId = null
   if (contract != null) {
-    if (GASTOKEN_CONTRACTS.includes(contract) && network === currencyCode) {
+    if (
+      GASTOKEN_CONTRACTS.some(
+        gasTokenContract =>
+          gasTokenContract.toLowerCase() === contract.toLowerCase()
+      ) &&
+      network === currencyCode
+    ) {
       tokenId = null
     } else {
       const tokenType = tokenTypes[chainPluginId]

You can send follow-ups to the cloud agent here.

Comment thread src/partners/changehero.ts Outdated
Comment thread src/partners/exolix.ts Outdated
Comment thread src/partners/exolix.ts
Comment thread src/partners/rango.ts Outdated
Copy link
Copy Markdown

@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 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Moonpay silently drops chain info for unknown networks
    • Added an explicit throw in processMetadata when neither networkCode nor chainId resolves to a known chain so unknown Moonpay networks no longer silently pass through.

Create PR

Or push these changes by commenting:

@cursor push 7eb8d22ce6
Preview (7eb8d22ce6)
diff --git a/src/partners/moonpay.ts b/src/partners/moonpay.ts
--- a/src/partners/moonpay.ts
+++ b/src/partners/moonpay.ts
@@ -84,6 +84,11 @@
       ? MOONPAY_NETWORK_TO_PLUGIN_ID[networkCode]
       : undefined) ??
     (chainIdNum != null ? REVERSE_EVM_CHAIN_IDS[chainIdNum] : undefined)
+  if (chainPluginId == null) {
+    throw new Error(
+      `Unknown network for currency ${currencyCode}: networkCode=${networkCode}, chainId=${rawChainId}`
+    )
+  }
 
   // Determine evmChainId
   let evmChainId: number | undefined

You can send follow-ups to the cloud agent here.

Comment thread src/partners/moonpay.ts
Copy link
Copy Markdown

@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 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared a fix for 1 of the 3 issues found in the latest run.

  • ✅ Fixed: ChangeNow shared global currency cache across concurrent partners
    • I serialized concurrent cache refreshes with a shared in-flight promise and atomically swapped in a rebuilt map to prevent clear-and-repopulate races.

Create PR

Or push these changes by commenting:

@cursor push d0596199bf
Preview (d0596199bf)
diff --git a/src/partners/changenow.ts b/src/partners/changenow.ts
--- a/src/partners/changenow.ts
+++ b/src/partners/changenow.ts
@@ -125,6 +125,7 @@
   loaded: false
 }
 let currencyCacheTimestamp = 0
+let currencyCacheLoadPromise: Promise<void> | undefined
 const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
 
 /**
@@ -141,44 +142,57 @@
     return
   }
 
-  try {
-    // The exchange/currencies endpoint doesn't require authentication
-    const url = 'https://api.changenow.io/v2/exchange/currencies?active=true'
-    const response = await retryFetch(url, {
-      method: 'GET'
-    })
+  if (currencyCacheLoadPromise != null) {
+    await currencyCacheLoadPromise
+    return
+  }
 
-    if (!response.ok) {
-      const text = await response.text()
-      throw new Error(`Failed to fetch currencies: ${text}`)
-    }
+  currencyCacheLoadPromise = (async () => {
+    try {
+      // The exchange/currencies endpoint doesn't require authentication
+      const url = 'https://api.changenow.io/v2/exchange/currencies?active=true'
+      const response = await retryFetch(url, {
+        method: 'GET'
+      })
 
-    const result = await response.json()
-    const currencies = asChangeNowCurrencyArray(result)
+      if (!response.ok) {
+        const text = await response.text()
+        throw new Error(`Failed to fetch currencies: ${text}`)
+      }
 
-    // Clear stale entries (delisted/renamed currencies) before repopulating
-    currencyCache.currencies.clear()
+      const result = await response.json()
+      const currencies = asChangeNowCurrencyArray(result)
 
-    for (const currency of currencies) {
-      const key = `${currency.ticker.toLowerCase()}:${currency.network.toLowerCase()}`
-      currencyCache.currencies.set(key, currency.tokenContract ?? null)
+      // Build a new map and swap it in atomically to avoid partial reads.
+      const nextCurrencies = new Map<string, string | null>()
+      for (const currency of currencies) {
+        const key = `${currency.ticker.toLowerCase()}:${currency.network.toLowerCase()}`
+        nextCurrencies.set(key, currency.tokenContract ?? null)
 
-      // Also cache by legacyTicker if different from ticker
-      if (
-        currency.legacyTicker != null &&
-        currency.legacyTicker !== currency.ticker
-      ) {
-        const legacyKey = `${currency.legacyTicker.toLowerCase()}:${currency.network.toLowerCase()}`
-        currencyCache.currencies.set(legacyKey, currency.tokenContract ?? null)
+        // Also cache by legacyTicker if different from ticker
+        if (
+          currency.legacyTicker != null &&
+          currency.legacyTicker !== currency.ticker
+        ) {
+          const legacyKey = `${currency.legacyTicker.toLowerCase()}:${currency.network.toLowerCase()}`
+          nextCurrencies.set(legacyKey, currency.tokenContract ?? null)
+        }
       }
+
+      currencyCache.currencies = nextCurrencies
+      currencyCache.loaded = true
+      currencyCacheTimestamp = Date.now()
+      log(`Currency cache loaded with ${currencies.length} entries`)
+    } catch (e) {
+      log.error(`Error loading currency cache: ${e}`)
+      throw e
     }
+  })()
 
-    currencyCache.loaded = true
-    currencyCacheTimestamp = Date.now()
-    log(`Currency cache loaded with ${currencies.length} entries`)
-  } catch (e) {
-    log.error(`Error loading currency cache: ${e}`)
-    throw e
+  try {
+    await currencyCacheLoadPromise
+  } finally {
+    currencyCacheLoadPromise = undefined
   }
 }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit f1bbd4a. Configure here.

Comment thread src/partners/changehero.ts
Comment thread src/partners/changenow.ts
Comment thread src/partners/moonpay.ts
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.

5 participants