diff --git a/src/swap/central/exolix.ts b/src/swap/central/exolix.ts index d3105de0..2b7d3caa 100644 --- a/src/swap/central/exolix.ts +++ b/src/swap/central/exolix.ts @@ -1,4 +1,4 @@ -import { gt, lt, mul } from 'biggystring' +import { gt, lt } from 'biggystring' import { asEither, asMaybe, @@ -10,6 +10,7 @@ import { } from 'cleaners' import { EdgeCorePluginOptions, + EdgeCurrencyWallet, EdgeFetchResponse, EdgeMemo, EdgeSpendInfo, @@ -23,12 +24,12 @@ import { } from 'edge-core-js/types' import { exolix as exolixMapping } from '../../mappings/exolix' -import { div18 } from '../../util/biggystringplus' +import { EdgeCurrencyPluginId } from '../../util/edgeCurrencyPluginIds' import { checkInvalidTokenIds, checkWhitelistedMainnetCodes, CurrencyPluginIdSwapChainCodeMap, - getCodesWithTranscription, + getContractAddresses, getMaxSwappable, InvalidTokenIds, makeSwapPluginQuote, @@ -38,21 +39,15 @@ import { import { convertRequest, denominationToNative, - fetchRates, getAddress, memoType, nativeToDenomination } from '../../util/utils' -import { - asRatesResponse, - EdgeSwapRequestPlugin, - RatesRespose, - StringMap -} from '../types' +import { EdgeSwapRequestPlugin, StringMap } from '../types' const pluginId = 'exolix' -const swapInfo: EdgeSwapInfo = { +export const swapInfo: EdgeSwapInfo = { pluginId, isDex: false, displayName: 'Exolix', @@ -63,7 +58,6 @@ const asInitOptions = asObject({ apiKey: asString }) -const MAX_USD_VALUE = '70000' const INVALID_TOKEN_IDS: InvalidTokenIds = { from: { polygon: [ @@ -79,6 +73,31 @@ const INVALID_TOKEN_IDS: InvalidTokenIds = { } } +interface ExolixCommonQuoteParams { + networkFrom: string + networkTo: string + coinAddressFrom?: string + coinAddressTo?: string + networkFromChainId?: number + networkToChainId?: number + withdrawalAddress: string + withdrawalExtraId: string + refundAddress: string + refundExtraId: string + rateType: 'fixed' | 'float' + rateId?: string +} + +type ExolixFromQuoteParams = ExolixCommonQuoteParams & { + amount: string +} + +type ExolixToQuoteParams = ExolixCommonQuoteParams & { + withdrawalAmount: string +} + +type ExolixQuoteParams = ExolixFromQuoteParams | ExolixToQuoteParams + const addressTypeMap: StringMap = { digibyte: 'publicAddress', zcash: 'transparentAddress' @@ -99,10 +118,21 @@ while true; do ((n++)); done */ -const MAINNET_CODE_TRANSCRIPTION: CurrencyPluginIdSwapChainCodeMap = mapToRecord( + +const EVM_CHAIN_NETWORK = 'evmGeneric' + +export const MAINNET_CODE_TRANSCRIPTION: CurrencyPluginIdSwapChainCodeMap = mapToRecord( exolixMapping ) +const getNetwork = (wallet: EdgeCurrencyWallet): string | null => { + const evmChainId = wallet.currencyInfo.evmChainId + if (evmChainId != null) return EVM_CHAIN_NETWORK + return MAINNET_CODE_TRANSCRIPTION[ + wallet.currencyInfo.pluginId as EdgeCurrencyPluginId + ] +} + const orderUri = 'https://exolix.com/transaction/' const uri = 'https://exolix.com/api/v2/' @@ -110,10 +140,13 @@ const expirationMs = 1000 * 60 const asRateResponse = asObject({ minAmount: asNumber, - withdrawMin: asOptional(asNumber, 0), + maxAmount: asNumber, + withdrawMin: asOptional(asNumber), + withdrawMax: asOptional(asNumber), fromAmount: asNumber, toAmount: asNumber, - message: asEither(asString, asNull) + message: asEither(asString, asNull), + rateId: asOptional(asString) }) const asQuoteInfo = asObject({ @@ -133,6 +166,18 @@ export function makeExolixPlugin(opts: EdgeCorePluginOptions): EdgeSwapPlugin { request: EdgeSwapRequestPlugin, _userSettings: Object | undefined ): Promise => { + const { fromWallet, toWallet, quoteFor } = request + + const networkFrom = getNetwork(fromWallet) + const networkTo = getNetwork(toWallet) + + if (networkFrom == null || networkTo == null) { + throw new SwapCurrencyError(swapInfo, request) + } + + const networkFromChainId = fromWallet.currencyInfo.evmChainId + const networkToChainId = toWallet.currencyInfo.evmChainId + async function call( method: 'GET' | 'POST', route: string, @@ -163,13 +208,9 @@ export function makeExolixPlugin(opts: EdgeCorePluginOptions): EdgeSwapPlugin { if (!response.ok) { if (response.status === 422) { - // Exolix inconsistently returns a !ok response for a 'from' quote - // under minimum amount, while the status is OK for a 'to' quote under - // minimum amount. - // Handle this inconsistency and ensure parse the proper under min error - // and we don't exit early with the wrong 'unsupported' error message. const resJson = await response.json() const maybeMinError = asMaybe(asRateResponse)(resJson) + if (maybeMinError != null) { return resJson } @@ -194,47 +235,48 @@ export function makeExolixPlugin(opts: EdgeCorePluginOptions): EdgeSwapPlugin { ) ]) - const exchangeQuoteAmount = - request.quoteFor === 'from' - ? nativeToDenomination( - request.fromWallet, - request.nativeAmount, - request.fromTokenId - ) - : nativeToDenomination( - request.toWallet, - request.nativeAmount, - request.toTokenId - ) - - const quoteAmount = parseFloat(exchangeQuoteAmount) - - const { - fromCurrencyCode, - toCurrencyCode, - fromMainnetCode, - toMainnetCode - } = getCodesWithTranscription(request, MAINNET_CODE_TRANSCRIPTION) - - const quoteParams: Record = { - coinFrom: fromCurrencyCode, - coinFromNetwork: fromMainnetCode, - coinTo: toCurrencyCode, - coinToNetwork: toMainnetCode, - amount: quoteAmount, - rateType: 'fixed' + let amount + if (quoteFor === 'from') { + const quoteAmount = nativeToDenomination( + request.fromWallet, + request.nativeAmount, + request.fromTokenId + ) + amount = { amount: quoteAmount } + } else { + const quoteAmount = nativeToDenomination( + request.toWallet, + request.nativeAmount, + request.toTokenId + ) + amount = { withdrawalAmount: quoteAmount } } - // Set the withdrawal amount if we are quoting for the toCurrencyCode - if (request.quoteFor === 'to') { - quoteParams.withdrawalAmount = quoteAmount + const { + fromContractAddress: coinAddressFrom, + toContractAddress: coinAddressTo + } = getContractAddresses(request) + + const quoteParams: ExolixQuoteParams = { + ...(coinAddressFrom != null ? { coinAddressFrom } : {}), + ...(networkFromChainId != null ? { networkFromChainId } : {}), + networkFrom, + ...(coinAddressTo != null ? { coinAddressTo } : {}), + ...(networkToChainId != null ? { networkToChainId } : {}), + networkTo, + rateType: 'fixed', + withdrawalAddress: toAddress, + refundAddress: fromAddress, + refundExtraId: '', + withdrawalExtraId: '', + ...amount } // Get Rate const rateResponse = asRateResponse(await call('GET', 'rate', quoteParams)) // Check rate minimum: - if (request.quoteFor === 'from') { + if (quoteFor === 'from') { const nativeMin = denominationToNative( request.fromWallet, rateResponse.minAmount.toString(), @@ -244,7 +286,21 @@ export function makeExolixPlugin(opts: EdgeCorePluginOptions): EdgeSwapPlugin { if (lt(request.nativeAmount, nativeMin)) { throw new SwapBelowLimitError(swapInfo, nativeMin, 'from') } + + const nativeMax = denominationToNative( + request.fromWallet, + rateResponse.maxAmount.toString(), + request.fromTokenId + ) + + if (gt(request.nativeAmount, nativeMax)) { + throw new SwapAboveLimitError(swapInfo, nativeMax, 'from') + } } else { + if (typeof rateResponse.withdrawMin === 'undefined') { + throw new SwapBelowLimitError(swapInfo, '0', 'to') + } + const nativeMin = denominationToNative( request.toWallet, rateResponse.withdrawMin.toString(), @@ -254,26 +310,29 @@ export function makeExolixPlugin(opts: EdgeCorePluginOptions): EdgeSwapPlugin { if (lt(request.nativeAmount, nativeMin)) { throw new SwapBelowLimitError(swapInfo, nativeMin, 'to') } + + if (typeof rateResponse.withdrawMax === 'undefined') { + throw new SwapAboveLimitError(swapInfo, '0', 'to') + } + + const nativeMax = denominationToNative( + request.toWallet, + rateResponse.withdrawMax.toString(), + request.toTokenId + ) + + if (gt(request.nativeAmount, nativeMax)) { + throw new SwapAboveLimitError(swapInfo, nativeMax, 'to') + } } // Make the transaction: - const exchangeParams: Record = { - coinFrom: quoteParams.coinFrom, - networkFrom: quoteParams.coinFromNetwork, - coinTo: quoteParams.coinTo, - networkTo: quoteParams.coinToNetwork, - amount: quoteAmount, - withdrawalAddress: toAddress, - withdrawalExtraId: '', - refundAddress: fromAddress, - refundExtraId: '', - rateType: 'fixed' + const exchangeParams: ExolixQuoteParams = { + ...quoteParams, + rateId: rateResponse.rateId } // Set the withdrawal amount if we are quoting for the toCurrencyCode - if (request.quoteFor === 'to') { - exchangeParams.withdrawalAmount = quoteAmount - } const callJson = await call('POST', 'transactions', exchangeParams) const quoteInfo = asQuoteInfo(callJson) @@ -367,71 +426,6 @@ export function makeExolixPlugin(opts: EdgeCorePluginOptions): EdgeSwapPlugin { const fixedOrder = await getFixedQuote(newRequest, userSettings) const fixedResult = await makeSwapPluginQuote(fixedOrder) - // Limit exolix to $70k USD - let currencyCode: string - let exchangeAmount: string - let denomToNative: string - if (newRequest.quoteFor === 'from') { - currencyCode = newRequest.fromCurrencyCode - exchangeAmount = nativeToDenomination( - newRequest.fromWallet, - newRequest.nativeAmount, - newRequest.fromTokenId - ) - denomToNative = denominationToNative( - newRequest.fromWallet, - '1', - newRequest.fromTokenId - ) - } else { - currencyCode = newRequest.toCurrencyCode - exchangeAmount = nativeToDenomination( - newRequest.toWallet, - newRequest.nativeAmount, - newRequest.toTokenId - ) - denomToNative = denominationToNative( - newRequest.toWallet, - '1', - newRequest.toTokenId - ) - } - const data = [{ currency_pair: `${currencyCode}_iso:USD` }] - - const options = { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ data }) - } - let rates: RatesRespose - try { - const response = await fetchRates(fetch, 'v2/exchangeRates', options) - if (!response.ok) { - const text = await response.text() - throw new Error(`Error fetching rates: ${text}`) - } - const reply = await response.json() - rates = asRatesResponse(reply) - } catch (e) { - log.error('Error fetching rates', String(e)) - throw new Error('Error fetching rates') - } - - const { exchangeRate } = rates.data[0] - if (exchangeRate == null) throw new SwapCurrencyError(swapInfo, request) - - const usdValue = mul(exchangeAmount, exchangeRate) - const maxExchangeAmount = div18(MAX_USD_VALUE, exchangeRate) - const maxNativeAmount = mul(maxExchangeAmount, denomToNative) - - if (gt(usdValue, MAX_USD_VALUE)) { - throw new SwapAboveLimitError( - swapInfo, - maxNativeAmount, - newRequest.quoteFor === 'from' ? 'from' : 'to' - ) - } - return fixedResult } } diff --git a/test/exolix.test.ts b/test/exolix.test.ts new file mode 100644 index 00000000..a25af54f --- /dev/null +++ b/test/exolix.test.ts @@ -0,0 +1,475 @@ +import { assert } from 'chai' +import { EdgeSwapPlugin } from 'edge-core-js' +import { describe, it } from 'mocha' + +import { makeExolixPlugin } from '../src/swap/central/exolix' + +interface MockResponse { + ok: boolean + status: number + body: any +} + +interface FetchCall { + url: string + init: any +} + +interface WalletOpts { + id: string + pluginId: string + currencyCode: string + multiplier: string + publicAddress: string + addressType?: string + evmChainId?: number +} + +function makeWallet(opts: WalletOpts): any { + const { + id, + pluginId, + currencyCode, + multiplier, + publicAddress, + addressType, + evmChainId + } = opts + + return { + id, + currencyInfo: { + currencyCode, + pluginId, + denominations: [{ name: currencyCode, multiplier }], + ...(evmChainId == null ? {} : { evmChainId }) + }, + currencyConfig: { + currencyInfo: { + pluginId + }, + allTokens: {} + }, + async getAddresses() { + return [{ publicAddress, addressType }] + }, + async makeSpend(spendInfo: any) { + return { + assetAction: spendInfo.assetAction, + currencyCode, + networkFee: '1000', + savedAction: spendInfo.savedAction, + tokenId: spendInfo.tokenId ?? null, + txid: 'test-txid' + } + } + } +} + +function makeResponse(body: any, status: number = 200): MockResponse { + return { + ok: status >= 200 && status < 300, + status, + body + } +} + +function makePlugin( + responses: MockResponse[] +): { + plugin: EdgeSwapPlugin + fetchCalls: FetchCall[] +} { + const fetchCalls: FetchCall[] = [] + + const plugin = makeExolixPlugin({ + initOptions: { apiKey: 'test-key' }, + io: { + fetch: async (url: string, init: any) => { + fetchCalls.push({ url, init }) + + const response = responses.shift() + if (response == null) throw new Error('Unexpected fetch call') + + return { + ok: response.ok, + status: response.status, + async json() { + return response.body + } + } + } + }, + log: { + warn() {} + } + } as any) + + return { plugin, fetchCalls } +} + +async function expectError( + promise: Promise, + name: string +): Promise { + try { + await promise + } catch (error: any) { + assert.equal(error.name, name) + return error + } + throw new Error(`Expected ${name}`) +} + +const btcWallet = makeWallet({ + id: 'btc-wallet', + pluginId: 'bitcoin', + currencyCode: 'BTC', + multiplier: '100000000', + publicAddress: 'bc1q3hwz3r7xa8eaj9ae9m64va4gaj3gktxqpwkp6q', + addressType: 'segwitAddress' +}) + +const ethWallet = makeWallet({ + id: 'eth-wallet', + pluginId: 'ethereum', + currencyCode: 'ETH', + multiplier: '1000000000000000000', + publicAddress: '0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', + evmChainId: 1 +}) + +describe(`exolix fetchSwapQuote`, function () { + it('uses amount for quoteFor from and creates a quote', async function () { + const { plugin, fetchCalls } = makePlugin([ + makeResponse({ + minAmount: 0.001, + maxAmount: 1, + fromAmount: 0.002, + toAmount: 0.06, + message: null, + rateId: 'rate-from' + }), + makeResponse({ + id: 'order-from', + amount: 0.002, + amountTo: 0.06, + depositAddress: 'bc1qdepositaddress', + depositExtraId: 'memo-123' + }) + ]) + + const quote = await plugin.fetchSwapQuote( + { + fromWallet: btcWallet, + toWallet: ethWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '200000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ) + + assert.equal(fetchCalls.length, 2) + assert.equal(fetchCalls[0].init.method, 'GET') + assert.equal(fetchCalls[1].init.method, 'POST') + + const rateUrl = new URL(fetchCalls[0].url) + assert.equal(rateUrl.pathname, '/api/v2/rate') + assert.equal(rateUrl.searchParams.get('amount'), '0.002') + assert.equal(rateUrl.searchParams.get('withdrawalAmount'), null) + assert.equal(rateUrl.searchParams.get('networkFrom'), 'BTC') + assert.equal(rateUrl.searchParams.get('networkTo'), 'evmGeneric') + assert.equal(rateUrl.searchParams.get('networkToChainId'), '1') + + const transactionBody = JSON.parse(fetchCalls[1].init.body) + assert.equal(transactionBody.amount, '0.002') + assert.equal(transactionBody.withdrawalAmount, undefined) + assert.equal(transactionBody.rateId, 'rate-from') + + assert.equal(quote.pluginId, 'exolix') + assert.equal(quote.request.quoteFor, 'from') + assert.equal(quote.fromNativeAmount, '200000') + assert.equal(quote.toNativeAmount, '60000000000000000') + }) + + it('uses withdrawalAmount for quoteFor to and creates a quote', async function () { + const { plugin, fetchCalls } = makePlugin([ + makeResponse({ + minAmount: 0.001, + maxAmount: 1, + withdrawMin: 0.5, + withdrawMax: 10, + fromAmount: 0.03, + toAmount: 0.5, + message: null, + rateId: 'rate-to' + }), + makeResponse({ + id: 'order-to', + amount: 0.03, + amountTo: 0.5, + depositAddress: 'bc1qdepositaddress', + depositExtraId: 'memo-456' + }) + ]) + + const quote = await plugin.fetchSwapQuote( + { + fromWallet: btcWallet, + toWallet: ethWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '500000000000000000', + quoteFor: 'to' + }, + undefined, + { infoPayload: {} } + ) + + assert.equal(fetchCalls.length, 2) + + const rateUrl = new URL(fetchCalls[0].url) + assert.equal(rateUrl.searchParams.get('amount'), null) + assert.equal(rateUrl.searchParams.get('withdrawalAmount'), '0.5') + + const transactionBody = JSON.parse(fetchCalls[1].init.body) + assert.equal(transactionBody.amount, undefined) + assert.equal(transactionBody.withdrawalAmount, '0.5') + assert.equal(transactionBody.rateId, 'rate-to') + + assert.equal(quote.request.quoteFor, 'to') + assert.equal(quote.fromNativeAmount, '3000000') + assert.equal(quote.toNativeAmount, '500000000000000000') + }) + + it('throws above-limit error when quoteFor to response is missing withdrawMax', async function () { + const { plugin, fetchCalls } = makePlugin([ + makeResponse({ + minAmount: 0.001, + maxAmount: 1, + withdrawMin: 0.5, + fromAmount: 0.03, + toAmount: 0.5, + message: null, + rateId: 'rate-to' + }) + ]) + + const error = await expectError( + plugin.fetchSwapQuote( + { + fromWallet: btcWallet, + toWallet: ethWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '500000000000000000', + quoteFor: 'to' + }, + undefined, + { infoPayload: {} } + ), + 'SwapAboveLimitError' + ) + + assert.equal(error.pluginId, 'exolix') + assert.equal(error.direction, 'to') + assert.equal(error.nativeMax, '0') + assert.equal(fetchCalls.length, 1) + }) + + it('turns 422 minimum response into a below-limit error for quoteFor from', async function () { + const { plugin, fetchCalls } = makePlugin([ + makeResponse( + { + minAmount: 0.001, + maxAmount: 1, + fromAmount: 0.0001, + toAmount: 0.003, + message: 'Amount is below minimum' + }, + 422 + ) + ]) + + const error = await expectError( + plugin.fetchSwapQuote( + { + fromWallet: btcWallet, + toWallet: ethWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '10000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ), + 'SwapBelowLimitError' + ) + + assert.equal(error.pluginId, 'exolix') + assert.equal(error.direction, 'from') + assert.equal(error.nativeMin, '100000') + assert.equal(fetchCalls.length, 1) + }) + + it('turns 422 minimum response into a below-limit error for quoteFor to', async function () { + const { plugin, fetchCalls } = makePlugin([ + makeResponse( + { + minAmount: 0.001, + maxAmount: 1, + withdrawMin: 0.5, + withdrawMax: 10, + fromAmount: 0.03, + toAmount: 0.5, + message: 'Amount is below minimum' + }, + 422 + ) + ]) + + const error = await expectError( + plugin.fetchSwapQuote( + { + fromWallet: btcWallet, + toWallet: ethWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '100000000000000000', + quoteFor: 'to' + }, + undefined, + { infoPayload: {} } + ), + 'SwapBelowLimitError' + ) + + assert.equal(error.pluginId, 'exolix') + assert.equal(error.direction, 'to') + assert.equal(error.nativeMin, '500000000000000000') + assert.equal(fetchCalls.length, 1) + }) + + it('returns 422 rate payload for all limit cases so later validation throws the limit error', async function () { + const cases = [ + { + name: 'below-limit from', + responseBody: { + minAmount: 0.001, + maxAmount: 1, + fromAmount: 0.0001, + toAmount: 0, + message: 'Amount is below minimum' + }, + request: { + fromWallet: btcWallet, + toWallet: ethWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '10000', + quoteFor: 'from' as const + }, + expectedErrorName: 'SwapBelowLimitError', + expectedField: 'nativeMin', + expectedValue: '100000', + expectedDirection: 'from' + }, + { + name: 'above-limit from', + responseBody: { + minAmount: 0.001, + maxAmount: 1, + fromAmount: 2, + toAmount: 0, + message: 'Amount is above maximum' + }, + request: { + fromWallet: btcWallet, + toWallet: ethWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '200000000', + quoteFor: 'from' as const + }, + expectedErrorName: 'SwapAboveLimitError', + expectedField: 'nativeMax', + expectedValue: '100000000', + expectedDirection: 'from' + }, + { + name: 'below-limit to', + responseBody: { + minAmount: 0.001, + maxAmount: 1, + withdrawMin: 0.5, + withdrawMax: 10, + fromAmount: 0.03, + toAmount: 0.5, + message: 'Amount is below minimum' + }, + request: { + fromWallet: btcWallet, + toWallet: ethWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '100000000000000000', + quoteFor: 'to' as const + }, + expectedErrorName: 'SwapBelowLimitError', + expectedField: 'nativeMin', + expectedValue: '500000000000000000', + expectedDirection: 'to' + }, + { + name: 'above-limit to', + responseBody: { + minAmount: 0.001, + maxAmount: 1, + withdrawMin: 0.5, + withdrawMax: 10, + fromAmount: 0.03, + toAmount: 12, + message: 'Amount is above maximum' + }, + request: { + fromWallet: btcWallet, + toWallet: ethWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '12000000000000000000', + quoteFor: 'to' as const + }, + expectedErrorName: 'SwapAboveLimitError', + expectedField: 'nativeMax', + expectedValue: '10000000000000000000', + expectedDirection: 'to' + } + ] + + for (const testCase of cases) { + const { plugin, fetchCalls } = makePlugin([ + makeResponse(testCase.responseBody, 422) + ]) + + const error = await expectError( + plugin.fetchSwapQuote(testCase.request, undefined, { infoPayload: {} }), + testCase.expectedErrorName + ) + + assert.notEqual(error.name, 'SwapCurrencyError', testCase.name) + assert.equal(error.pluginId, 'exolix', testCase.name) + assert.equal(error.direction, testCase.expectedDirection, testCase.name) + assert.equal( + error[testCase.expectedField], + testCase.expectedValue, + testCase.name + ) + assert.equal(fetchCalls.length, 1, testCase.name) + assert.equal(fetchCalls[0].init.method, 'GET', testCase.name) + } + }) +})