Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 126 additions & 132 deletions src/swap/central/exolix.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { gt, lt, mul } from 'biggystring'
import { gt, lt } from 'biggystring'
import {
asEither,
asMaybe,
Expand All @@ -10,6 +10,7 @@ import {
} from 'cleaners'
import {
EdgeCorePluginOptions,
EdgeCurrencyWallet,
EdgeFetchResponse,
EdgeMemo,
EdgeSpendInfo,
Expand All @@ -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,
Expand All @@ -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',
Expand All @@ -63,7 +58,6 @@ const asInitOptions = asObject({
apiKey: asString
})

const MAX_USD_VALUE = '70000'
const INVALID_TOKEN_IDS: InvalidTokenIds = {
from: {
polygon: [
Expand All @@ -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'
Expand All @@ -99,21 +118,35 @@ 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/'

const expirationMs = 1000 * 60

const asRateResponse = asObject({
minAmount: asNumber,
withdrawMin: asOptional(asNumber, 0),
maxAmount: asNumber,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The 422 fallback uses asMaybe(asRateResponse), but this cleaner now requires fields like maxAmount and rateId. If Exolix returns a partial 422/minimum payload, parsing fails and this path throws SwapCurrencyError instead of returning a precise limit error.

Recommendation: Use a dedicated looser cleaner for 422 limit payloads, and reserve asRateResponse for successful quote payloads.

withdrawMin: asOptional(asNumber),
withdrawMax: asOptional(asNumber),
fromAmount: asNumber,
toAmount: asNumber,
message: asEither(asString, asNull)
message: asEither(asString, asNull),
rateId: asOptional(asString)
})

const asQuoteInfo = asObject({
Expand All @@ -133,6 +166,18 @@ export function makeExolixPlugin(opts: EdgeCorePluginOptions): EdgeSwapPlugin {
request: EdgeSwapRequestPlugin,
_userSettings: Object | undefined
): Promise<SwapOrder> => {
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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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<string, any> = {
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(),
Expand All @@ -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(),
Expand All @@ -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<string, any> = {
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: This change removes the prior local $70k USD guardrail. If that cap was a product/risk requirement, behavior has changed and large swaps now rely solely on partner-provided limits.

Recommendation: Confirm policy intent and keep a local cap if still required.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I confirm this — it was necessary for the old functionality; now all limits are calculated automatically.

}

// 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)
Expand Down Expand Up @@ -367,71 +426,6 @@ export function makeExolixPlugin(opts: EdgeCorePluginOptions): EdgeSwapPlugin {
const fixedOrder = await getFixedQuote(newRequest, userSettings)
const fixedResult = await makeSwapPluginQuote(fixedOrder)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: This PR materially changes Exolix quote behavior (new request shape, max/min handling, and 422 parsing) without corresponding automated tests.

Recommendation: Add targeted tests for quoteFor: from/to, missing withdrawMax, and 422 minimum responses to prevent regressions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the API requirements angle, this PR does move the integration in the right direction in a few meaningful ways: it updates the request shape to use chain-aware identifiers (evmGeneric plus chain IDs for EVM assets), includes token contract addresses, and adds explicit destination-side quoting support via quoteFor: 'to'. Those are all improvements toward the chain/token identification and bi-directional quoting requirements.

The highest-priority remaining concern is whether the new destination-side quoting path is fully reliable in practice. Because quoteFor: 'to' is new here and limit handling now depends on withdrawMin / withdrawMax, I would want confidence that Exolix always returns the necessary limit fields or that the plugin tolerates missing fields without manufacturing false over-limit failures.

Beyond that, the other material API-requirements items are provider capabilities that matter to user experience and ops, but are not really proven by this plugin diff: an unauthenticated status page, a transaction-status API by orderId, and reporting data that can be matched back to Edge orders. I would not block this PR on every last detail of error-shape perfection or extra hardening if the Exolix team has already confirmed those behaviors, but before treating the integration as compliant with the API requirements, I would want those core capabilities explicitly confirmed somewhere in the PR or supporting notes.


// 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
}
}
Expand Down
Loading