diff --git a/src/demo/partners.ts b/src/demo/partners.ts index 14e7dc4b..97048697 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' @@ -152,5 +156,9 @@ export default { xanpool: { type: 'fiat', color: '#46228B' + }, + xgram: { + type: 'swap', + color: '#0FA7B1' } } as const 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) { diff --git a/src/partners/banxa.ts b/src/partners/banxa.ts index e089b18c..271b83f9 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' } } /** @@ -607,10 +609,15 @@ 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' + case 'KLARNA Checkout': + return 'klarna' case 'ZeroHash ACH Sell': case 'Fortress/Plaid ACH': return 'ach' diff --git a/src/partners/changenow.ts b/src/partners/changenow.ts index e4eb6814..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' }) @@ -376,24 +380,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/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] diff --git a/src/partners/nexchange.ts b/src/partners/nexchange.ts new file mode 100644 index 00000000..5dd43b21 --- /dev/null +++ b/src/partners/nexchange.ts @@ -0,0 +1,428 @@ +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 + // 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/partners/rango.ts b/src/partners/rango.ts index 62445ee8..ccfdbe6d 100644 --- a/src/partners/rango.ts +++ b/src/partners/rango.ts @@ -115,10 +115,13 @@ const RANGO_BLOCKCHAIN_TO_PLUGIN_ID: Record = { FANTOM: 'fantom', LTC: 'litecoin', MATIC: 'polygon', + MONAD: 'monad', OPTIMISM: 'optimism', OSMOSIS: 'osmosis', POLYGON: 'polygon', SOLANA: 'solana', + SONIC: 'sonic', + SUI: 'sui', TON: 'ton', TRON: 'tron', XRPL: 'ripple', @@ -178,17 +181,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/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 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 5c9ce353..ee34571c 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' @@ -34,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, @@ -74,6 +76,7 @@ const plugins = [ lifi, maya, moonpay, + nexchange, paybis, paytrie, rango, @@ -85,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/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', 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, 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') + }) + }) +}) 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) + }) +})