Add missing partner asset and payment mappings#223
Conversation
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>
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
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.
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.
| export function processNexchangeTx( | ||
| rawTx: unknown, | ||
| currencyMap: NexchangeCurrencyInfoMap | ||
| ): StandardTx { |
There was a problem hiding this comment.
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)
Triggered by learned rule: process*Tx must have uniform signature for backfill scripts
Reviewed by Cursor Bugbot for commit 5025d6d. Configure here.



Summary
This branch adds mappings that were causing the query engine to halt (fail-closed) and block collection of newer transactions:
DELISTED_COINS)sonicEdge pluginId (also includes MONAD/SUI mappings and stricter per-tx error handling from deploy staging)paypalandgooglepayNote: 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)
Test plan
Made with Cursor