Skip to content

Add missing partner asset and payment mappings#223

Open
paullinator wants to merge 8 commits into
masterfrom
paul/fixPartnerMappings
Open

Add missing partner asset and payment mappings#223
paullinator wants to merge 8 commits into
masterfrom
paul/fixPartnerMappings

Conversation

@paullinator

Copy link
Copy Markdown
Member

Summary

This branch adds mappings that were causing the query engine to halt (fail-closed) and block collection of newer transactions:

  • SideShift: map SWARMS on Solana to its mint address (delisted from SideShift coins API, added to DELISTED_COINS)
  • Rango: map the SONIC blockchain to the sonic Edge pluginId (also includes MONAD/SUI mappings and stricter per-tx error handling from deploy staging)
  • Banxa: map "Primer Paypal Pay" and "Primer Google Pay" payment types to paypal and googlepay

Note: This branch also includes deploy-staged work already covered by other open PRs (n.exchange plugin #217, Xgram #216, LetsExchange network fix #222). Consider merging those first, then rebasing this branch to land only the mapping commit — or merge this as a bundled deploy promotion.

Commits included (vs master)

  • Add missing partner asset and payment mappings
  • LetsExchange native network inference fix
  • n.exchange plugin integration
  • Xgram partner plugin
  • Rango/ChangeNow token-id handling improvements

Test plan

  • Confirm SideShift sync progresses past SWARMS-solana transactions
  • Confirm Rango sync handles SONIC blockchain transactions
  • Confirm Banxa orders with Primer Paypal Pay / Primer Google Pay map to correct fiat payment types

Made with Cursor

MMrj9 and others added 8 commits June 4, 2026 07:49
Co-authored-by: Cursor <cursoragent@cursor.com>
When an asset has a contract address it is a token, but several plugins
silently fell back to a native (tokenId: null) mapping when the token
could not be resolved. That prices the token with the chain's gas-token
rate and overcounts volume whenever the token is worth less than the gas
token.

- nexchange: throw when a contract-bearing asset is on a chain whose
  tokenType is missing, instead of returning tokenId: null.
- changenow: drop the try/catch around createTokenId that swallowed
  failures and returned tokenId: null.
- rango: drop the per-tx try/catch that logged and continued, silently
  dropping any transaction whose asset could not be resolved.

All three plugins paginate oldest-to-newest and persist progress on
throw, so a failing order halts and is retried next run rather than being
mispriced or silently dropped.

Also add the SUI and MONAD chain mappings to rango: these were
previously dropped silently and would now halt the plugin. Verified by
reprocessing the last three months of orders (nexchange 22.7k, changenow
61.9k, rango 2.9k) with zero processing failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Some older LetsExchange transactions return null network fields for
unambiguous native assets (e.g. ETH, BTC, XRP), causing processing to
throw "Missing network" and stalling the query. Add a currency-to-network
fallback for 1:1 native tickers so these transactions process correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>
These mappings were causing the query engine to halt and stop scanning
forward for the affected partners (fail-closed design), blocking
collection of newer transactions until the mapping was added.

- SideShift: map SWARMS on Solana to its mint address (delisted from the
  SideShift coins API, so added to DELISTED_COINS).
- Rango: map the SONIC blockchain to the `sonic` Edge pluginId.
- Banxa: map the "Primer Paypal Pay" and "Primer Google Pay" payment
  types to paypal and googlepay respectively.

Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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.

Fix All in Cursor

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

  • ✅ Fixed: processTx signatures omit pluginParams
    • Refactored both processors to accept PluginParams and load their own currency caches while updating callers and tests.

Create PR

Or push these changes by commenting:

@cursor push 4d53fdc78c
Preview (4d53fdc78c)
diff --git a/src/partners/nexchange.ts b/src/partners/nexchange.ts
--- a/src/partners/nexchange.ts
+++ b/src/partners/nexchange.ts
@@ -68,7 +68,11 @@
 const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days
 const LIMIT = 200
 const MAX_ERROR_TEXT_LENGTH = 500
+const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
 
+let currencyCache: NexchangeCurrencyInfoMap | undefined
+let currencyCacheTimestamp = 0
+
 const statusMap: { [key: string]: Status } = {
   released: 'complete',
   complete: 'complete',
@@ -176,6 +180,13 @@
 export async function fetchNexchangeCurrencyMap(): Promise<
   NexchangeCurrencyInfoMap
 > {
+  if (
+    currencyCache != null &&
+    Date.now() - currencyCacheTimestamp < CACHE_TTL_MS
+  ) {
+    return currencyCache
+  }
+
   const response = await retryFetch(CURRENCY_URL, { method: 'GET' })
   if (!response.ok) {
     const text = await response.text()
@@ -189,9 +200,21 @@
   for (const currency of currencies) {
     map[currency.code.toUpperCase()] = currency
   }
+  currencyCache = map
+  currencyCacheTimestamp = Date.now()
   return map
 }
 
+async function loadNexchangeCurrencyMap(
+  pluginParams: PluginParams
+): Promise<NexchangeCurrencyInfoMap> {
+  const { currencyMap } = (pluginParams as unknown) as {
+    currencyMap?: NexchangeCurrencyInfoMap
+  }
+  if (currencyMap != null) return currencyMap
+  return await fetchNexchangeCurrencyMap()
+}
+
 /**
  * Returned by `resolveNexchangeAsset`.  The shape is consistent across all
  * exit branches so callers can rely on the field set.  `chainPluginId`,
@@ -315,7 +338,7 @@
     // audit-orders endpoint omits, so it is required for chain/token
     // enrichment.  Fetch it up front; a failure aborts the run (saving
     // nothing) rather than persisting a batch of unenriched transactions.
-    const currencyMap = await fetchNexchangeCurrencyMap()
+    await fetchNexchangeCurrencyMap()
 
     while (true) {
       const params: string[] = [
@@ -341,7 +364,7 @@
       const { orders, nextCursor, hasMore } = asNexchangeOrdersResponse(json)
 
       for (const rawOrder of orders) {
-        const standardTx = processNexchangeTx(rawOrder, currencyMap)
+        const standardTx = await processNexchangeTx(rawOrder, pluginParams)
         txByOrderId.set(standardTx.orderId, standardTx)
         if (standardTx.isoDate > latestIsoDate) {
           latestIsoDate = standardTx.isoDate
@@ -382,11 +405,12 @@
   pluginId: 'nexchange'
 }
 
-export function processNexchangeTx(
+export async function processNexchangeTx(
   rawTx: unknown,
-  currencyMap: NexchangeCurrencyInfoMap
-): StandardTx {
+  pluginParams: PluginParams
+): Promise<StandardTx> {
   const tx = asNexchangeOrder(rawTx)
+  const currencyMap = await loadNexchangeCurrencyMap(pluginParams)
   const lowerStatus = tx.status.toLowerCase()
   const status = statusMap[lowerStatus] ?? 'other'
   const { isoDate, timestamp } = parseApiDate(tx.createdAt)

diff --git a/src/partners/xgram.ts b/src/partners/xgram.ts
--- a/src/partners/xgram.ts
+++ b/src/partners/xgram.ts
@@ -281,6 +281,23 @@
   return currencyCache
 }
 
+async function loadXgramCurrencies(
+  pluginParams: PluginParams
+): Promise<XgramCurrencies> {
+  const { currencies } = (pluginParams as unknown) as {
+    currencies?: XgramCurrencies
+  }
+  if (currencies != null) return currencies
+
+  const { log } = pluginParams
+  const { apiKeys } = asStandardPluginParams(pluginParams)
+  const { apiKey } = apiKeys
+  if (apiKey == null) {
+    throw new Error('Xgram apiKey required for asset info lookup')
+  }
+  return await fetchCurrencyCache(apiKey, log)
+}
+
 function isNativeTicker(chainPluginId: string, currencyCode: string): boolean {
   return NATIVE_TICKERS[chainPluginId]?.has(currencyCode.toUpperCase()) ?? false
 }
@@ -375,7 +392,7 @@
   if (previousTimestamp < 0) previousTimestamp = 0
   const targetIsoDate = new Date(previousTimestamp).toISOString()
 
-  const currencies = await fetchCurrencyCache(apiKey, log)
+  await fetchCurrencyCache(apiKey, log)
 
   // Because Xgram pages from newest to oldest, the watermark can only be
   // advanced once the entire newer-than-target range has been fetched and
@@ -427,7 +444,7 @@
     }
     let oldestIsoDate = '999999999999999999999999999999999999'
     for (const rawTx of txs) {
-      const standardTx = processXgramTx(rawTx, currencies)
+      const standardTx = await processXgramTx(rawTx, pluginParams)
       if (standardTx.isoDate < oldestIsoDate) {
         oldestIsoDate = standardTx.isoDate
       }
@@ -462,11 +479,12 @@
   pluginId: 'xgram'
 }
 
-export function processXgramTx(
+export async function processXgramTx(
   rawTx: unknown,
-  currencies: XgramCurrencies
-): StandardTx {
+  pluginParams: PluginParams
+): Promise<StandardTx> {
   const tx: XgramTxTx = asXgramTx(rawTx)
+  const currencies = await loadXgramCurrencies(pluginParams)
   const { isoDate, timestamp } = parseXgramDate(tx.date)
   const depositCurrency = tx['x-fromCcy'].toUpperCase()
   const payoutCurrency = tx['x-toCcy'].toUpperCase()

diff --git a/test/nexchange.test.ts b/test/nexchange.test.ts
--- a/test/nexchange.test.ts
+++ b/test/nexchange.test.ts
@@ -9,6 +9,7 @@
   resolveNexchangeAsset,
   toQueryIsoDate
 } from '../src/partners/nexchange'
+import { PluginParams } from '../src/types'
 
 const currencyMap: NexchangeCurrencyInfoMap = {
   BTC: {
@@ -92,6 +93,19 @@
   }
 }
 
+const testLog = Object.assign(() => {}, {
+  warn: () => {},
+  error: () => {}
+})
+const pluginParams: PluginParams & {
+  currencyMap: NexchangeCurrencyInfoMap
+} = {
+  settings: {},
+  apiKeys: {},
+  log: testLog,
+  currencyMap
+}
+
 function makeRawOrder(overrides: { [key: string]: any } = {}): unknown {
   return {
     orderId: 'NEX-DEFAULT',
@@ -116,10 +130,10 @@
 
 describe('nexchange plugin', () => {
   describe('processNexchangeTx', () => {
-    it('maps Edge audit order payload into StandardTx with chain plugin and token ids', () => {
-      const tx = processNexchangeTx(
+    it('maps Edge audit order payload into StandardTx with chain plugin and token ids', async () => {
+      const tx = await processNexchangeTx(
         makeRawOrder({ orderId: 'NEX-ABCD1234' }),
-        currencyMap
+        pluginParams
       )
 
       expect(tx.orderId).to.equal('NEX-ABCD1234')
@@ -159,10 +173,10 @@
       ['something-else', 'other']
     ]
     for (const [rawStatus, expected] of statusCases) {
-      it(`maps status "${rawStatus}" to "${expected}"`, () => {
-        const tx = processNexchangeTx(
+      it(`maps status "${rawStatus}" to "${expected}"`, async () => {
+        const tx = await processNexchangeTx(
           makeRawOrder({ status: rawStatus }),
-          currencyMap
+          pluginParams
         )
         expect(tx.status).to.equal(expected)
       })

diff --git a/test/xgram.test.ts b/test/xgram.test.ts
--- a/test/xgram.test.ts
+++ b/test/xgram.test.ts
@@ -2,6 +2,7 @@
 import { describe, it } from 'mocha'
 
 import { processXgramTx, XgramCurrencies } from '../src/partners/xgram'
+import { PluginParams } from '../src/types'
 
 const currencies: XgramCurrencies = {
   BTC: {
@@ -31,9 +32,22 @@
   }
 }
 
+const testLog = Object.assign(() => {}, {
+  warn: () => {},
+  error: () => {}
+})
+const pluginParams: PluginParams & {
+  currencies: XgramCurrencies
+} = {
+  settings: {},
+  apiKeys: {},
+  log: testLog,
+  currencies
+}
+
 describe('processXgramTx', () => {
-  it('maps source and destination asset IDs', () => {
-    const tx = processXgramTx(
+  it('maps source and destination asset IDs', async () => {
+    const tx = await processXgramTx(
       {
         id: 'dyv3a2tdbgipvh0',
         'x-status': 'x-completed',
@@ -49,7 +63,7 @@
         date: '27.05.2026 20:57:28',
         txId: 'payout-hash'
       },
-      currencies
+      pluginParams
     )
 
     expect(tx.status).equals('complete')
@@ -66,8 +80,8 @@
     expect(tx.isoDate).equals('2026-05-27T20:57:28.000Z')
   })
 
-  it('uses expected amounts and chain-specific token IDs for pending rows', () => {
-    const tx = processXgramTx(
+  it('uses expected amounts and chain-specific token IDs for pending rows', async () => {
+    const tx = await processXgramTx(
       {
         id: 'tmah3a2td9cp20q0',
         'x-status': 'x-new',
@@ -83,7 +97,7 @@
         date: '27.05.2026 20:56:54',
         txId: null
       },
-      currencies
+      pluginParams
     )
 
     expect(tx.status).equals('pending')
@@ -97,8 +111,8 @@
     expect(tx.payoutTokenId).equals(null)
   })
 
-  it('maps historical native currencies missing from the currency API', () => {
-    const tx = processXgramTx(
+  it('maps historical native currencies missing from the currency API', async () => {
+    const tx = await processXgramTx(
       {
         id: 'talr3a0e49fplpog',
         'x-status': 'x-timeout',
@@ -114,7 +128,7 @@
         date: '12.05.2026 20:07:51',
         txId: null
       },
-      currencies
+      pluginParams
     )
 
     expect(tx.status).equals('expired')

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

Reviewed by Cursor Bugbot for commit 5025d6d. Configure here.

Comment thread src/partners/nexchange.ts
export function processNexchangeTx(
rawTx: unknown,
currencyMap: NexchangeCurrencyInfoMap
): StandardTx {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

processTx signatures omit pluginParams

Medium Severity

New processNexchangeTx and processXgramTx take a currency map or cache as a second argument instead of PluginParams. Uniform backfill callers cannot invoke these processors the same way as other partner process*Tx helpers without extra setup.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: process*Tx must have uniform signature for backfill scripts

Reviewed by Cursor Bugbot for commit 5025d6d. Configure here.

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.

2 participants