From 11668160b4147bcffdc62bc9fefb0f3f90e437a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Moruj=C3=A3o?= Date: Tue, 6 Jan 2026 17:21:04 +0000 Subject: [PATCH] Add n.exchange centralized swap integration Integrate n.exchange as a centralized swap provider supporting multiple networks and tokens. The implementation uses contract addresses for token identification, eliminating the need for currency code mapping. This approach provides better support for tokens and aligns with API requirements. The integration includes support for native currencies and ERC20 tokens across major networks including Ethereum, Polygon, Base, Arbitrum, Optimism, BSC, and others. The plugin handles rate queries, order creation, and payment processing through the n.exchange API v2. Additional changes include mapctl tooling for automatic provider mappings and updates to existing swap plugins to use the new mapping system. --- CHANGELOG.md | 2 + src/index.ts | 2 + src/swap/central/nexchange.ts | 553 +++++++++++++++++++++++++++ test/partnerJson/nexchangeMap.json | 31 ++ test/partnerJson/partnerJson.test.ts | 44 +++ test/testconfig.ts | 6 + 6 files changed, 638 insertions(+) create mode 100644 src/swap/central/nexchange.ts create mode 100644 test/partnerJson/nexchangeMap.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bccb065f..4cadc50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: n.exchange centralized swap integration with support for multiple networks and tokens using contract address-based identification + ## 2.40.3 (2025-12-29) - fixed: Fix Rango EVM approval address diff --git a/src/index.ts b/src/index.ts index f0c1961b..33f65721 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { makeChangeNowPlugin } from './swap/central/changenow' import { makeExolixPlugin } from './swap/central/exolix' import { makeGodexPlugin } from './swap/central/godex' 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 { make0xGaslessPlugin } from './swap/defi/0x/0xGasless' @@ -35,6 +36,7 @@ const plugins = { godex: makeGodexPlugin, letsexchange: makeLetsExchangePlugin, lifi: makeLifiPlugin, + nexchange: makeNexchangePlugin, rango: makeRangoPlugin, sideshift: makeSideshiftPlugin, spookySwap: makeSpookySwapPlugin, diff --git a/src/swap/central/nexchange.ts b/src/swap/central/nexchange.ts new file mode 100644 index 00000000..c9dcc4b0 --- /dev/null +++ b/src/swap/central/nexchange.ts @@ -0,0 +1,553 @@ +import { gt, lt } from 'biggystring' +import { + asArray, + asDate, + asEither, + asNumber, + asObject, + asOptional, + asString +} from 'cleaners' +import { + EdgeCorePluginOptions, + EdgeCurrencyWallet, + EdgeMemo, + EdgeSpendInfo, + EdgeSwapInfo, + EdgeSwapPlugin, + EdgeSwapQuote, + EdgeSwapRequest, + SwapAboveLimitError, + SwapBelowLimitError, + SwapCurrencyError +} from 'edge-core-js/types' + +import { + checkInvalidTokenIds, + CurrencyPluginIdSwapChainCodeMap, + ensureInFuture, + getMaxSwappable, + InvalidTokenIds, + makeSwapPluginQuote, + SwapOrder +} from '../../util/swapHelpers' +import { + convertRequest, + denominationToNative, + getAddress, + memoType, + nativeToDenomination +} from '../../util/utils' +import { EdgeSwapRequestPlugin, StringMap } from '../types' + +const pluginId = 'nexchange' + +export const swapInfo: EdgeSwapInfo = { + pluginId, + isDex: false, + displayName: 'n.exchange', + supportEmail: 'support@n.exchange' +} + +const asInitOptions = asObject({ + apiKey: asString, + referralCode: asString +}) + +const orderUri = 'https://n.exchange/order/' +const uri = 'https://api.n.exchange/en/api/v2' + +const INVALID_TOKEN_IDS: InvalidTokenIds = { + from: {}, + to: {} +} + +const addressTypeMap: StringMap = { + zcash: 'transparentAddress' +} + +// See https://api.n.exchange/en/api/v2/currency/ for list of supported currencies +// Network codes map to Nexchange network identifiers +// Based on supported networks: ALGO, ATOM, SOL, BCH, BTC, DASH, DOGE, DOT, EOS, TON, HBAR, LTC, XLM, XRP, XTZ, ZEC, TRON, ADA, BASE, MATIC/POL, ETH, AVAXC, BSC, ETC, ARB, OP, FTM, SONIC +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +export const MAINNET_CODE_TRANSCRIPTION = { + algorand: 'ALGO', + arbitrum: 'ARB', + avalanche: 'AVAXC', + axelar: null, + base: 'BASE', + binance: null, + binancesmartchain: 'BSC', + bitcoin: 'BTC', + bitcoincash: 'BCH', + bitcoingold: null, + bitcoinsv: null, + bobevm: null, + cardano: 'ADA', + celo: null, + coreum: null, + cosmoshub: 'ATOM', + dash: 'DASH', + digibyte: null, + dogecoin: 'DOGE', + eboost: null, + ecash: null, + eos: 'EOS', + ethereum: 'ETH', + ethereumclassic: 'ETC', + ethereumpow: null, + fantom: 'FTM', + feathercoin: null, + filecoin: null, + filecoinfevm: null, + fio: null, + groestlcoin: null, + hedera: 'HBAR', + hyperevm: null, + liberland: null, + litecoin: 'LTC', + monero: null, + optimism: 'OP', + osmosis: null, + piratechain: null, + pivx: null, + polkadot: 'DOT', + polygon: 'POL', // Nexchange uses POL for Polygon + pulsechain: null, + qtum: null, + ravencoin: null, + ripple: 'XRP', + rsk: null, + smartcash: null, + solana: 'SOL', + sonic: 'SONIC', + stellar: 'XLM', + sui: null, + telos: null, + tezos: 'XTZ', + thorchainrune: null, + ton: 'TON', + tron: 'TRON', + ufo: null, + vertcoin: null, + wax: null, + zano: null, + zcash: 'ZEC', + zcoin: null, + zksync: null +} as CurrencyPluginIdSwapChainCodeMap + +// Helper function to get contract address from tokenId +function getContractAddress( + wallet: EdgeCurrencyWallet, + tokenId: string | null +): string | null { + if (tokenId == null) return null + const token = wallet.currencyConfig.allTokens[tokenId] + if (token == null) return null + return token.networkLocation?.contractAddress ?? null +} + +// Helper function to format currency for Nexchange API +// Supports contract address format (recommended for tokens) or code format (backward compatible) +function formatCurrency( + networkCode: string | null, + contractAddress: string | null +): + | string + | { code: string; network: string } + | { contract_address: string; network: string } { + // For native currencies (no contract address), return just the code + if (contractAddress == null || contractAddress === '') { + if (networkCode == null) { + throw new Error( + 'Network code required when contract address is not provided' + ) + } + return networkCode + } + + // For tokens, use contract address format (recommended) + if (networkCode == null) { + throw new Error('Network code required when contract address is provided') + } + + return { + contract_address: contractAddress, + network: networkCode + } +} + +const asRateV2 = asObject({ + pair: asString, + from: asString, + to: asString, + withdrawal_fee: asString, + rate: asString, + rate_id: asString, + max_withdraw_amount: asString, + min_withdraw_amount: asString, + max_deposit_amount: asString, + min_deposit_amount: asString, + expiration_time_unix: asString +}) + +const asOrderV2 = asObject({ + unique_reference: asString, + side: asString, + withdraw_amount: asString, + deposit_amount: asString, + deposit_currency: asEither( + asString, + asObject({ code: asString, network: asString }), + asObject({ contract_address: asString, network: asString }) + ), + withdraw_currency: asEither( + asString, + asObject({ code: asString, network: asString }), + asObject({ contract_address: asString, network: asString }) + ), + status: asString, + created_on: asDate, + payment_window_minutes: asNumber, + fixed_rate_deadline: asOptional(asDate), + rate: asOptional(asString), + deposit_address: asString, + deposit_address_extra_id: asOptional(asString), + withdraw_address: asOptional(asString), + withdraw_address_extra_id: asOptional(asString), + refund_address: asOptional(asString), + refund_address_extra_id: asOptional(asString), + deposit_transaction: asOptional(asString), + withdraw_transaction: asOptional(asString) +}) + +export function makeNexchangePlugin( + opts: EdgeCorePluginOptions +): EdgeSwapPlugin { + const { io, log } = opts + const { fetchCors = io.fetch } = io + const { apiKey, referralCode } = asInitOptions(opts.initOptions) + + const headers: { [key: string]: string } = { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `ApiKey ${apiKey}`, + 'x-referral-token': referralCode + } + + async function call( + url: string, + options: { + method?: string + body?: string + headers?: { [key: string]: string } + } = {}, + request?: EdgeSwapRequestPlugin + ): Promise { + const response = await fetchCors(url, { + ...options, + headers: { + ...headers, + ...(options.headers ?? {}) + } + }) + + if (!response.ok) { + const text = await response.text() + log.warn('Nexchange response:', text) + if ( + (response.status === 400 || response.status === 404) && + request != null + ) { + throw new SwapCurrencyError(swapInfo, request) + } + const errorMessage = `Nexchange returned error code ${response.status}: ${text}` + throw new Error(errorMessage) + } + + return await response.json() + } + + async function getFixedQuote( + request: EdgeSwapRequestPlugin, + _userSettings: Object | undefined + ): Promise { + const [fromAddress, toAddress] = await Promise.all([ + getAddress( + request.fromWallet, + addressTypeMap[request.fromWallet.currencyInfo.pluginId] + ), + getAddress( + request.toWallet, + addressTypeMap[request.toWallet.currencyInfo.pluginId] + ) + ]) + + // Get network codes from plugin IDs (no currency codes needed - using contract addresses only) + const fromMainnetCode = + MAINNET_CODE_TRANSCRIPTION[ + request.fromWallet.currencyInfo + .pluginId as keyof typeof MAINNET_CODE_TRANSCRIPTION + ] + const toMainnetCode = + MAINNET_CODE_TRANSCRIPTION[ + request.toWallet.currencyInfo + .pluginId as keyof typeof MAINNET_CODE_TRANSCRIPTION + ] + + if (fromMainnetCode == null || toMainnetCode == null) { + throw new SwapCurrencyError(swapInfo, request) + } + + // Get contract addresses from tokenIds + const fromContractAddress = getContractAddress( + request.fromWallet, + request.fromTokenId + ) + const toContractAddress = getContractAddress( + request.toWallet, + request.toTokenId + ) + + const quoteAmount = + request.quoteFor === 'from' + ? nativeToDenomination( + request.fromWallet, + request.nativeAmount, + request.fromTokenId + ) + : nativeToDenomination( + request.toWallet, + request.nativeAmount, + request.toTokenId + ) + + // Build rate query using contract addresses only - API MUST support contract addresses + // This works for both tokens and native currencies + const params = new URLSearchParams() + // For native currencies, use empty string for contract_address + params.append('fromContractAddress', fromContractAddress ?? '') + params.append('fromNetwork', fromMainnetCode) + params.append('toContractAddress', toContractAddress ?? '') + params.append('toNetwork', toMainnetCode) + + // Use contract address format only - API MUST support contract addresses + const rateResponse = await call( + `${uri}/rate/?${params.toString()}`, + {}, + request + ) + const rates = asArray(asRateV2)(rateResponse) + const rate = rates[0] // Contract address query returns single rate + + if (rate == null) { + throw new SwapCurrencyError(swapInfo, request) + } + + // Check if rate is expired + const expirationTime = parseInt(rate.expiration_time_unix, 10) * 1000 + if (Date.now() >= expirationTime) { + throw new SwapCurrencyError(swapInfo, request) + } + + // Check min/max limits based on quote direction + if (request.quoteFor === 'from') { + // We're quoting based on deposit amount (what we send) + const maxFromNative = denominationToNative( + request.fromWallet, + rate.max_deposit_amount, + request.fromTokenId + ) + const minFromNative = denominationToNative( + request.fromWallet, + rate.min_deposit_amount, + request.fromTokenId + ) + + if (gt(quoteAmount, rate.max_deposit_amount)) { + throw new SwapAboveLimitError(swapInfo, maxFromNative) + } + if (lt(quoteAmount, rate.min_deposit_amount)) { + throw new SwapBelowLimitError(swapInfo, minFromNative) + } + } else { + // We're quoting based on withdraw amount (what we receive) + const maxToNative = denominationToNative( + request.toWallet, + rate.max_withdraw_amount, + request.toTokenId + ) + const minToNative = denominationToNative( + request.toWallet, + rate.min_withdraw_amount, + request.toTokenId + ) + + if (gt(quoteAmount, rate.max_withdraw_amount)) { + throw new SwapAboveLimitError(swapInfo, maxToNative, 'to') + } + if (lt(quoteAmount, rate.min_withdraw_amount)) { + throw new SwapBelowLimitError(swapInfo, minToNative, 'to') + } + } + + // Create order + // BUY is the default side (buying withdraw_currency with deposit_currency) + // For Edge: fromCurrency -> toCurrency means sending fromCurrency, receiving toCurrency + // For Nexchange: deposit_currency = what we send, withdraw_currency = what we receive + // + // Use contract addresses from tokenIds (required by API requirements) + // Format: { contract_address: "0x...", network: "ETH" } for tokens + // Format: "BTC" (string) for native currencies + + // Map Edge currencies to Nexchange currencies using contract addresses + // deposit_currency = what Edge sends = Edge fromCurrency + // withdraw_currency = what Edge receives = Edge toCurrency + const depositCurrency = formatCurrency(fromMainnetCode, fromContractAddress) + const withdrawCurrency = formatCurrency(toMainnetCode, toContractAddress) + + const orderBody: { + deposit_currency: + | string + | { code: string; network: string } + | { contract_address: string; network: string } + withdraw_currency: + | string + | { code: string; network: string } + | { contract_address: string; network: string } + withdraw_address: string + refund_address: string + rate_id: string + deposit_amount?: string + withdraw_amount?: string + } = { + deposit_currency: depositCurrency, + withdraw_currency: withdrawCurrency, + withdraw_address: toAddress, + refund_address: fromAddress, + rate_id: rate.rate_id + } + + // Set amount based on quote direction + if (request.quoteFor === 'from') { + // We know the deposit amount (what we're sending) + orderBody.deposit_amount = quoteAmount + } else { + // We know the withdraw amount (what we want to receive) + orderBody.withdraw_amount = quoteAmount + } + + const orderResponse = await call( + `${uri}/orders/`, + { + method: 'POST', + body: JSON.stringify(orderBody) + }, + request + ) + + const order = asOrderV2(orderResponse) + + // Calculate amounts + const amountExpectedFromNative = denominationToNative( + request.fromWallet, + order.deposit_amount, + request.fromTokenId + ) + const amountExpectedToNative = denominationToNative( + request.toWallet, + order.withdraw_amount, + request.toTokenId + ) + + const memos: EdgeMemo[] = + order.deposit_address_extra_id == null || + order.deposit_address_extra_id === '' + ? [] + : [ + { + type: memoType(request.fromWallet.currencyInfo.pluginId), + value: order.deposit_address_extra_id + } + ] + + // Calculate expiration date from fixed_rate_deadline or use default + // Use ensureInFuture to guarantee quotes have at least 30 seconds before expiring + // Use order creation time instead of now() to account for any processing delay + const orderCreatedTime = order.created_on.getTime() + const defaultExpiration = new Date( + orderCreatedTime + order.payment_window_minutes * 60 * 1000 + ) + const expirationDate: Date = + order.fixed_rate_deadline != null + ? ensureInFuture(order.fixed_rate_deadline) ?? defaultExpiration + : ensureInFuture(defaultExpiration) ?? defaultExpiration + + const spendInfo: EdgeSpendInfo = { + tokenId: request.fromTokenId, + spendTargets: [ + { + nativeAmount: amountExpectedFromNative, + publicAddress: order.deposit_address + } + ], + memos, + networkFeeOption: 'high', + assetAction: { + assetActionType: 'swap' + }, + savedAction: { + actionType: 'swap', + swapInfo, + orderUri: orderUri + order.unique_reference, + orderId: order.unique_reference, + isEstimate: false, + toAsset: { + pluginId: request.toWallet.currencyInfo.pluginId, + tokenId: request.toTokenId, + nativeAmount: amountExpectedToNative + }, + fromAsset: { + pluginId: request.fromWallet.currencyInfo.pluginId, + tokenId: request.fromTokenId, + nativeAmount: amountExpectedFromNative + }, + payoutAddress: toAddress, + payoutWalletId: request.toWallet.id, + refundAddress: fromAddress + } + } + + return { + request, + spendInfo, + swapInfo, + fromNativeAmount: amountExpectedFromNative, + expirationDate + } + } + + const out: EdgeSwapPlugin = { + swapInfo, + async fetchSwapQuote( + req: EdgeSwapRequest, + userSettings: Object | undefined, + opts: { promoCode?: string } + ): Promise { + const request = convertRequest(req) + + checkInvalidTokenIds(INVALID_TOKEN_IDS, request, swapInfo) + + const newRequest = await getMaxSwappable( + getFixedQuote, + request, + userSettings + ) + const swapOrder = await getFixedQuote(newRequest, userSettings) + return await makeSwapPluginQuote(swapOrder) + } + } + + return out +} diff --git a/test/partnerJson/nexchangeMap.json b/test/partnerJson/nexchangeMap.json new file mode 100644 index 00000000..1da4e668 --- /dev/null +++ b/test/partnerJson/nexchangeMap.json @@ -0,0 +1,31 @@ +[ + ["BTC", [{"contractAddress": null, "tokenCode": "BTC"}]], + ["ETH", [{"contractAddress": null, "tokenCode": "ETH"}, {"contractAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", "tokenCode": "USDT"}, {"contractAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "tokenCode": "USDC"}]], + ["AVAXC", [{"contractAddress": null, "tokenCode": "AVAX"}, {"contractAddress": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", "tokenCode": "USDT"}]], + ["BSC", [{"contractAddress": null, "tokenCode": "BNB"}]], + ["POL", [{"contractAddress": null, "tokenCode": "POL"}]], + ["ARB", [{"contractAddress": null, "tokenCode": "ARB"}]], + ["OP", [{"contractAddress": null, "tokenCode": "OP"}]], + ["FTM", [{"contractAddress": null, "tokenCode": "FTM"}]], + ["SOL", [{"contractAddress": null, "tokenCode": "SOL"}]], + ["TRON", [{"contractAddress": null, "tokenCode": "TRX"}]], + ["ALGO", [{"contractAddress": null, "tokenCode": "ALGO"}]], + ["ATOM", [{"contractAddress": null, "tokenCode": "ATOM"}]], + ["BCH", [{"contractAddress": null, "tokenCode": "BCH"}]], + ["DASH", [{"contractAddress": null, "tokenCode": "DASH"}]], + ["DOGE", [{"contractAddress": null, "tokenCode": "DOGE"}]], + ["DOT", [{"contractAddress": null, "tokenCode": "DOT"}]], + ["EOS", [{"contractAddress": null, "tokenCode": "EOS"}]], + ["TON", [{"contractAddress": null, "tokenCode": "TON"}]], + ["HBAR", [{"contractAddress": null, "tokenCode": "HBAR"}]], + ["LTC", [{"contractAddress": null, "tokenCode": "LTC"}]], + ["XLM", [{"contractAddress": null, "tokenCode": "XLM"}]], + ["XRP", [{"contractAddress": null, "tokenCode": "XRP"}]], + ["XTZ", [{"contractAddress": null, "tokenCode": "XTZ"}]], + ["ZEC", [{"contractAddress": null, "tokenCode": "ZEC"}]], + ["ADA", [{"contractAddress": null, "tokenCode": "ADA"}]], + ["BASE", [{"contractAddress": null, "tokenCode": "ETH"}]], + ["ETC", [{"contractAddress": null, "tokenCode": "ETC"}]], + ["SONIC", [{"contractAddress": null, "tokenCode": "SONIC"}]] +] + diff --git a/test/partnerJson/partnerJson.test.ts b/test/partnerJson/partnerJson.test.ts index cc5d79d2..37c67f0a 100644 --- a/test/partnerJson/partnerJson.test.ts +++ b/test/partnerJson/partnerJson.test.ts @@ -24,6 +24,10 @@ import { SPECIAL_MAINNET_CASES as letsexchangeMainnetSpecialCases, swapInfo as letsexchangeSwapInfo } from '../../src/swap/central/letsexchange' +import { + MAINNET_CODE_TRANSCRIPTION as nexchangeMainnetTranscription, + swapInfo as nexchangeSwapInfo +} from '../../src/swap/central/nexchange' import { MAINNET_CODE_TRANSCRIPTION as sideshiftMainnetTranscription, swapInfo as sideshiftSwapInfo @@ -35,6 +39,7 @@ import { import changeheroChainCodeTickerJson from './changeheroMap.json' import changenowChainCodeTickerJson from './changenowMap.json' import letsexchangeChainCodeTickerJson from './letsexchangeMap.json' +import nexchangeChainCodeTickerJson from './nexchangeMap.json' import sideshiftChainCodeTickerJson from './sideshiftMap.json' const btcWallet = { @@ -137,6 +142,18 @@ const letsexchange = async (request: EdgeSwapRequest): Promise => { letsexchangeMainnetSpecialCases ) } +const nexchange = async (request: EdgeSwapRequest): Promise => { + const nexchangeChainCodeTickerMap = getChainCodeTickerMap( + nexchangeChainCodeTickerJson + ) + + return await getChainAndTokenCodes( + request, + nexchangeSwapInfo, + nexchangeChainCodeTickerMap, + nexchangeMainnetTranscription + ) +} const sideshift = async (request: EdgeSwapRequest): Promise => { const sideshiftChainCodeTickerMap = getChainCodeTickerMap( sideshiftChainCodeTickerJson @@ -189,6 +206,15 @@ describe(`swap btc to eth`, function () { toCurrencyCode: 'ETH' }) }) + it('nexchange', async function () { + const result = await nexchange(request) + return assert.deepEqual(result, { + fromMainnetCode: 'BTC', + fromCurrencyCode: 'BTC', + toMainnetCode: 'ETH', + toCurrencyCode: 'ETH' + }) + }) it('sideshift', async function () { const result = await sideshift(request) return assert.deepEqual(result, { @@ -239,6 +265,15 @@ describe(`swap btc to avax`, function () { toCurrencyCode: 'AVAX' }) }) + it('nexchange', async function () { + const result = await nexchange(request) + return assert.deepEqual(result, { + fromMainnetCode: 'BTC', + fromCurrencyCode: 'BTC', + toMainnetCode: 'AVAXC', + toCurrencyCode: 'AVAX' + }) + }) it('sideshift', async function () { const result = await sideshift(request) return assert.deepEqual(result, { @@ -289,6 +324,15 @@ describe(`swap btc to usdt (avax c-chain)`, function () { toCurrencyCode: 'USDT' }) }) + it('nexchange', async function () { + const result = await nexchange(request) + return assert.deepEqual(result, { + fromMainnetCode: 'BTC', + fromCurrencyCode: 'BTC', + toMainnetCode: 'AVAXC', + toCurrencyCode: 'USDT' + }) + }) it('sideshift', async function () { const result = await sideshift(request) return assert.deepEqual(result, { diff --git a/test/testconfig.ts b/test/testconfig.ts index 1c9c77ab..beca2e82 100644 --- a/test/testconfig.ts +++ b/test/testconfig.ts @@ -78,6 +78,12 @@ export const asTestConfig = asObject({ apiKey: asOptional(asString, '') }).withRest ), + NEXCHANGE_INIT: asCorePluginInit( + asObject({ + apiKey: asOptional(asString, ''), + referralCode: asOptional(asString, '') + }).withRest + ), MONERO_INIT: asCorePluginInit( asObject({ apiKey: asOptional(asString, '')