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, '')