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/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 new file mode 100644 index 00000000..bf27fcb0 --- /dev/null +++ b/src/partners/nexchange.ts @@ -0,0 +1,423 @@ +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 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, + 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, 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) { + throw new Error( + `Unknown tokenType for chainPluginId "${chainPluginId}" (currency: ${normalizedCode}, contract: ${contractAddress}). Add tokenType to tokenTypes.` + ) + } + + 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/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/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..b287b0c2 --- /dev/null +++ b/test/nexchange.test.ts @@ -0,0 +1,294 @@ +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' + }, + // 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' + } +} + +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/ + ) + }) + + 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', () => { + 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') + }) + }) +})