From 38f6598823974010ee0c030bfd93fc11289cc283 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Fri, 29 May 2026 07:03:45 -0700 Subject: [PATCH] Add Xgram Co-authored-by: Cursor --- src/demo/partners.ts | 4 + src/partners/xgram.ts | 506 ++++++++++++++++++++++++++++++++++++++++++ src/queryEngine.ts | 4 +- test/xgram.test.ts | 126 +++++++++++ 4 files changed, 639 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 14e7dc4b..0270eabc 100644 --- a/src/demo/partners.ts +++ b/src/demo/partners.ts @@ -152,5 +152,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..e09a8f49 --- /dev/null +++ b/src/partners/xgram.ts @@ -0,0 +1,506 @@ +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 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}: ${text}`) + } + + const result = await response.json() + currencyCache = { + ...asXgramCurrencies(result), + ...MISSING_CURRENCIES + } + currencyCacheTimestamp = Date.now() + log(`Cached ${Object.keys(currencyCache).length} Xgram currencies`) + return currencyCache +} + +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}: ${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 8551c54f..1144cbb1 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -34,6 +34,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, @@ -85,7 +86,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) + }) +})