From ce1b8d403e77b83fdf2cefe84fecf5916d74a2ea 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 01/10] Feat: nexchange plugin Co-authored-by: Cursor --- src/demo/partners.ts | 4 + src/partners/nexchange.ts | 426 ++++++++++++++++++++++++++++++++++++++ src/queryEngine.ts | 2 + test/nexchange.test.ts | 276 ++++++++++++++++++++++++ 4 files changed, 708 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..3d0a09bd --- /dev/null +++ b/src/partners/nexchange.ts @@ -0,0 +1,426 @@ +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 + // Rows consumed so far, including pages fetched by cursor. Offset paging must + // resume from this count, otherwise falling back from a cursor would re-request + // pages that were already consumed. + let fetchedCount = 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) + fetchedCount += orders.length + + 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 = fetchedCount + } + } + } 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 5c9ce353..0d7cea08 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 5e16f6f4899ee79f8b0c6704718aa46f477e1568 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Thu, 28 May 2026 16:11:43 -0700 Subject: [PATCH 02/10] 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 3d0a09bd..5dd43b21 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', () => { From 96229b18f67a74c333632e4c28e548345f2d7128 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Fri, 29 May 2026 07:03:45 -0700 Subject: [PATCH 03/10] Add Xgram Co-authored-by: Cursor --- src/demo/partners.ts | 4 + src/partners/xgram.ts | 528 ++++++++++++++++++++++++++++++++++++++++++ src/queryEngine.ts | 4 +- test/xgram.test.ts | 126 ++++++++++ 4 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 src/partners/xgram.ts create mode 100644 test/xgram.test.ts diff --git a/src/demo/partners.ts b/src/demo/partners.ts index 71244537..97048697 100644 --- a/src/demo/partners.ts +++ b/src/demo/partners.ts @@ -156,5 +156,9 @@ export default { xanpool: { type: 'fiat', color: '#46228B' + }, + xgram: { + type: 'swap', + color: '#0FA7B1' } } as const diff --git a/src/partners/xgram.ts b/src/partners/xgram.ts new file mode 100644 index 00000000..9fdb84c8 --- /dev/null +++ b/src/partners/xgram.ts @@ -0,0 +1,528 @@ +import { + asArray, + asEither, + asMap, + asMaybe, + asNumber, + asObject, + asOptional, + asString, + asUnknown, + asValue +} from 'cleaners' + +import { + asStandardPluginParams, + PartnerPlugin, + PluginParams, + PluginResult, + StandardTx, + Status +} from '../types' +import { retryFetch, safeParseFloat, snooze } from '../util' +import { createTokenId, EdgeTokenId, tokenTypes } from '../util/asEdgeTokenId' +import { EVM_CHAIN_IDS } from '../util/chainIds' + +const asXgramStatus = asMaybe( + asValue( + 'x-new', + 'x-awaiting_funds', + 'x-funds_received', + 'x-processing_exchange', + 'x-transferring', + 'x-completed', + 'x-timeout', + 'x-error', + 'x-transfer_error', + 'x-returned' + ), + 'other' +) + +const asXgramAmount = asMaybe(asEither(asNumber, asString), null) + +const asXgramTx = asObject({ + date: asString, + id: asString, + 'x-status': asXgramStatus, + 'x-fromCcy': asString, + 'x-toCcy': asString, + 'x-ccyDepositAddress': asString, + 'x-ccyDepositHash': asMaybe(asString, undefined), + 'x-ccyExpectedAmountFrom': asNumber, + 'x-ccyExpectedAmountTo': asNumber, + 'x-ccyAmountFrom': asXgramAmount, + 'x-ccyDestinationAddress': asString, + 'x-ccyAmountTo': asXgramAmount, + txId: asMaybe(asString, undefined) +}) + +const asXgramResult = asObject({ exchanges: asArray(asUnknown) }) +const asXgramCurrency = asObject({ + coinName: asString, + network: asString, + contract: asOptional(asString, '') +}) +const asXgramCurrencies = asMap(asXgramCurrency) + +type XgramTxTx = ReturnType +type XgramStatus = ReturnType +export type XgramCurrencies = ReturnType + +interface EdgeAssetInfo { + chainPluginId: string + evmChainId: number | undefined + tokenId: EdgeTokenId +} + +const MAX_RETRIES = 5 +const MAX_ERROR_TEXT_LENGTH = 500 +const LIMIT = 50 +const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days +const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours + +const statusMap: { [key in XgramStatus]: Status } = { + 'x-new': 'pending', + 'x-awaiting_funds': 'confirming', + 'x-funds_received': 'processing', + 'x-processing_exchange': 'processing', + 'x-transferring': 'withdrawing', + 'x-completed': 'complete', + 'x-timeout': 'expired', + 'x-error': 'failed', + 'x-transfer_error': 'failed', + 'x-returned': 'refunded', + other: 'other' +} + +const XGRAM_NETWORK_TO_PLUGIN_ID: Record = { + ADA: 'cardano', + Algorand: 'algorand', + ARBITRUM: 'arbitrum', + AVAX: 'avalanche', + 'AVAX C-Chain': 'avalanche', + AVAXC: 'avalanche', + BASE: 'base', + BEP20: 'binancesmartchain', + Bitcoin: 'bitcoin', + BitcoinCash: 'bitcoincash', + 'Bitcoin SV': 'bitcoinsv', + BitcoinGold: 'bitcoingold', + CELO: 'celo', + Cosmos: 'cosmoshub', + 'Digital Cash': 'dash', + EOS: 'eos', + ERC20: 'ethereum', + ETH: 'ethereum', + EthereumPoW: 'ethereumpow', + Fantom: 'fantom', + Filecoin: 'filecoin', + FIO: 'fio', + Hedera: 'hedera', + Litecoin: 'litecoin', + Monero: 'monero', + OPTIMISM: 'optimism', + Polkadot: 'polkadot', + POLYGON: 'polygon', + Quantum: 'qtum', + Ravencoin: 'ravencoin', + RBTC: 'rsk', + Ripple: 'ripple', + SOL: 'solana', + 'Stellar Lumens': 'stellar', + SUI: 'sui', + Tezos: 'tezos', + TON: 'ton', + TRC20: 'tron', + Vertcoin: 'vertcoin', + Wax: 'wax', + XEC: 'ecash', + ZANO: 'zano', + Zcash: 'zcash', + Zcoin: 'zcoin', + ZKSYNC: 'zksync' +} + +const NATIVE_TICKERS: Record> = { + algorand: new Set(['ALGO']), + arbitrum: new Set(['ETH']), + avalanche: new Set(['AVAX']), + base: new Set(['ETH']), + binancesmartchain: new Set(['BNB']), + bitcoin: new Set(['BTC']), + bitcoincash: new Set(['BCH']), + bitcoingold: new Set(['BTG']), + bitcoinsv: new Set(['BSV']), + cardano: new Set(['ADA']), + celo: new Set(['CELO']), + cosmoshub: new Set(['ATOM']), + dash: new Set(['DASH']), + ecash: new Set(['XEC']), + eos: new Set(['EOS']), + ethereum: new Set(['ETH']), + ethereumpow: new Set(['ETHW']), + fantom: new Set(['FTM']), + filecoin: new Set(['FIL']), + fio: new Set(['FIO']), + hedera: new Set(['HBAR']), + litecoin: new Set(['LTC']), + monero: new Set(['XMR']), + optimism: new Set(['ETH']), + polkadot: new Set(['DOT']), + polygon: new Set(['MATIC', 'POL']), + qtum: new Set(['QTUM']), + ravencoin: new Set(['RVN']), + rsk: new Set(['RBTC']), + ripple: new Set(['XRP']), + solana: new Set(['SOL']), + stellar: new Set(['XLM']), + sui: new Set(['SUI']), + tezos: new Set(['XTZ']), + ton: new Set(['TON']), + tron: new Set(['TRX']), + vertcoin: new Set(['VTC']), + wax: new Set(['WAXP']), + zano: new Set(['ZANO']), + zcash: new Set(['ZEC']), + zcoin: new Set(['XZC']), + zksync: new Set(['ETHZKSYNC']) +} + +const GASTOKEN_CONTRACTS = new Set([ + '0x0000000000000000000000000000000000000000', + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + 'So11111111111111111111111111111111111111111', + 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c' +]) + +let currencyCache: XgramCurrencies | undefined +let currencyCacheTimestamp = 0 + +const MISSING_CURRENCIES: XgramCurrencies = { + ADA: { + coinName: 'Cardano', + network: 'ADA', + contract: '' + }, + ATOM: { + coinName: 'Cosmos', + network: 'Cosmos', + contract: '' + }, + LINK: { + coinName: 'Chainlink', + network: 'ERC20', + contract: '0x514910771af9ca656af840dff83e8264ecf986ca' + }, + USDC: { + coinName: 'USD Coin', + network: 'ERC20', + contract: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + }, + USDCSOLANA: { + coinName: 'USD Coin', + network: 'SOL', + contract: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + }, + USDT: { + coinName: 'Tether', + network: 'ERC20', + contract: '0xdac17f958d2ee523a2206206994597c13d831ec7' + }, + USDTSOLANA: { + coinName: 'Tether', + network: 'SOL', + contract: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB' + }, + USDTTRC20: { + coinName: 'Tether', + network: 'TRC20', + contract: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + }, + ZEC: { + coinName: 'Zcash', + network: 'Zcash', + contract: '' + } +} + +async function fetchCurrencyCache( + apiKey: string, + log: PluginParams['log'] +): Promise { + if ( + currencyCache != null && + Date.now() - currencyCacheTimestamp < CACHE_TTL_MS + ) { + return currencyCache + } + + const response = await retryFetch( + 'https://xgram.io/api/v1/list-currency-options', + { + method: 'GET', + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json' + } + } + ) + if (!response.ok) { + const text = await response.text() + throw new Error( + `Xgram currency list error ${response.status}: ${truncateForError(text)}` + ) + } + + const result = await response.json() + const currencies = asXgramCurrencies(result) + + // The live catalog is authoritative. Only fill in tickers it omits, so + // hardcoded networks and contracts can never mask API corrections. + for (const [currencyCode, currency] of Object.entries(MISSING_CURRENCIES)) { + if (currencies[currencyCode] == null) { + currencies[currencyCode] = currency + } + } + + currencyCache = currencies + currencyCacheTimestamp = Date.now() + log(`Cached ${Object.keys(currencyCache).length} Xgram currencies`) + return currencyCache +} + +/** + * Upstream error bodies land in the logs verbatim, so bound them instead of + * persisting an unknown amount of partner response data. + */ +function truncateForError(text: string): string { + return text.length > MAX_ERROR_TEXT_LENGTH + ? `${text.slice(0, MAX_ERROR_TEXT_LENGTH)}…` + : text +} + +function isNativeTicker(chainPluginId: string, currencyCode: string): boolean { + return NATIVE_TICKERS[chainPluginId]?.has(currencyCode.toUpperCase()) ?? false +} + +function isGasTokenContract(contract: string): boolean { + return ( + GASTOKEN_CONTRACTS.has(contract) || + GASTOKEN_CONTRACTS.has(contract.toLowerCase()) + ) +} + +function getAssetInfo( + currencyCode: string, + currencies: XgramCurrencies +): EdgeAssetInfo { + const currency = currencies[currencyCode] + if (currency == null) { + throw new Error(`Unknown Xgram currency: ${currencyCode}`) + } + + const chainPluginId = XGRAM_NETWORK_TO_PLUGIN_ID[currency.network] + if (chainPluginId == null) { + throw new Error( + `Unknown Xgram network "${currency.network}" for ${currencyCode}` + ) + } + + const evmChainId = EVM_CHAIN_IDS[chainPluginId] + const contract = (currency.contract ?? '').trim() + const isNative = + isNativeTicker(chainPluginId, currencyCode) || isGasTokenContract(contract) + + if (contract === '' || isNative) { + if (isNative) { + return { chainPluginId, evmChainId, tokenId: null } + } + throw new Error( + `Missing Xgram contract for non-native ${currencyCode} on ${currency.network}` + ) + } + + const tokenType = tokenTypes[chainPluginId] + if (tokenType == null) { + throw new Error( + `Unknown tokenType for ${chainPluginId} (${currencyCode} on ${currency.network})` + ) + } + + return { + chainPluginId, + evmChainId, + tokenId: createTokenId(tokenType, currencyCode, contract) + } +} + +function parseAmount( + amount: ReturnType, + fallback: number +): number { + if (amount == null) return fallback + if (typeof amount === 'number') return amount + return safeParseFloat(amount) +} + +function parseXgramDate(date: string): { isoDate: string; timestamp: number } { + const match = date.match(/^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}:\d{2}:\d{2})$/) + if (match == null) { + throw new Error(`Unexpected Xgram date format: ${date}`) + } + const [, day, month, year, time] = match + const parsed = new Date(`${year}-${month}-${day}T${time}Z`) + if (Number.isNaN(parsed.getTime())) { + throw new Error(`Invalid Xgram date: ${date}`) + } + return { isoDate: parsed.toISOString(), timestamp: parsed.getTime() / 1000 } +} + +export const queryXgram = async ( + pluginParams: PluginParams +): Promise => { + const { log } = pluginParams + const { settings, apiKeys } = asStandardPluginParams(pluginParams) + const { apiKey } = apiKeys + const { latestIsoDate } = settings + + if (apiKey == null) { + return { settings: { latestIsoDate }, transactions: [] } + } + + const standardTxs: StandardTx[] = [] + let previousTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK + if (previousTimestamp < 0) previousTimestamp = 0 + const targetIsoDate = new Date(previousTimestamp).toISOString() + + const currencies = 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 + // processed without error. Track the candidate watermark separately and only + // return it when the run completes cleanly; bailing out early (e.g. a + // permanent fetch failure) must leave the persisted watermark untouched so + // the next run re-queries the same range instead of skipping the orders that + // were never reached. + let newLatestIsoDate = latestIsoDate + let completed = false + let page = 0 + let retry = 0 + let done = false + while (!done) { + const url = `https://xgram.io/api/v1/exchange-history?page=${page}&limit=${LIMIT}` + let txs + try { + const response = await retryFetch(url, { + method: 'GET', + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json' + } + }) + if (!response.ok) { + const text = await response.text() + throw new Error( + `Xgram history error ${response.status}: ${truncateForError(text)}` + ) + } + const result = await response.json() + txs = asXgramResult(result).exchanges + } catch (e) { + log.error(String(e)) + // Retry a few times with time delay to prevent throttling + retry++ + if (retry <= MAX_RETRIES) { + log.warn(`Snoozing ${5 * retry}s`) + await snooze(5000 * retry) + continue + } else { + // Permanent fetch failure: stop without advancing the watermark. + break + } + } + + if (txs.length === 0) { + // Reached the end of Xgram's history: the full range was fetched. + completed = true + break + } + let oldestIsoDate = '999999999999999999999999999999999999' + for (const rawTx of txs) { + const standardTx = processXgramTx(rawTx, currencies) + if (standardTx.isoDate < oldestIsoDate) { + oldestIsoDate = standardTx.isoDate + } + if (standardTx.isoDate < targetIsoDate) { + // Reached the lookback boundary: every order newer than the target has + // been processed, so the run is complete. + completed = true + done = true + break + } + standardTxs.push(standardTx) + if (standardTx.isoDate > newLatestIsoDate) { + newLatestIsoDate = standardTx.isoDate + } + } + log( + `Xgram page ${page} oldestIsoDate ${oldestIsoDate} targetIsoDate ${targetIsoDate}` + ) + page += 1 + retry = 0 + } + const out: PluginResult = { + settings: { latestIsoDate: completed ? newLatestIsoDate : latestIsoDate }, + transactions: standardTxs + } + return out +} + +export const xgram: PartnerPlugin = { + queryFunc: queryXgram, + pluginName: 'xgram', + pluginId: 'xgram' +} + +export function processXgramTx( + rawTx: unknown, + currencies: XgramCurrencies +): StandardTx { + const tx: XgramTxTx = asXgramTx(rawTx) + const { isoDate, timestamp } = parseXgramDate(tx.date) + const depositCurrency = tx['x-fromCcy'].toUpperCase() + const payoutCurrency = tx['x-toCcy'].toUpperCase() + const depositAsset = getAssetInfo(depositCurrency, currencies) + const payoutAsset = getAssetInfo(payoutCurrency, currencies) + const standardTx: StandardTx = { + status: statusMap[tx['x-status']], + orderId: tx.id, + countryCode: null, + depositTxid: tx['x-ccyDepositHash'], + depositAddress: tx['x-ccyDepositAddress'], + depositCurrency, + depositChainPluginId: depositAsset.chainPluginId, + depositEvmChainId: depositAsset.evmChainId, + depositTokenId: depositAsset.tokenId, + depositAmount: parseAmount( + tx['x-ccyAmountFrom'], + tx['x-ccyExpectedAmountFrom'] + ), + direction: null, + exchangeType: 'swap', + paymentType: null, + payoutTxid: tx.txId, + payoutAddress: tx['x-ccyDestinationAddress'], + payoutCurrency, + payoutChainPluginId: payoutAsset.chainPluginId, + payoutEvmChainId: payoutAsset.evmChainId, + payoutTokenId: payoutAsset.tokenId, + payoutAmount: parseAmount(tx['x-ccyAmountTo'], tx['x-ccyExpectedAmountTo']), + timestamp, + isoDate, + usdValue: -1, + rawTx + } + + return standardTx +} diff --git a/src/queryEngine.ts b/src/queryEngine.ts index 0d7cea08..ee34571c 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -35,6 +35,7 @@ import { maya, thorchain } from './partners/thorchain' import { transak } from './partners/transak' import { wyre } from './partners/wyre' import { xanpool } from './partners/xanpool' +import { xgram } from './partners/xgram' import { asApp, asApps, @@ -87,7 +88,8 @@ const plugins = [ thorchain, transak, wyre, - xanpool + xanpool, + xgram ] const QUERY_FREQ_MS = 60 * 1000 const MAX_CONCURRENT_QUERIES = 3 diff --git a/test/xgram.test.ts b/test/xgram.test.ts new file mode 100644 index 00000000..b86f57c9 --- /dev/null +++ b/test/xgram.test.ts @@ -0,0 +1,126 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { processXgramTx, XgramCurrencies } from '../src/partners/xgram' + +const currencies: XgramCurrencies = { + BTC: { + coinName: 'Bitcoin', + network: 'Bitcoin', + contract: '' + }, + ADA: { + coinName: 'Cardano', + network: 'ADA', + contract: '' + }, + USDT: { + coinName: 'Tether', + network: 'ERC20', + contract: '0xdac17f958d2ee523a2206206994597c13d831ec7' + }, + USDTTRC20: { + coinName: 'Tether', + network: 'TRC20', + contract: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + }, + ZEC: { + coinName: 'Zcash', + network: 'Zcash', + contract: '' + } +} + +describe('processXgramTx', () => { + it('maps source and destination asset IDs', () => { + const tx = processXgramTx( + { + id: 'dyv3a2tdbgipvh0', + 'x-status': 'x-completed', + 'x-fromCcy': 'BTC', + 'x-toCcy': 'USDT', + 'x-ccyDepositAddress': 'bc1q8tgkyamr4jvlfw2ccaqg5gd2tskqs9h6r7fra7', + 'x-ccyDepositHash': 'deposit-hash', + 'x-ccyDestinationAddress': '0xf12fb83D413c509506635A663D188B1Dc7fA0C47', + 'x-ccyExpectedAmountFrom': 0.01334746, + 'x-ccyExpectedAmountTo': 992.5, + 'x-ccyAmountFrom': '0.0133', + 'x-ccyAmountTo': '990.1', + date: '27.05.2026 20:57:28', + txId: 'payout-hash' + }, + currencies + ) + + expect(tx.status).equals('complete') + expect(tx.depositCurrency).equals('BTC') + expect(tx.depositAmount).equals(0.0133) + expect(tx.depositChainPluginId).equals('bitcoin') + expect(tx.depositEvmChainId).equals(undefined) + expect(tx.depositTokenId).equals(null) + expect(tx.payoutCurrency).equals('USDT') + expect(tx.payoutAmount).equals(990.1) + expect(tx.payoutChainPluginId).equals('ethereum') + expect(tx.payoutEvmChainId).equals(1) + expect(tx.payoutTokenId).equals('dac17f958d2ee523a2206206994597c13d831ec7') + 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( + { + id: 'tmah3a2td9cp20q0', + 'x-status': 'x-new', + 'x-fromCcy': 'USDTTRC20', + 'x-toCcy': 'BTC', + 'x-ccyDepositAddress': '0xcc56c6a4B3Fa0Cc4b672f8bDfd08f420F901d7D3', + 'x-ccyDepositHash': null, + 'x-ccyDestinationAddress': 'bc1qcrean77uds2gwggjzyry4vw30j80j7lhhvvczl', + 'x-ccyExpectedAmountFrom': 1001.699001, + 'x-ccyExpectedAmountTo': 0.01308, + 'x-ccyAmountFrom': null, + 'x-ccyAmountTo': null, + date: '27.05.2026 20:56:54', + txId: null + }, + currencies + ) + + expect(tx.status).equals('pending') + expect(tx.depositCurrency).equals('USDTTRC20') + expect(tx.depositAmount).equals(1001.699001) + expect(tx.depositChainPluginId).equals('tron') + expect(tx.depositTokenId).equals('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t') + expect(tx.payoutCurrency).equals('BTC') + expect(tx.payoutAmount).equals(0.01308) + expect(tx.payoutChainPluginId).equals('bitcoin') + expect(tx.payoutTokenId).equals(null) + }) + + it('maps historical native currencies missing from the currency API', () => { + const tx = processXgramTx( + { + id: 'talr3a0e49fplpog', + 'x-status': 'x-timeout', + 'x-fromCcy': 'ZEC', + 'x-toCcy': 'ADA', + 'x-ccyDepositAddress': 't1example', + 'x-ccyDepositHash': null, + 'x-ccyDestinationAddress': 'addr1example', + 'x-ccyExpectedAmountFrom': 1.2, + 'x-ccyExpectedAmountTo': 123, + 'x-ccyAmountFrom': null, + 'x-ccyAmountTo': null, + date: '12.05.2026 20:07:51', + txId: null + }, + currencies + ) + + expect(tx.status).equals('expired') + expect(tx.depositChainPluginId).equals('zcash') + expect(tx.depositTokenId).equals(null) + expect(tx.payoutChainPluginId).equals('cardano') + expect(tx.payoutTokenId).equals(null) + }) +}) From 084ddb072680b11556493e4bd4d8651976949ac1 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Tue, 9 Jun 2026 11:59:15 -0700 Subject: [PATCH 04/10] Infer LetsExchange native network when API omits network fields 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 --- src/partners/letsexchange.ts | 69 ++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/src/partners/letsexchange.ts b/src/partners/letsexchange.ts index e05a8718..de6bfe0d 100644 --- a/src/partners/letsexchange.ts +++ b/src/partners/letsexchange.ts @@ -201,6 +201,68 @@ const LETSEXCHANGE_NETWORK_TO_PLUGIN_ID: Record = { ZKSYNC: 'zksync' } +// When the API omits network fields, infer native chain from currency code. +// Only unambiguous 1:1 native-ticker cases (not USDT, USDC, BNB, etc.). +const LETSEXCHANGE_CURRENCY_TO_DEFAULT_NETWORK: Record = { + ADA: 'ADA', + ALGO: 'ALGO', + ARRR: 'ARRR', + ATOM: 'ATOM', + AVAX: 'AVAXC', + BCH: 'BCH', + BSV: 'BSV', + BTC: 'BTC', + BTG: 'BTG', + CELO: 'CELO', + COREUM: 'COREUM', + DASH: 'DASH', + DGB: 'DGB', + DOGE: 'DOGE', + DOT: 'DOT', + EOS: 'EOS', + ETC: 'ETC', + ETH: 'ETH', + ETHW: 'ETHW', + FIL: 'FIL', + FIO: 'FIO', + FIRO: 'FIRO', + FTM: 'FTM', + GRS: 'GRS', + HBAR: 'HBAR', + LTC: 'LTC', + MATIC: 'MATIC', + PIVX: 'PIVX', + POL: 'POL', + PLS: 'PLS', + QTUM: 'QTUM', + RUNE: 'RUNE', + RVN: 'RVN', + SOL: 'SOL', + SONIC: 'SONIC', + SUI: 'SUI', + TLOS: 'TLOS', + TON: 'TON', + TRX: 'TRX', + XEC: 'XEC', + XLM: 'XLM', + XMR: 'XMR', + XRP: 'XRP', + XTZ: 'XTZ', + ZANO: 'ZANO', + ZEC: 'ZEC' +} + +function resolveNetworkCode( + network: string | null, + currencyCode: string, + isoDate: string +): string | null { + if (network != null) return network + if (isoDate < NETWORK_FIELDS_AVAILABLE_DATE) return null + const currencyUpper = currencyCode.toUpperCase() + return LETSEXCHANGE_CURRENCY_TO_DEFAULT_NETWORK[currencyUpper] ?? null +} + // Native token placeholder addresses that should be treated as null (native coin) // All values should be lowercase for case-insensitive matching const NATIVE_TOKEN_ADDRESSES = new Set([ @@ -299,13 +361,16 @@ function getAssetInfo( contractAddress: string | null, isoDate: string ): AssetInfo | undefined { - if (initialNetwork == null) { + const network = resolveNetworkCode(initialNetwork, currencyCode, isoDate) + if (network == null) { + // Transactions predating the network fields cannot be backfilled, so they + // are allowed through unenriched. Anything newer must resolve a network or + // fail closed rather than persist missing chain and token data. 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] From 4d0bc8d518dfe5e10b0af438460b8399fb555580 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Tue, 23 Jun 2026 14:59:14 -0700 Subject: [PATCH 05/10] Add missing partner asset and payment mappings 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 --- src/partners/banxa.ts | 3 +++ src/partners/rango.ts | 1 + src/partners/sideshift.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/src/partners/banxa.ts b/src/partners/banxa.ts index e089b18c..ecc5b8ee 100644 --- a/src/partners/banxa.ts +++ b/src/partners/banxa.ts @@ -607,7 +607,10 @@ function getFiatPaymentType(tx: BanxaTx): FiatPaymentType { case 'WorldPay ApplePay': case 'Primer Apple Pay': return 'applepay' + case 'Primer Paypal Pay': + return 'paypal' case 'WorldPay GooglePay': + case 'Primer Google Pay': return 'googlepay' case 'iDEAL Transfer': return 'ideal' diff --git a/src/partners/rango.ts b/src/partners/rango.ts index 8a122089..ccfdbe6d 100644 --- a/src/partners/rango.ts +++ b/src/partners/rango.ts @@ -120,6 +120,7 @@ const RANGO_BLOCKCHAIN_TO_PLUGIN_ID: Record = { OSMOSIS: 'osmosis', POLYGON: 'polygon', SOLANA: 'solana', + SONIC: 'sonic', SUI: 'sui', TON: 'ton', TRON: 'tron', diff --git a/src/partners/sideshift.ts b/src/partners/sideshift.ts index d05f66eb..c12e5d41 100644 --- a/src/partners/sideshift.ts +++ b/src/partners/sideshift.ts @@ -77,6 +77,7 @@ const DELISTED_COINS: Record = { 'MATIC-polygon': null, // Native gas token (rebranded to POL) 'MKR-ethereum': '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2', 'PYTH-solana': 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', + 'SWARMS-solana': '74SBV4zDXxTRgv1pEMoECskKBkZHc2yGPnc7GYVepump', 'USDC-tron': 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', 'XMR-monero': null, // Native gas token 'ZEC-zcash': null // Native gas token From 70d713af148386dd233e783830b83d94524bab0d Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Mon, 29 Jun 2026 13:44:21 -0700 Subject: [PATCH 06/10] Load full ChangeNow currency list to resolve delisted assets The currency cache was built from the `currencies?active=true` endpoint. When ChangeNow deactivates an asset (e.g. DASH), it disappears from the active list while historical transactions still reference it. The lookup then misses and the plugin halts fail-closed, stalling all ChangeNow transaction collection. Fetch the full currency list (omit `active=true`) so previously-listed assets continue to resolve for historical transactions. Co-authored-by: Cursor --- src/partners/changenow.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/partners/changenow.ts b/src/partners/changenow.ts index f91c9ca6..2d6a110a 100644 --- a/src/partners/changenow.ts +++ b/src/partners/changenow.ts @@ -143,8 +143,12 @@ async function loadCurrencyCache( } try { - // The exchange/currencies endpoint doesn't require authentication - const url = 'https://api.changenow.io/v2/exchange/currencies?active=true' + // The exchange/currencies endpoint doesn't require authentication. + // Fetch the full list (omit `active=true`): historical transactions can + // reference currencies that ChangeNow has since deactivated/delisted (e.g. + // DASH). Filtering to active-only drops those entries, causing a cache miss + // and a fail-closed halt on otherwise-valid historical transactions. + const url = 'https://api.changenow.io/v2/exchange/currencies' const response = await retryFetch(url, { method: 'GET' }) From 19d1556aaeecea8eb35fdfc31265847df72896ad Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Mon, 13 Jul 2026 14:23:41 -0700 Subject: [PATCH 07/10] Add Banxa historical fallback for delisted ZEC Banxa removed ZEC from the v2 crypto catalog, so historical ZEC orders abort the partner query and stall ingestion past July 8. Co-authored-by: Cursor --- src/partners/banxa.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/partners/banxa.ts b/src/partners/banxa.ts index ecc5b8ee..62bad0ae 100644 --- a/src/partners/banxa.ts +++ b/src/partners/banxa.ts @@ -113,7 +113,9 @@ const BANXA_HISTORICAL_COINS: Record = { 'RLUSD-XRP': { contractAddress: 'rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De', pluginId: 'ripple' - } + }, + // ZEC delisted from Banxa v2 catalog + 'ZEC-ZEC': { contractAddress: null, pluginId: 'zcash' } } /** From 78900c78b07811fa8c9b1e4d7a81d2ba976303b7 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Tue, 28 Jul 2026 19:20:27 -0700 Subject: [PATCH 08/10] Disable getTxInfo endpoint Not private enough and is scrapable. Co-authored-by: Cursor --- src/indexApi.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/indexApi.ts b/src/indexApi.ts index 76fe4e9d..af6b06f3 100644 --- a/src/indexApi.ts +++ b/src/indexApi.ts @@ -8,7 +8,7 @@ import { analyticsRouter } from './routes/v1/analytics' import { checkTxsRouter } from './routes/v1/checkTxs' import { getAppIdRouter } from './routes/v1/getAppId' import { getPluginIdsRouter } from './routes/v1/getPluginIds' -import { getTxInfoRouter } from './routes/v1/getTxInfo' +// import { getTxInfoRouter } from './routes/v1/getTxInfo' import { HttpError } from './util/httpErrors' export const nanoDb = nano(config.couchDbFullpath) @@ -28,7 +28,8 @@ async function main(): Promise { app.use('/v1/checkTxs/', checkTxsRouter) app.use('/v1/getAppId/', getAppIdRouter) app.use('/v1/getPluginIds/', getPluginIdsRouter) - app.use('/v1/getTxInfo/', getTxInfoRouter) + // Disabled: not private enough and is scrapable. + // app.use('/v1/getTxInfo/', getTxInfoRouter) // Error router app.use(function(err, _req, res, _next) { From ed329b276f46f77c02291baf79f441b8abdba1ef Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Tue, 28 Jul 2026 19:15:50 -0700 Subject: [PATCH 09/10] Add Banxa Klarna Checkout payment type mapping Unblocks Banxa query progress stuck since Jul 24 on unrecognized KLARNA Checkout payment methods. Co-authored-by: Cursor --- src/partners/banxa.ts | 2 ++ src/types.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/partners/banxa.ts b/src/partners/banxa.ts index 62bad0ae..271b83f9 100644 --- a/src/partners/banxa.ts +++ b/src/partners/banxa.ts @@ -616,6 +616,8 @@ function getFiatPaymentType(tx: BanxaTx): FiatPaymentType { return 'googlepay' case 'iDEAL Transfer': return 'ideal' + case 'KLARNA Checkout': + return 'klarna' case 'ZeroHash ACH Sell': case 'Fortress/Plaid ACH': return 'ach' diff --git a/src/types.ts b/src/types.ts index 9f8d0582..7094a749 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,6 +93,7 @@ const asFiatPaymentType = asValue( 'interac', 'iobank', 'israelibank', + 'klarna', 'mexicobank', 'mobikwik', 'moonpay', From a6407a16ffd23e2699b45a04c4e5e0a3132b9003 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 23:53:32 +0000 Subject: [PATCH 10/10] Add Monad chain id 143 to EVM_CHAIN_IDS Rango maps MONAD to the monad pluginId, but without a numeric chain id depositEvmChainId/payoutEvmChainId stay undefined for Monad EVM txs. --- src/util/chainIds.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/util/chainIds.ts b/src/util/chainIds.ts index eed2c767..f459e6cd 100644 --- a/src/util/chainIds.ts +++ b/src/util/chainIds.ts @@ -13,6 +13,7 @@ export const EVM_CHAIN_IDS: Record = { fantom: 250, filecoinfevm: 314, hyperevm: 999, + monad: 143, optimism: 10, opbnb: 204, polygon: 137,