From 90c546d6fb2c68b1294965c146dc8af65142ccf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Moruj=C3=A3o?= Date: Thu, 26 Feb 2026 16:37:41 +0000 Subject: [PATCH 1/2] Feat: nexchange plugin Co-authored-by: Cursor --- src/demo/partners.ts | 4 + src/partners/nexchange.ts | 421 ++++++++++++++++++++++++++++++++++++++ src/queryEngine.ts | 2 + test/nexchange.test.ts | 276 +++++++++++++++++++++++++ 4 files changed, 703 insertions(+) create mode 100644 src/partners/nexchange.ts create mode 100644 test/nexchange.test.ts diff --git a/src/demo/partners.ts b/src/demo/partners.ts index 14e7dc4b..71244537 100644 --- a/src/demo/partners.ts +++ b/src/demo/partners.ts @@ -97,6 +97,10 @@ export default { type: 'fiat', color: '#7214F5' }, + nexchange: { + type: 'swap', + color: '#1D31B6' + }, paybis: { type: 'fiat', color: '#FFB400' diff --git a/src/partners/nexchange.ts b/src/partners/nexchange.ts new file mode 100644 index 00000000..798d9019 --- /dev/null +++ b/src/partners/nexchange.ts @@ -0,0 +1,421 @@ +import { + asArray, + asBoolean, + asEither, + asNull, + asObject, + asOptional, + asString, + asUnknown +} from 'cleaners' + +import { + asStandardPluginParams, + PartnerPlugin, + PluginParams, + PluginResult, + StandardTx, + Status +} from '../types' +import { retryFetch, safeParseFloat } from '../util' +import { createTokenId, tokenTypes } from '../util/asEdgeTokenId' +import { EVM_CHAIN_IDS } from '../util/chainIds' + +// n.exchange endpoints are fixed for all deployments; they intentionally are +// not exposed via apiKeys. Auth uses the modern `x-api-key` header — the +// legacy `Authorization: ApiKey ` form is not used. +const BASE_URL = 'https://api.n.exchange/en/api/v1' +const CURRENCY_URL = 'https://api.n.exchange/en/api/v2/currency/' + +const asNexchangeTransfer = asObject({ + currency: asString, + amount: asString, + address: asOptional(asEither(asString, asNull), null), + txid: asOptional(asEither(asString, asNull), null) +}) + +const asNexchangeOrder = asObject({ + orderId: asString, + status: asString, + createdAt: asString, + deposit: asNexchangeTransfer, + payout: asNexchangeTransfer, + countryCode: asOptional(asEither(asString, asNull), null) +}) + +const asNexchangeOrdersResponse = asObject({ + orders: asArray(asUnknown), + nextCursor: asOptional(asEither(asString, asNull), null), + hasMore: asBoolean +}) + +// Each entry from /api/v2/currency/. Only the fields below are needed to +// derive Edge chain plugin / token ids; other catalog fields (decimals, +// withdrawal_fee, etc.) are intentionally ignored. +const asNexchangeCurrencyMeta = asObject({ + code: asString, + is_fiat: asOptional(asBoolean, false), + network: asOptional(asEither(asString, asNull), null), + contract_address: asOptional(asEither(asString, asNull), null), + common_symbol: asOptional(asEither(asString, asNull), null) +}) + +const asNexchangeCurrencyList = asArray(asNexchangeCurrencyMeta) + +export type NexchangeCurrencyMeta = ReturnType +export type NexchangeCurrencyInfoMap = Record + +const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days +const LIMIT = 200 +const MAX_ERROR_TEXT_LENGTH = 500 + +const statusMap: { [key: string]: Status } = { + released: 'complete', + complete: 'complete', + completed: 'complete', + done: 'complete', + processing: 'processing', + exchanging: 'processing', + confirming: 'processing', + waiting: 'pending', + pending: 'pending', + created: 'pending', + new: 'pending', + expired: 'expired', + blocked: 'blocked', + refund: 'refunded', + refunded: 'refunded', + cancelled: 'other', + canceled: 'other', + failed: 'other' +} + +// Map of n.exchange network identifier -> Edge chain plugin id. +// Network strings are lowercased before lookup so we are resilient to casing +// changes from n.exchange (e.g. HyperEvm vs HYPEREVM). Networks that have no +// Edge equivalent are intentionally omitted; those transactions will be +// reported without chain/token id enrichment. +// +// n.exchange uses TRON as the canonical network name in the v2 currency +// catalog, but historical Edge audit-orders payloads have also been observed +// to reference TRX — both are mapped so the plugin works regardless of which +// the API returns. +export const NEXCHANGE_NETWORK_TO_PLUGIN_ID: Record = { + ada: 'cardano', + algo: 'algorand', + arb: 'arbitrum', + atom: 'cosmoshub', + avaxc: 'avalanche', + base: 'base', + bch: 'bitcoincash', + bsc: 'binancesmartchain', + btc: 'bitcoin', + dash: 'dash', + doge: 'dogecoin', + dot: 'polkadot', + eos: 'eos', + etc: 'ethereumclassic', + eth: 'ethereum', + fil: 'filecoin', + filevm: 'filecoinfevm', + ftm: 'fantom', + hbar: 'hedera', + hyperevm: 'hyperevm', + ltc: 'litecoin', + // n.exchange exposes both MATIC and POL networks; both reference the same + // Polygon chain (chain id 137). + matic: 'polygon', + op: 'optimism', + pol: 'polygon', + sol: 'solana', + sonic: 'sonic', + sui: 'sui', + ton: 'ton', + tron: 'tron', + trx: 'tron', + xlm: 'stellar', + xmr: 'monero', + xrp: 'ripple', + xtz: 'tezos', + zec: 'zcash' +} + +export function toQueryIsoDate(latestIsoDate: string): string { + let previousTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK + if (previousTimestamp < 0) previousTimestamp = 0 + return new Date(previousTimestamp).toISOString() +} + +export function parseApiDate( + dateString: string +): { isoDate: string; timestamp: number } { + const hasTimezone = /(Z|[+-]\d{2}:\d{2})$/.test(dateString) + const normalized = hasTimezone ? dateString : `${dateString}Z` + const date = new Date(normalized) + if (isNaN(date.getTime())) { + throw new Error(`Invalid createdAt date: ${dateString}`) + } + return { + isoDate: date.toISOString(), + timestamp: date.getTime() / 1000 + } +} + +function truncateForError(text: string): string { + return text.length > MAX_ERROR_TEXT_LENGTH + ? `${text.slice(0, MAX_ERROR_TEXT_LENGTH)}…` + : text +} + +/** + * Fetches the n.exchange currency catalog and returns a lookup keyed by the + * uppercased currency code. The catalog supplies the network and contract + * address fields that the audit-orders endpoint omits, which Edge needs to + * populate chain plugin id and token id. + */ +export async function fetchNexchangeCurrencyMap(): Promise< + NexchangeCurrencyInfoMap +> { + const response = await retryFetch(CURRENCY_URL, { method: 'GET' }) + if (!response.ok) { + const text = await response.text() + throw new Error( + `HTTP ${response.status.toString()}: ${truncateForError(text)}` + ) + } + const json = await response.json() + const currencies = asNexchangeCurrencyList(json) + const map: NexchangeCurrencyInfoMap = {} + for (const currency of currencies) { + map[currency.code.toUpperCase()] = currency + } + return map +} + +/** + * Returned by `resolveNexchangeAsset`. The shape is consistent across all + * exit branches so callers can rely on the field set. `chainPluginId`, + * `tokenId`, and `evmChainId` are `undefined` whenever the asset cannot be + * mapped to an Edge chain/token; `tokenId` is `null` to mean "native chain + * asset" (per Edge's tokenId conventions), so the distinction between + * "unmapped" and "native" is preserved. + */ +export interface ResolvedNexchangeAsset { + currencyCode: string + chainPluginId: string | undefined + tokenId: string | null | undefined + evmChainId: number | undefined +} + +function asUnmapped(currencyCode: string): ResolvedNexchangeAsset { + return { + currencyCode, + chainPluginId: undefined, + tokenId: undefined, + evmChainId: undefined + } +} + +/** + * Resolves an n.exchange currency code into Edge chain plugin and token + * identifiers. Returns an "unmapped" shape (all chain fields undefined) when + * the network is unknown to Edge, when no metadata is available, or when the + * currency is fiat. Callers should leave the corresponding StandardTx + * fields undefined in that case so downstream rates lookup can fall back to + * currency-code mappings. + * + * Throws if a token-supporting chain has a contract address that cannot be + * converted into an Edge tokenId, so the bad payload is surfaced rather than + * silently producing an unenriched transaction. + */ +export function resolveNexchangeAsset( + currencyCode: string, + currencyMap: NexchangeCurrencyInfoMap +): ResolvedNexchangeAsset { + const upper = currencyCode.toUpperCase() + const meta = currencyMap[upper] + + // Default to the raw nexchange code; downstream `standardizeNames` handles + // some of the composite codes (e.g. USDCSOL -> USDC). + let normalizedCode = upper + + if (meta == null) return asUnmapped(normalizedCode) + + // Prefer the canonical symbol when n.exchange supplies a clean ticker. + // Some entries embed suffixes like "USDT-old"; only use the symbol when it + // is alphanumeric. + if ( + meta.common_symbol != null && + meta.common_symbol !== '' && + /^[A-Za-z0-9]+$/.test(meta.common_symbol) + ) { + normalizedCode = meta.common_symbol.toUpperCase() + } + + if (meta.is_fiat) return asUnmapped(normalizedCode) + + const network = meta.network + if (network == null || network === '') return asUnmapped(normalizedCode) + + const chainPluginId = NEXCHANGE_NETWORK_TO_PLUGIN_ID[network.toLowerCase()] + if (chainPluginId == null) return asUnmapped(normalizedCode) + + const evmChainId = EVM_CHAIN_IDS[chainPluginId] + const contractAddress = meta.contract_address + + // No contract_address means a native chain asset. + if (contractAddress == null || contractAddress === '') { + return { + currencyCode: normalizedCode, + chainPluginId, + tokenId: null, + evmChainId + } + } + + // The contract address is present but the chain does not support tokens in + // Edge's model; fall back to a chain-only mapping so we at least populate + // the chain plugin id for rates lookup. + const tokenType = tokenTypes[chainPluginId] + if (tokenType == null) { + return { + currencyCode: normalizedCode, + chainPluginId, + tokenId: null, + evmChainId + } + } + + const tokenId = createTokenId(tokenType, normalizedCode, contractAddress) + return { currencyCode: normalizedCode, chainPluginId, tokenId, evmChainId } +} + +export async function queryNexchange( + pluginParams: PluginParams +): Promise { + const { log } = pluginParams + const { settings, apiKeys } = asStandardPluginParams(pluginParams) + const { apiKey } = apiKeys + let { latestIsoDate } = settings + + if (apiKey == null || apiKey === '') { + return { settings: { latestIsoDate }, transactions: [] } + } + + const headers = { 'x-api-key': apiKey } + const queryDateFrom = toQueryIsoDate(latestIsoDate) + const txByOrderId: Map = new Map() + let cursor: string | undefined + let offset = 0 + + try { + // The currency catalog supplies the network/contract metadata that the + // 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() + + while (true) { + const params: string[] = [ + `dateFrom=${encodeURIComponent(queryDateFrom)}`, + `limit=${LIMIT.toString()}`, + 'sortDirection=ASC' + ] + if (cursor != null && cursor !== '') { + params.push(`cursor=${encodeURIComponent(cursor)}`) + } else { + params.push(`offset=${offset.toString()}`) + } + + const url = `${BASE_URL}/audits/edge/orders?${params.join('&')}` + const response = await retryFetch(url, { headers, method: 'GET' }) + if (!response.ok) { + const text = await response.text() + throw new Error( + `HTTP ${response.status.toString()}: ${truncateForError(text)}` + ) + } + const json = await response.json() + const { orders, nextCursor, hasMore } = asNexchangeOrdersResponse(json) + + for (const rawOrder of orders) { + const standardTx = processNexchangeTx(rawOrder, currencyMap) + txByOrderId.set(standardTx.orderId, standardTx) + if (standardTx.isoDate > latestIsoDate) { + latestIsoDate = standardTx.isoDate + } + } + log(`latestIsoDate ${latestIsoDate}`) + + if (!hasMore || orders.length === 0) break + + if (nextCursor != null && nextCursor !== '') { + cursor = nextCursor + } else { + // Reset cursor when falling back to offset, otherwise the previous + // cursor value would re-pin pagination to the wrong position next + // iteration. + cursor = undefined + offset += orders.length + } + } + } catch (e) { + log.error(String(e)) + // Do not re-throw. Pagination is oldest -> newest, so any transactions + // already collected are fully processed and older than latestIsoDate; we + // can safely persist that progress and resume from it next run. A failing + // order halts pagination (it is never silently skipped) so its volume is + // retried rather than lost. + } + + return { + settings: { latestIsoDate }, + transactions: Array.from(txByOrderId.values()) + } +} + +export const nexchange: PartnerPlugin = { + queryFunc: queryNexchange, + pluginName: 'Nexchange', + pluginId: 'nexchange' +} + +export function processNexchangeTx( + rawTx: unknown, + currencyMap: NexchangeCurrencyInfoMap +): StandardTx { + const tx = asNexchangeOrder(rawTx) + const lowerStatus = tx.status.toLowerCase() + const status = statusMap[lowerStatus] ?? 'other' + const { isoDate, timestamp } = parseApiDate(tx.createdAt) + + const deposit = resolveNexchangeAsset(tx.deposit.currency, currencyMap) + const payout = resolveNexchangeAsset(tx.payout.currency, currencyMap) + + return { + status, + orderId: tx.orderId, + countryCode: tx.countryCode, + depositTxid: tx.deposit.txid ?? undefined, + depositAddress: tx.deposit.address ?? undefined, + depositCurrency: deposit.currencyCode, + depositChainPluginId: deposit.chainPluginId, + depositTokenId: deposit.tokenId, + depositEvmChainId: deposit.evmChainId, + depositAmount: safeParseFloat(tx.deposit.amount), + direction: null, + exchangeType: 'swap', + paymentType: null, + payoutTxid: tx.payout.txid ?? undefined, + payoutAddress: tx.payout.address ?? undefined, + payoutCurrency: payout.currencyCode, + payoutChainPluginId: payout.chainPluginId, + payoutTokenId: payout.tokenId, + payoutEvmChainId: payout.evmChainId, + payoutAmount: safeParseFloat(tx.payout.amount), + timestamp, + isoDate, + usdValue: -1, + rawTx + } +} diff --git a/src/queryEngine.ts b/src/queryEngine.ts index 8551c54f..ea572f50 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -22,6 +22,7 @@ import { letsexchange } from './partners/letsexchange' import { libertyx } from './partners/libertyx' import { lifi } from './partners/lifi' import { moonpay } from './partners/moonpay' +import { nexchange } from './partners/nexchange' import { paybis } from './partners/paybis' import { paytrie } from './partners/paytrie' import { rango } from './partners/rango' @@ -74,6 +75,7 @@ const plugins = [ lifi, maya, moonpay, + nexchange, paybis, paytrie, rango, diff --git a/test/nexchange.test.ts b/test/nexchange.test.ts new file mode 100644 index 00000000..38b3e406 --- /dev/null +++ b/test/nexchange.test.ts @@ -0,0 +1,276 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { + NEXCHANGE_NETWORK_TO_PLUGIN_ID, + NexchangeCurrencyInfoMap, + parseApiDate, + processNexchangeTx, + resolveNexchangeAsset, + toQueryIsoDate +} from '../src/partners/nexchange' + +const currencyMap: NexchangeCurrencyInfoMap = { + BTC: { + code: 'BTC', + is_fiat: false, + network: 'BTC', + contract_address: null, + common_symbol: 'BTC' + }, + USDTTRX: { + code: 'USDTTRX', + is_fiat: false, + network: 'TRON', + contract_address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + common_symbol: 'USDT' + }, + USDCSOL: { + code: 'USDCSOL', + is_fiat: false, + network: 'SOL', + contract_address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + common_symbol: 'USDC' + }, + USDTERC: { + code: 'USDTERC', + is_fiat: false, + network: 'ETH', + contract_address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + common_symbol: 'USDT' + }, + ETHBASE: { + code: 'ETHBASE', + is_fiat: false, + network: 'BASE', + contract_address: null, + common_symbol: 'ETH' + }, + HYPE: { + code: 'HYPE', + is_fiat: false, + network: 'HyperEvm', + contract_address: null, + common_symbol: null + }, + USDTMATIC: { + code: 'USDTMATIC', + is_fiat: false, + network: 'MATIC', + contract_address: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', + common_symbol: 'USDT-old' + }, + USD: { + code: 'USD', + is_fiat: true, + network: null, + contract_address: null, + common_symbol: null + }, + XYZTOKEN: { + code: 'XYZTOKEN', + is_fiat: false, + network: 'UNKNOWNNET', + contract_address: '0xdeadbeef', + common_symbol: 'XYZ' + }, + BADTOKEN: { + code: 'BADTOKEN', + is_fiat: false, + network: 'ATOM', + contract_address: 'NOT A VALID DENOM', + common_symbol: 'BAD' + } +} + +function makeRawOrder(overrides: { [key: string]: any } = {}): unknown { + return { + orderId: 'NEX-DEFAULT', + status: 'Released', + createdAt: '2026-01-20T11:43:10+00:00', + deposit: { + currency: 'USDTTRX', + amount: '100.00000000', + address: 'TQhaM...sample', + txid: '0xdep123' + }, + payout: { + currency: 'BTC', + amount: '0.00145000', + address: 'bc1q...sample', + txid: '0xpay123' + }, + countryCode: 'PT', + ...overrides + } +} + +describe('nexchange plugin', () => { + describe('processNexchangeTx', () => { + it('maps Edge audit order payload into StandardTx with chain plugin and token ids', () => { + const tx = processNexchangeTx( + makeRawOrder({ orderId: 'NEX-ABCD1234' }), + currencyMap + ) + + expect(tx.orderId).to.equal('NEX-ABCD1234') + expect(tx.status).to.equal('complete') + expect(tx.exchangeType).to.equal('swap') + expect(tx.direction).to.equal(null) + expect(tx.depositCurrency).to.equal('USDT') + expect(tx.depositChainPluginId).to.equal('tron') + expect(tx.depositTokenId).to.equal('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t') + expect(tx.depositEvmChainId).to.equal(undefined) + expect(tx.payoutCurrency).to.equal('BTC') + expect(tx.payoutChainPluginId).to.equal('bitcoin') + expect(tx.payoutTokenId).to.equal(null) + expect(tx.depositAmount).to.equal(100) + expect(tx.payoutAmount).to.equal(0.00145) + expect(tx.countryCode).to.equal('PT') + expect(tx.isoDate).to.equal('2026-01-20T11:43:10.000Z') + expect(tx.timestamp).to.equal(1768909390) + }) + + const statusCases: Array<[string, string]> = [ + ['Released', 'complete'], + ['completed', 'complete'], + ['done', 'complete'], + ['processing', 'processing'], + ['confirming', 'processing'], + ['pending', 'pending'], + ['NEW', 'pending'], + ['Waiting', 'pending'], + ['expired', 'expired'], + ['blocked', 'blocked'], + ['Refund', 'refunded'], + ['refunded', 'refunded'], + ['cancelled', 'other'], + ['canceled', 'other'], + ['failed', 'other'], + ['something-else', 'other'] + ] + for (const [rawStatus, expected] of statusCases) { + it(`maps status "${rawStatus}" to "${expected}"`, () => { + const tx = processNexchangeTx( + makeRawOrder({ status: rawStatus }), + currencyMap + ) + expect(tx.status).to.equal(expected) + }) + } + }) + + describe('resolveNexchangeAsset', () => { + it('lowercases and 0x-strips EVM token addresses and returns the EVM chain id', () => { + const asset = resolveNexchangeAsset('USDTERC', currencyMap) + expect(asset.currencyCode).to.equal('USDT') + expect(asset.chainPluginId).to.equal('ethereum') + expect(asset.tokenId).to.equal('dac17f958d2ee523a2206206994597c13d831ec7') + expect(asset.evmChainId).to.equal(1) + }) + + it('returns null tokenId for native EVM assets (e.g. ETHBASE)', () => { + const asset = resolveNexchangeAsset('ETHBASE', currencyMap) + expect(asset.currencyCode).to.equal('ETH') + expect(asset.chainPluginId).to.equal('base') + expect(asset.tokenId).to.equal(null) + expect(asset.evmChainId).to.equal(8453) + }) + + it('matches mixed-case n.exchange networks case-insensitively', () => { + const asset = resolveNexchangeAsset('HYPE', currencyMap) + expect(asset.chainPluginId).to.equal('hyperevm') + expect(asset.tokenId).to.equal(null) + expect(asset.evmChainId).to.equal(999) + }) + + it('returns unmapped fields when the currency is not in the catalog', () => { + const asset = resolveNexchangeAsset('XYZ', currencyMap) + expect(asset.currencyCode).to.equal('XYZ') + expect(asset.chainPluginId).to.equal(undefined) + expect(asset.tokenId).to.equal(undefined) + expect(asset.evmChainId).to.equal(undefined) + }) + + it('returns unmapped fields for fiat currencies', () => { + const asset = resolveNexchangeAsset('USD', currencyMap) + expect(asset.currencyCode).to.equal('USD') + expect(asset.chainPluginId).to.equal(undefined) + expect(asset.tokenId).to.equal(undefined) + expect(asset.evmChainId).to.equal(undefined) + }) + + it('returns unmapped fields when the network is unknown to Edge', () => { + const asset = resolveNexchangeAsset('XYZTOKEN', currencyMap) + expect(asset.currencyCode).to.equal('XYZ') + expect(asset.chainPluginId).to.equal(undefined) + expect(asset.tokenId).to.equal(undefined) + expect(asset.evmChainId).to.equal(undefined) + }) + + it('keeps the raw currency code when common_symbol is non-alphanumeric (e.g. "USDT-old")', () => { + const asset = resolveNexchangeAsset('USDTMATIC', currencyMap) + expect(asset.currencyCode).to.equal('USDTMATIC') + expect(asset.chainPluginId).to.equal('polygon') + expect(asset.tokenId).to.equal('c2132d05d31c914a87c6611c10748aeb04b58e8f') + }) + + it('throws when a token chain has a contract address that fails createTokenId', () => { + expect(() => resolveNexchangeAsset('BADTOKEN', currencyMap)).to.throw( + /Invalid contract address/ + ) + }) + }) + + describe('parseApiDate', () => { + it('parses an offset-suffixed date', () => { + const result = parseApiDate('2026-01-20T11:43:10+00:00') + expect(result.isoDate).to.equal('2026-01-20T11:43:10.000Z') + expect(result.timestamp).to.equal(1768909390) + }) + + it('parses a Z-suffixed date', () => { + const result = parseApiDate('2026-01-20T11:43:10Z') + expect(result.isoDate).to.equal('2026-01-20T11:43:10.000Z') + }) + + it('appends Z when no timezone suffix is present', () => { + const result = parseApiDate('2026-01-20T11:43:10') + expect(result.isoDate).to.equal('2026-01-20T11:43:10.000Z') + }) + + it('throws on an invalid date string', () => { + expect(() => parseApiDate('not-a-date')).to.throw(/Invalid createdAt/) + }) + }) + + describe('toQueryIsoDate', () => { + it('rewinds latestIsoDate by the lookback window', () => { + const result = toQueryIsoDate('2026-01-20T00:00:00.000Z') + expect(result).to.equal('2026-01-15T00:00:00.000Z') + }) + + it('clamps to the epoch when latestIsoDate is near zero', () => { + const result = toQueryIsoDate('1970-01-01T00:00:00.000Z') + expect(result).to.equal('1970-01-01T00:00:00.000Z') + }) + }) + + describe('NEXCHANGE_NETWORK_TO_PLUGIN_ID', () => { + it('covers the representative n.exchange networks used by Edge users', () => { + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.eth).to.equal('ethereum') + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.bsc).to.equal('binancesmartchain') + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.sol).to.equal('solana') + }) + + it('maps both TRON and TRX (the v2 catalog and historical audit forms)', () => { + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.tron).to.equal('tron') + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.trx).to.equal('tron') + }) + + it('maps both MATIC and POL to the same Polygon chain id', () => { + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.matic).to.equal('polygon') + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.pol).to.equal('polygon') + }) + }) +}) From 130f10143f48e930101721887f73c13900ed4b5d Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Thu, 28 May 2026 16:11:43 -0700 Subject: [PATCH 2/2] Throw instead of pricing tokens as native gas tokens 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 --- src/partners/changenow.ts | 31 +++++++++++++------------------ src/partners/nexchange.ts | 26 ++++++++++++++------------ src/partners/rango.ts | 24 +++++++++++++----------- test/nexchange.test.ts | 18 ++++++++++++++++++ 4 files changed, 58 insertions(+), 41 deletions(-) diff --git a/src/partners/changenow.ts b/src/partners/changenow.ts index e4eb6814..f91c9ca6 100644 --- a/src/partners/changenow.ts +++ b/src/partners/changenow.ts @@ -376,24 +376,19 @@ function getAssetInfo(network: string, currencyCode: string): EdgeAssetInfo { ) } - try { - const tokenId = createTokenId( - tokenType, - currencyCode.toUpperCase(), - contractAddress - ) - return { - chainPluginId, - evmChainId, - tokenId - } - } catch (e) { - // If tokenId creation fails, treat as native (no log available in this sync function) - return { - chainPluginId, - evmChainId, - tokenId: null - } + // Let createTokenId throw if the contract address cannot be converted: a + // token must never be silently downgraded to a native (tokenId: null) + // mapping, which would price it with the chain's gas-token rate and + // overcount volume whenever the token is worth less than the gas token. + const tokenId = createTokenId( + tokenType, + currencyCode.toUpperCase(), + contractAddress + ) + return { + chainPluginId, + evmChainId, + tokenId } } diff --git a/src/partners/nexchange.ts b/src/partners/nexchange.ts index 798d9019..bf27fcb0 100644 --- a/src/partners/nexchange.ts +++ b/src/partners/nexchange.ts @@ -224,9 +224,12 @@ function asUnmapped(currencyCode: string): ResolvedNexchangeAsset { * fields undefined in that case so downstream rates lookup can fall back to * currency-code mappings. * - * Throws if a token-supporting chain has a contract address that cannot be - * converted into an Edge tokenId, so the bad payload is surfaced rather than - * silently producing an unenriched transaction. + * Throws when an asset has a contract address (i.e. it is a token) but cannot + * be converted into an Edge tokenId — either because Edge does not model + * tokens on that chain, or because the address fails createTokenId. This is + * deliberate: a token must never be silently downgraded to a native + * (tokenId: null) mapping, which would price it with the chain's gas-token + * rate and overcount volume. */ export function resolveNexchangeAsset( currencyCode: string, @@ -273,17 +276,16 @@ export function resolveNexchangeAsset( } } - // The contract address is present but the chain does not support tokens in - // Edge's model; fall back to a chain-only mapping so we at least populate - // the chain plugin id for rates lookup. + // The contract address is present, so this is a token. If Edge does not + // model tokens on this chain we must NOT fall back to a native + // (tokenId: null) mapping: that would price the token using the chain's + // gas-token rate and overcount volume whenever the token is worth less than + // the gas token. Surface the gap loudly instead. const tokenType = tokenTypes[chainPluginId] if (tokenType == null) { - return { - currencyCode: normalizedCode, - chainPluginId, - tokenId: null, - evmChainId - } + throw new Error( + `Unknown tokenType for chainPluginId "${chainPluginId}" (currency: ${normalizedCode}, contract: ${contractAddress}). Add tokenType to tokenTypes.` + ) } const tokenId = createTokenId(tokenType, normalizedCode, contractAddress) diff --git a/src/partners/rango.ts b/src/partners/rango.ts index 62445ee8..8a122089 100644 --- a/src/partners/rango.ts +++ b/src/partners/rango.ts @@ -115,10 +115,12 @@ const RANGO_BLOCKCHAIN_TO_PLUGIN_ID: Record = { FANTOM: 'fantom', LTC: 'litecoin', MATIC: 'polygon', + MONAD: 'monad', OPTIMISM: 'optimism', OSMOSIS: 'osmosis', POLYGON: 'polygon', SOLANA: 'solana', + SUI: 'sui', TON: 'ton', TRON: 'tron', XRPL: 'ripple', @@ -178,17 +180,17 @@ export async function queryRango( let processedCount = 0 for (const rawTx of txs) { - try { - const standardTx = processRangoTx(rawTx, pluginParams) - standardTxs.push(standardTx) - processedCount++ - - if (standardTx.isoDate > latestIsoDate) { - latestIsoDate = standardTx.isoDate - } - } catch (e) { - // Log but continue processing other transactions - log.warn(`Failed to process tx: ${String(e)}`) + // Do not catch per-tx errors: a failure here (e.g. a token that cannot + // be resolved to a tokenId) must halt the run rather than silently + // dropping the transaction. The outer catch saves progress up to the + // last fully processed tx, and the oldest-to-newest ordering means the + // failing tx is retried on the next run. + const standardTx = processRangoTx(rawTx, pluginParams) + standardTxs.push(standardTx) + processedCount++ + + if (standardTx.isoDate > latestIsoDate) { + latestIsoDate = standardTx.isoDate } } diff --git a/test/nexchange.test.ts b/test/nexchange.test.ts index 38b3e406..b287b0c2 100644 --- a/test/nexchange.test.ts +++ b/test/nexchange.test.ts @@ -80,6 +80,15 @@ const currencyMap: NexchangeCurrencyInfoMap = { network: 'ATOM', contract_address: 'NOT A VALID DENOM', common_symbol: 'BAD' + }, + // A token (has a contract address) on a chain Edge does not model tokens for. + USDCXLM: { + code: 'USDCXLM', + is_fiat: false, + network: 'XLM', + contract_address: + 'USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + common_symbol: 'USDC' } } @@ -220,6 +229,15 @@ describe('nexchange plugin', () => { /Invalid contract address/ ) }) + + it('throws for a token (contract address) on a chain Edge does not model tokens for', () => { + // USDC on Stellar: pricing it as native XLM would overcount volume, so + // the unmapped token type must surface as an error rather than fall back + // to tokenId: null. + expect(() => resolveNexchangeAsset('USDCXLM', currencyMap)).to.throw( + /Unknown tokenType for chainPluginId "stellar"/ + ) + }) }) describe('parseApiDate', () => {