diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b60eb1..0bbba245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: Xgram support + ## 2.42.3 (2026-03-10) - fixed: Map `mayachain` pluginId to MAYA chain correctly and remove non-existent `cacao` pluginId diff --git a/scripts/allSynchronizers.ts b/scripts/allSynchronizers.ts index af376b7e..4d30e944 100644 --- a/scripts/allSynchronizers.ts +++ b/scripts/allSynchronizers.ts @@ -19,6 +19,7 @@ import { makeSideShiftSynchronizer } from './synchronizers/sideshift/sideshiftSy import { makeSwapKitSynchronizer } from './synchronizers/swapkit/swapkitSynchronizer' import { makeSwapuzSynchronizer } from './synchronizers/swapuz/swapuzSynchronizer' import { makeThorchainSynchronizer } from './synchronizers/thorchain/thorchainSynchronizer' +import { makeXgramSynchronizer } from './synchronizers/xgram/xgramSynchronizer' import { SwapSynchronizer } from './types' export const synchronizers: SwapSynchronizer[] = [ @@ -33,5 +34,6 @@ export const synchronizers: SwapSynchronizer[] = [ makeSideShiftSynchronizer(config), makeSwapKitSynchronizer(config), makeSwapuzSynchronizer(config), - makeThorchainSynchronizer(config) + makeThorchainSynchronizer(config), + makeXgramSynchronizer(config) ] diff --git a/scripts/mapctlConfig.ts b/scripts/mapctlConfig.ts index 679b7022..fdf0716b 100644 --- a/scripts/mapctlConfig.ts +++ b/scripts/mapctlConfig.ts @@ -9,7 +9,8 @@ const asMapctlConfig = asObject({ LETSEXCHANGE_API_KEY: asOptional(asString, ''), RANGO_API_KEY: asOptional(asString, ''), SWAPUZ_API_KEY: asOptional(asString, ''), - SWAPKIT_API_KEY: asOptional(asString, '') + SWAPKIT_API_KEY: asOptional(asString, ''), + XGRAM_API_KEY: asOptional(asString, '') }) export type MapctlConfig = ReturnType diff --git a/scripts/mappings/xgramMappings.ts b/scripts/mappings/xgramMappings.ts new file mode 100644 index 00000000..dff10084 --- /dev/null +++ b/scripts/mappings/xgramMappings.ts @@ -0,0 +1,92 @@ +import { EdgeCurrencyPluginId } from '../../src/util/edgeCurrencyPluginIds' + +export const xgram = new Map() +// WARNING: Not included by the synchronizer synchronization +xgram.set('ada', 'cardano') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('ALGO', 'algorand') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('ARBITRUM', 'arbitrum') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('ATOM', 'cosmoshub') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('AVAXC', 'avalanche') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('BITCOINCASH', 'bitcoincash') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('BSC', 'binancesmartchain') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('BSV', 'bitcoinsv') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('BTC', 'bitcoin') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('CELO', 'celo') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('DGB', 'digibyte') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('DOGECOIN', 'dogecoin') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('ETH', 'ethereum') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('ETHEREUM CLASSIC', 'ethereumclassic') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('ETHEREUMPOW', 'ethereumpow') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('FIL', 'filecoin') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('FIO', 'fio') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('HBAR', 'hedera') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('OPTIMISM', 'optimism') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('OSMO', 'osmosis') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('POL', 'polygon') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('QTUM', 'qtum') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('RVN', 'ravencoin') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('SOL', 'solana') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('SUI', 'sui') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('TRX', 'tron') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('WAX', 'wax') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('XMR', 'monero') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('XRP', 'ripple') + +// WARNING: Not included by the synchronizer synchronization +xgram.set('ZEC', 'zcash') diff --git a/scripts/synchronizers/xgram/xgramSynchronizer.ts b/scripts/synchronizers/xgram/xgramSynchronizer.ts new file mode 100644 index 00000000..dfc76c06 --- /dev/null +++ b/scripts/synchronizers/xgram/xgramSynchronizer.ts @@ -0,0 +1,82 @@ +import { asMaybe } from 'cleaners' +import fetch from 'node-fetch' + +import { MapctlConfig } from '../../mapctlConfig' +import { FetchChainCodeResult, SwapSynchronizer } from '../../types' +import { getMappingFilePath, loadMappingFile } from '../../util/loadMappingFile' +import { asXgramCurrency } from './xgramTypes' + +const NAME = 'xgram' +export const makeXgramSynchronizer = ( + config: MapctlConfig +): SwapSynchronizer => { + const apiKey = config.XGRAM_API_KEY + if (apiKey == null || apiKey === '') { + throw new Error('Missing XGRAM_API_KEY in environment variables') + } + + return { + name: NAME, + get map() { + return loadMappingFile(NAME) + }, + mappingFilePath: getMappingFilePath(NAME), + fetchChainCodes: async (): Promise => { + const response = await fetch( + 'https://xgram.io/api/v1/list-currency-options', + { + headers: { + 'x-api-key': apiKey + } + } + ) + + if (!response.ok) { + throw new Error( + `Failed to fetch Xgram currencies: ${response.statusText}` + ) + } + + const data = await response.json() + if (typeof data !== 'object' || data == null) { + throw new Error('Xgram API returned unexpected response format') + } + + // Note: The `network` field from list-currency-options does not map + // cleanly to the v2 quote endpoint's accepted fromNetwork/toNetwork + // values. We still collect it here for visibility in sync output, but + // the checked-in Xgram mappings remain hand-maintained. + const networkMap = new Map() + for (const [, value] of Object.entries(data)) { + const currency = asMaybe(asXgramCurrency)(value) + if (currency == null) continue + if (currency.network === '' || !currency.available) continue + + const existing = networkMap.get(currency.network) + if (existing != null) { + existing.count++ + } else { + networkMap.set(currency.network, { count: 1 }) + } + } + + const results = Array.from(networkMap.entries()).map( + ([network, info]) => ({ + chainCode: network, + metadata: { + 'Display Name': network, + 'Currency Count': String(info.count) + } + }) + ) + + if (results.length === 0) { + throw new Error( + 'Xgram API returned currencies but no valid networks were extracted.' + ) + } + + return results + } + } +} diff --git a/scripts/synchronizers/xgram/xgramTypes.ts b/scripts/synchronizers/xgram/xgramTypes.ts new file mode 100644 index 00000000..6233c834 --- /dev/null +++ b/scripts/synchronizers/xgram/xgramTypes.ts @@ -0,0 +1,11 @@ +import { asBoolean, asNumber, asObject, asString } from 'cleaners' + +export const asXgramCurrency = asObject({ + coinName: asString, + contract: asString, + minFrom: asNumber, + maxFrom: asNumber, + tagname: asString, + network: asString, + available: asBoolean +}) diff --git a/src/index.ts b/src/index.ts index 5ca45e64..fb3c6c7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { makeLetsExchangePlugin } from './swap/central/letsexchange' import { makeNexchangePlugin } from './swap/central/nexchange' import { makeSideshiftPlugin } from './swap/central/sideshift' import { makeSwapuzPlugin } from './swap/central/swapuz' +import { makeXgramPlugin } from './swap/central/xgram' import { make0xGaslessPlugin } from './swap/defi/0x/0xGasless' import { makeBridgelessPlugin } from './swap/defi/bridgeless' import { makeCosmosIbcPlugin } from './swap/defi/cosmosIbc' @@ -50,6 +51,7 @@ const plugins = { transfer: makeTransferPlugin, unizen: makeUnizenPlugin, velodrome: makeVelodromePlugin, + xgram: makeXgramPlugin, xrpdex, fantomsonicupgrade: makeFantomSonicUpgradePlugin, '0xgasless': make0xGaslessPlugin diff --git a/src/mappings/xgram.ts b/src/mappings/xgram.ts new file mode 100644 index 00000000..4191c92b --- /dev/null +++ b/src/mappings/xgram.ts @@ -0,0 +1,101 @@ +/** + * ⚠️ AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY ⚠️ + * + * This file is automatically generated from scripts/mappings/xgramMappings.ts + * To regenerate this file, run: yarn mapctl update-mappings + * + * To edit mappings: + * 1. Edit scripts/mappings/xgramMappings.ts + * 2. Run: yarn mapctl update-mappings + * + * This file maps EdgeCurrencyPluginId -> synchronizer network identifier (or null) + */ + +import { EdgeCurrencyPluginId } from '../util/edgeCurrencyPluginIds' + +export const xgram = new Map() +xgram.set('abstract', null) +xgram.set('algorand', 'ALGO') +xgram.set('amoy', null) +xgram.set('arbitrum', 'ARBITRUM') +xgram.set('avalanche', 'AVAXC') +xgram.set('axelar', null) +xgram.set('badcoin', null) +xgram.set('base', null) +xgram.set('binance', null) +xgram.set('binancesmartchain', 'BSC') +xgram.set('bitcoin', 'BTC') +xgram.set('bitcoincash', 'BITCOINCASH') +xgram.set('bitcoincashtestnet', null) +xgram.set('bitcoingold', null) +xgram.set('bitcoingoldtestnet', null) +xgram.set('bitcoinsv', 'BSV') +xgram.set('bitcointestnet', null) +xgram.set('bitcointestnet4', null) +xgram.set('bobevm', null) +xgram.set('botanix', null) +xgram.set('calibration', null) +xgram.set('cardano', 'ada') +xgram.set('cardanotestnet', null) +xgram.set('celo', 'CELO') +xgram.set('coreum', null) +xgram.set('cosmoshub', 'ATOM') +xgram.set('dash', null) +xgram.set('digibyte', 'DGB') +xgram.set('dogecoin', 'DOGECOIN') +xgram.set('eboost', null) +xgram.set('ecash', null) +xgram.set('eos', null) +xgram.set('ethDev', null) +xgram.set('ethereum', 'ETH') +xgram.set('ethereumclassic', 'ETHEREUM CLASSIC') +xgram.set('ethereumpow', 'ETHEREUMPOW') +xgram.set('fantom', null) +xgram.set('feathercoin', null) +xgram.set('filecoin', 'FIL') +xgram.set('filecoinfevm', null) +xgram.set('filecoinfevmcalibration', null) +xgram.set('fio', 'FIO') +xgram.set('groestlcoin', null) +xgram.set('hedera', 'HBAR') +xgram.set('holesky', null) +xgram.set('hyperevm', null) +xgram.set('liberland', null) +xgram.set('liberlandtestnet', null) +xgram.set('litecoin', null) +xgram.set('mayachain', null) +xgram.set('monad', null) +xgram.set('monero', 'XMR') +xgram.set('nym', null) +xgram.set('opbnb', null) +xgram.set('optimism', 'OPTIMISM') +xgram.set('osmosis', 'OSMO') +xgram.set('piratechain', null) +xgram.set('pivx', null) +xgram.set('polkadot', null) +xgram.set('polygon', 'POL') +xgram.set('pulsechain', null) +xgram.set('qtum', 'QTUM') +xgram.set('ravencoin', 'RVN') +xgram.set('ripple', 'XRP') +xgram.set('rsk', null) +xgram.set('sepolia', null) +xgram.set('smartcash', null) +xgram.set('solana', 'SOL') +xgram.set('sonic', null) +xgram.set('stellar', null) +xgram.set('sui', 'SUI') +xgram.set('suitestnet', null) +xgram.set('telos', null) +xgram.set('tezos', null) +xgram.set('thorchainrune', null) +xgram.set('thorchainrunestagenet', null) +xgram.set('ton', null) +xgram.set('tron', 'TRX') +xgram.set('ufo', null) +xgram.set('vertcoin', null) +xgram.set('wax', 'WAX') +xgram.set('zano', null) +xgram.set('zcash', 'ZEC') +xgram.set('zcoin', null) +xgram.set('zksync', null) diff --git a/src/swap/central/xgram.ts b/src/swap/central/xgram.ts new file mode 100644 index 00000000..56331e29 --- /dev/null +++ b/src/swap/central/xgram.ts @@ -0,0 +1,393 @@ +import { + asArray, + asDate, + asEither, + asMaybe, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' +import { + EdgeCorePluginOptions, + EdgeMemo, + EdgeSpendInfo, + EdgeSwapInfo, + EdgeSwapPlugin, + EdgeSwapQuote, + EdgeSwapRequest, + SwapAboveLimitError, + SwapBelowLimitError, + SwapCurrencyError, + SwapPermissionError +} from 'edge-core-js/types' + +import { xgram as xgramMapping } from '../../mappings/xgram' +import { EdgeCurrencyPluginId } from '../../util/edgeCurrencyPluginIds' +import { + checkWhitelistedMainnetCodes, + CurrencyPluginIdSwapChainCodeMap, + ensureInFuture, + getContractAddresses, + getMaxSwappable, + makeSwapPluginQuote, + mapToRecord, + SwapOrder +} from '../../util/swapHelpers' +import { + convertRequest, + denominationToNative, + getAddress, + memoType, + nativeToDenomination +} from '../../util/utils' +import { asNumberString, EdgeSwapRequestPlugin, StringMap } from '../types' +import { asOptionalBlank } from './changenow' + +const pluginId = 'xgram' + +export const swapInfo: EdgeSwapInfo = { + pluginId, + isDex: false, + displayName: 'Xgram', + supportEmail: 'support@xgram.io' +} + +const asInitOptions = asObject({ + apiKey: asString +}) + +const orderUri = 'https://xgram.io/exchange/order?id=' +const uri = 'https://xgram.io/api/v2/' +const newExchange = 'launch-new-exchange-edge' +const newRevExchange = 'launch-new-payment-exchange-edge' + +export const MAINNET_CODE_TRANSCRIPTION: CurrencyPluginIdSwapChainCodeMap = mapToRecord( + xgramMapping +) + +const addressTypeMap: StringMap = { + zcash: 'transparentAddress' +} + +const swapType = 'fixed' as const +const ccyAmountLimitRegex = /ccyAmount must be ([><])\s*([\d.]+)/ + +export function makeXgramPlugin(opts: EdgeCorePluginOptions): EdgeSwapPlugin { + const { io } = opts + + const fetchCors = io.fetch + const { apiKey } = asInitOptions(opts.initOptions) + + const headers = { + 'Content-Type': 'application/json', + 'x-api-key': apiKey + } + + const fetchSwapQuoteInner = async ( + request: EdgeSwapRequestPlugin, + opts: { promoCode?: string } + ): Promise => { + const { fromWallet, toWallet, nativeAmount } = request + + const [fromAddress, toAddress] = await Promise.all([ + getAddress( + request.fromWallet, + addressTypeMap[request.fromWallet.currencyInfo.pluginId] + ), + getAddress( + request.toWallet, + addressTypeMap[request.toWallet.currencyInfo.pluginId] + ) + ]) + + const { fromContractAddress, toContractAddress } = getContractAddresses( + request + ) + + const fromNetwork = + MAINNET_CODE_TRANSCRIPTION[ + fromWallet.currencyInfo.pluginId as EdgeCurrencyPluginId + ] ?? '' + const toNetwork = + MAINNET_CODE_TRANSCRIPTION[ + toWallet.currencyInfo.pluginId as EdgeCurrencyPluginId + ] ?? '' + + if (fromNetwork === '' || toNetwork === '') { + throw new SwapCurrencyError(swapInfo, request) + } + + async function createOrder( + isSelling: boolean, + largeDenomAmount: string + ): Promise { + const createExchangeUrl = isSelling ? newExchange : newRevExchange + + const qs = new URLSearchParams({ + toAddress: String(toAddress), + refundAddress: String(fromAddress), + ccyAmount: largeDenomAmount, + type: swapType, + fromContractAddress: fromContractAddress ?? '', + toContractAddress: toContractAddress ?? '', + fromNetwork, + toNetwork + }).toString() + + const orderResponse = await fetchCors( + uri + createExchangeUrl + `?${qs}`, + { + headers + } + ) + + if (!orderResponse.ok) { + throw new Error('Xgram create order failed') + } + + const orderResponseJson = await orderResponse.json() + const quoteFor = request.quoteFor === 'from' ? 'from' : 'to' + const quoteReply = asXgramQuoteReply(orderResponseJson) + + if ('errors' in quoteReply) { + const errors = quoteReply.errors + + if (errors.find(error => error.code === 'REGION_UNSUPPORTED') != null) { + throw new SwapPermissionError(swapInfo, 'geoRestriction') + } + + if ( + errors.find(error => error.code === 'CURRENCY_UNSUPPORTED') != null + ) { + throw new SwapCurrencyError(swapInfo, request) + } + const limitError = errors + .map(e => asMaybe(asXgramLimitError)(e)) + .find(e => e != null) + + if (limitError?.code === 'BELOW_LIMIT') { + const nativeLimit = denominationToNative( + isSelling ? request.fromWallet : request.toWallet, + isSelling + ? limitError.sourceAmountLimit + : limitError.destinationAmountLimit, + isSelling ? request.fromTokenId : request.toTokenId + ) + + throw new SwapBelowLimitError(swapInfo, nativeLimit, quoteFor) + } + if (limitError?.code === 'ABOVE_LIMIT') { + const nativeLimit = denominationToNative( + isSelling ? request.fromWallet : request.toWallet, + isSelling + ? limitError.sourceAmountLimit + : limitError.destinationAmountLimit, + isSelling ? request.fromTokenId : request.toTokenId + ) + throw new SwapAboveLimitError(swapInfo, nativeLimit, quoteFor) + } + throw new Error('Xgram create order error') + } + if ('error' in quoteReply) { + const match = ccyAmountLimitRegex.exec(quoteReply.error) + if (match != null) { + const [, direction, limitStr] = match + const nativeLimit = denominationToNative( + isSelling ? request.fromWallet : request.toWallet, + limitStr, + isSelling ? request.fromTokenId : request.toTokenId + ) + if (direction === '>') { + throw new SwapBelowLimitError(swapInfo, nativeLimit, quoteFor) + } + throw new SwapAboveLimitError(swapInfo, nativeLimit, quoteFor) + } + + throw new Error(`Xgram: ${quoteReply.error}`) + } + + if (quoteReply.ccyAmountToExpected == null && isSelling) { + throw new Error('Xgram quote missing ccyAmountToExpected') + } + + return { + id: quoteReply.id, + validUntil: quoteReply.expiresAt, + fromAmount: quoteReply.ccyAmountFrom, + toAmount: + quoteReply.ccyAmountToExpected != null + ? quoteReply.ccyAmountToExpected.toString() + : largeDenomAmount, + payinAddress: quoteReply.depositAddress, + payinExtraId: quoteReply.depositTag + } + } + + async function swapExchange(isSelling: boolean): Promise { + const largeDenomAmount = nativeToDenomination( + isSelling ? request.fromWallet : request.toWallet, + nativeAmount, + isSelling ? request.fromTokenId : request.toTokenId + ) + + const { + fromAmount, + toAmount, + payinAddress, + payinExtraId, + id, + validUntil + } = await createOrder(isSelling, largeDenomAmount) + + const fromNativeAmount = denominationToNative( + request.fromWallet, + fromAmount.toString(), + request.fromTokenId + ) + const toNativeAmount = denominationToNative( + request.toWallet, + toAmount.toString(), + request.toTokenId + ) + + const memos: EdgeMemo[] = + payinExtraId == null || payinExtraId === '' + ? [] + : [ + { + type: memoType(request.fromWallet.currencyInfo.pluginId), + value: payinExtraId + } + ] + + const spendInfo: EdgeSpendInfo = { + tokenId: request.fromTokenId, + spendTargets: [ + { + nativeAmount: fromNativeAmount, + publicAddress: payinAddress + } + ], + memos, + networkFeeOption: 'high', + assetAction: { + assetActionType: 'swap' + }, + savedAction: { + actionType: 'swap', + swapInfo, + orderId: id, + orderUri: orderUri + id, + isEstimate: false, + toAsset: { + pluginId: request.toWallet.currencyInfo.pluginId, + tokenId: request.toTokenId, + nativeAmount: toNativeAmount + }, + fromAsset: { + pluginId: request.fromWallet.currencyInfo.pluginId, + tokenId: request.fromTokenId, + nativeAmount: fromNativeAmount + }, + payoutAddress: toAddress, + payoutWalletId: request.toWallet.id, + refundAddress: fromAddress + } + } + + return { + request, + spendInfo, + swapInfo, + fromNativeAmount, + expirationDate: + validUntil != null + ? ensureInFuture(validUntil) + : new Date(Date.now() + 1000 * 60) + } + } + + const { quoteFor } = request + + if (quoteFor === 'from') { + return await swapExchange(true) + } else { + return await swapExchange(false) + } + } + + const out: EdgeSwapPlugin = { + swapInfo, + + async fetchSwapQuote( + req: EdgeSwapRequest, + userSettings: Object | undefined, + opts: { promoCode?: string } + ): Promise { + const request = convertRequest(req) + + checkWhitelistedMainnetCodes( + MAINNET_CODE_TRANSCRIPTION, + request, + swapInfo + ) + const newRequest = await getMaxSwappable( + fetchSwapQuoteInner, + request, + opts + ) + const swapOrder = await fetchSwapQuoteInner(newRequest, opts) + return await makeSwapPluginQuote(swapOrder) + } + } + return out +} + +interface XgramResponse { + id: string + fromAmount: string + toAmount: string + payinExtraId?: string + payinAddress: string + validUntil?: Date | null +} + +const asXgramLimitError = asObject({ + code: asValue('BELOW_LIMIT', 'ABOVE_LIMIT'), + destinationAmountLimit: asString, + error: asString, + sourceAmountLimit: asString +}) + +const asXgramRegionError = asObject({ + code: asValue('REGION_UNSUPPORTED'), + message: asString +}) + +const asXgramCurrencyError = asObject({ + code: asValue('CURRENCY_UNSUPPORTED'), + error: asString +}) +const asXgramError = asObject({ + errors: asArray( + asEither(asXgramLimitError, asXgramRegionError, asXgramCurrencyError) + ) +}) +const asXgramStringError = asObject({ + error: asString +}) +const asXgramQuote = asObject({ + ccyAmountToExpected: asOptional(asNumberString), + depositAddress: asString, + depositTag: asOptionalBlank(asString), + id: asString, + result: asValue(true), + expiresAt: asOptional(asDate), + ccyAmountFrom: asNumberString +}) +const asXgramQuoteReply = asEither( + asXgramQuote, + asXgramError, + asXgramStringError +)