From bb19620fc342dc2cf75e021137be7a6809d2107c Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Mon, 24 Jun 2024 16:12:47 -0700 Subject: [PATCH 1/3] Add Rango Exchange plugin --- src/index.ts | 2 + src/swap/defi/rango.ts | 547 +++++++++++++++++++++++++++++++++++++++++ test/testconfig.ts | 8 + test/testpartner.ts | 1 + 4 files changed, 558 insertions(+) create mode 100644 src/swap/defi/rango.ts diff --git a/src/index.ts b/src/index.ts index bf1b056d..e9dc234d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { makeSideshiftPlugin } from './swap/central/sideshift' import { makeSwapuzPlugin } from './swap/central/swapuz' import { makeCosmosIbcPlugin } from './swap/defi/cosmosIbc' import { makeLifiPlugin } from './swap/defi/lifi' +import { makeRangoPlugin } from './swap/defi/rango' import { makeThorchainPlugin } from './swap/defi/thorchain' import { makeThorchainDaPlugin } from './swap/defi/thorchainDa' import { makeSpookySwapPlugin } from './swap/defi/uni-v2-based/plugins/spookySwap' @@ -28,6 +29,7 @@ const plugins = { godex: makeGodexPlugin, letsexchange: makeLetsExchangePlugin, lifi: makeLifiPlugin, + rango: makeRangoPlugin, sideshift: makeSideshiftPlugin, spookySwap: makeSpookySwapPlugin, swapuz: makeSwapuzPlugin, diff --git a/src/swap/defi/rango.ts b/src/swap/defi/rango.ts new file mode 100644 index 00000000..fbbc1a4f --- /dev/null +++ b/src/swap/defi/rango.ts @@ -0,0 +1,547 @@ +import { + asArray, + asEither, + asNull, + asNumber, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' +import { + EdgeCorePluginOptions, + EdgeSpendInfo, + EdgeSwapInfo, + EdgeSwapPlugin, + EdgeSwapQuote, + EdgeSwapRequest, + EdgeTransaction, + SwapCurrencyError +} from 'edge-core-js/types' + +import { div18 } from '../../util/biggystringplus' +import { + getMaxSwappable, + makeSwapPluginQuote, + SwapOrder +} from '../../util/swapHelpers' +import { + convertRequest, + fetchInfo, + fetchWaterfall, + getAddress, + hexToDecimal, + makeQueryParams, + promiseWithTimeout +} from '../../util/utils' +import { EdgeSwapRequestPlugin, StringMap } from '../types' + +const swapInfo: EdgeSwapInfo = { + pluginId: 'rango', + isDex: true, + displayName: 'Rango Exchange', + supportEmail: 'support@edge.app' +} + +const EXPIRATION_MS = 1000 * 60 +const EXCHANGE_INFO_UPDATE_FREQ_MS = 60000 + +const MAINNET_CODE_TRANSCRIPTION: StringMap = { + arbitrum: 'ARBITRUM', + avalanche: 'AVAX_CCHAIN', + binancesmartchain: 'BSC', + ethereum: 'ETH', + fantom: 'FANTOM', + moonbeam: 'MOONBEAM', + moonriver: 'MOONRIVER', + okexchain: 'OKC', + optimism: 'OPTIMISM', + polygon: 'POLYGON', + zksync: 'ZKSYNC' +} + +const RANGO_SERVERS_DEFAULT = ['https://api.rango.exchange'] + +const PARENT_TOKEN_CONTRACT_ADDRESS = '0x0' + +const DEFAULT_SLIPPAGE = '5.0' + +interface Asset { + blockchain: string + address: string | null + symbol: string +} + +function assetToString(asset: Asset): string { + if ( + !(asset.address == null) && + asset.address !== PARENT_TOKEN_CONTRACT_ADDRESS + ) + return `${asset.blockchain}.${asset.symbol}--${asset.address}` + else return `${asset.blockchain}.${asset.symbol}` +} + +const asInitOptions = asObject({ + appId: asOptional(asString, 'edge'), + rangoApiKey: asString, + referrerAddress: asOptional(asString), + referrerFee: asOptional(asString) +}) + +const asExchangeInfo = asObject({ + swap: asObject({ + plugins: asObject({ + rango: asOptional( + asObject({ + rangoServers: asOptional(asArray(asString)) + }) + ) + }) + }) +}) + +const asCompactToken = asObject({ + // blockchain + b: asString, + // address + a: asOptional(asString), + // symbol + s: asString +}) + +const asCompactMetaResponse = asObject({ + tokens: asArray(asCompactToken) +}) + +const asToken = asObject({ + blockchain: asString, + address: asEither(asString, asNull), + symbol: asString +}) + +const asSwapperMeta = asObject({ + id: asString, + title: asString +}) + +const asSwapPath = asObject({ + from: asToken, + to: asToken, + swapper: asSwapperMeta, + expectedOutput: asString +}) + +const asSwapFee = asObject({ + name: asString, + token: asToken, + expenseType: asValue( + 'FROM_SOURCE_WALLET', + 'DECREASE_FROM_OUTPUT', + 'FROM_DESTINATION_WALLET' + ), + amount: asString +}) + +const asSwapSimulationResult = asObject({ + from: asToken, + to: asToken, + outputAmount: asString, + outputAmountMin: asString, + outputAmountUsd: asEither(asNumber, asNull), + swapper: asSwapperMeta, + path: asEither(asArray(asSwapPath), asNull), + fee: asArray(asSwapFee), + feeUsd: asEither(asNumber, asNull), + estimatedTimeInSeconds: asNumber +}) + +const asRoutingResultType = asValue( + 'OK', + 'HIGH_IMPACT', + 'NO_ROUTE', + 'INPUT_LIMIT_ISSUE' +) + +const asEvmTransaction = asObject({ + type: asValue('EVM'), + from: asEither(asString, asNull), + approveTo: asEither(asString, asNull), + approveData: asEither(asString, asNull), + txTo: asString, + txData: asEither(asString, asNull), + value: asEither(asString, asNull), + gasLimit: asEither(asString, asNull), + gasPrice: asEither(asString, asNull), + maxPriorityFeePerGas: asEither(asString, asNull), + maxFeePerGas: asEither(asString, asNull) +}) + +const asSwapResponse = asObject({ + resultType: asRoutingResultType, + route: asEither(asSwapSimulationResult, asNull), + error: asEither(asString, asNull), + tx: asEither(asEvmTransaction, asNull) +}) + +type ExchangeInfo = ReturnType + +type TokenSymbol = string + +const rangoTokens: { + [blockchain: string]: { [address: string]: TokenSymbol } +} = {} + +let exchangeInfo: ExchangeInfo | undefined +let exchangeInfoLastUpdate = 0 + +export function makeRangoPlugin(opts: EdgeCorePluginOptions): EdgeSwapPlugin { + const { io, log } = opts + const { fetchCors = io.fetch } = io + const { appId, rangoApiKey, referrerAddress, referrerFee } = asInitOptions( + opts.initOptions + ) + + const headers = { + 'Content-Type': 'application/json' + } + + let rangoServers: string[] = RANGO_SERVERS_DEFAULT + + let params = makeQueryParams({ + apiKey: rangoApiKey + }) + + Object.values(MAINNET_CODE_TRANSCRIPTION).forEach( + blockchain => (params += `&blockchains=${blockchain}`) + ) + + const metaRequest = fetchWaterfall( + fetchCors, + rangoServers, + `meta/compact?${params}`, + { + headers + } + ) + + const fetchSwapQuoteInner = async ( + request: EdgeSwapRequestPlugin + ): Promise => { + const { + fromTokenId, + toTokenId, + nativeAmount, + fromWallet, + toWallet, + quoteFor + } = request + if (quoteFor !== 'from') { + throw new SwapCurrencyError(swapInfo, request) + } + + if (Object.keys(rangoTokens).length === 0) { + await metaRequest + .then(async metaResponse => { + if (metaResponse.ok) { + return await metaResponse.json() + } else { + const text = await metaResponse.text() + throw new Error(text) + } + }) + .then(meta => { + const { tokens } = asCompactMetaResponse(meta) + tokens.forEach(token => { + const tokenBlockchain = token.b + const tokenAddress = token.a + const tokenSymbol = token.s + if (rangoTokens[tokenBlockchain] === undefined) { + rangoTokens[tokenBlockchain] = {} + } + rangoTokens[tokenBlockchain][ + tokenAddress ?? PARENT_TOKEN_CONTRACT_ADDRESS + ] = tokenSymbol + }) + }) + .catch(e => { + throw new Error(`Error fetching Rango meta ${String(e)}`) + }) + } + + const fromToken = + fromTokenId != null + ? fromWallet.currencyConfig.allTokens[fromTokenId] + : undefined + let fromContractAddress + if (fromTokenId === null) { + fromContractAddress = PARENT_TOKEN_CONTRACT_ADDRESS + } else { + fromContractAddress = fromToken?.networkLocation?.contractAddress + } + + const toToken = + toTokenId != null + ? toWallet.currencyConfig.allTokens[toTokenId] + : undefined + let toContractAddress + if (toTokenId === null) { + toContractAddress = PARENT_TOKEN_CONTRACT_ADDRESS + } else { + toContractAddress = toToken?.networkLocation?.contractAddress + } + + if (fromContractAddress == null || toContractAddress == null) { + throw new SwapCurrencyError(swapInfo, request) + } + + // Do not support transfer between same assets + if ( + fromWallet.currencyInfo.pluginId === toWallet.currencyInfo.pluginId && + request.fromCurrencyCode === request.toCurrencyCode + ) { + throw new SwapCurrencyError(swapInfo, request) + } + + const fromAddress = await getAddress(fromWallet) + const toAddress = await getAddress(toWallet) + + const fromMainnetCode = + MAINNET_CODE_TRANSCRIPTION[fromWallet.currencyInfo.pluginId] + const toMainnetCode = + MAINNET_CODE_TRANSCRIPTION[toWallet.currencyInfo.pluginId] + + if (fromMainnetCode == null || toMainnetCode == null) { + throw new SwapCurrencyError(swapInfo, request) + } + + const fromSymbol = + rangoTokens[fromMainnetCode]?.[fromContractAddress.toLowerCase()] + const toSymbol = + rangoTokens[toMainnetCode]?.[toContractAddress.toLowerCase()] + + if (fromSymbol == null || toSymbol == null) { + throw new SwapCurrencyError(swapInfo, request) + } + + const now = Date.now() + if ( + now - exchangeInfoLastUpdate > EXCHANGE_INFO_UPDATE_FREQ_MS || + exchangeInfo == null + ) { + try { + const exchangeInfoResponse = await promiseWithTimeout( + fetchInfo(fetchCors, `v1/exchangeInfo/${appId}`) + ) + + if (exchangeInfoResponse.ok === true) { + exchangeInfo = asExchangeInfo(await exchangeInfoResponse.json()) + exchangeInfoLastUpdate = now + } else { + // Error is ok. We just use defaults + const text: string = await exchangeInfoResponse.text() + log.warn( + `Error getting info server exchangeInfo. Using defaults... Error: ${text}` + ) + } + } catch (e: any) { + log.warn( + 'Error getting info server exchangeInfo. Using defaults...', + e.message + ) + } + } + + if (exchangeInfo != null) { + const { rango } = exchangeInfo.swap.plugins + rangoServers = rango?.rangoServers ?? rangoServers + } + + let referrer: { referrerFee: string; referrerAddress: string } | undefined + + if ( + referrerAddress != null && + referrerAddress !== '' && + referrerFee != null && + referrerFee !== '' + ) { + referrer = { referrerAddress, referrerFee } + } + + const swapParameters = { + apiKey: rangoApiKey, + from: assetToString({ + blockchain: fromMainnetCode, + address: fromContractAddress, + symbol: fromSymbol + }), + to: assetToString({ + blockchain: toMainnetCode, + address: toContractAddress, + symbol: toSymbol + }), + fromAddress: fromAddress, + toAddress: toAddress, + amount: nativeAmount, + disableEstimate: true, + slippage: DEFAULT_SLIPPAGE, + ...(referrer != null ? referrer : undefined) + } + + const params = makeQueryParams(swapParameters) + + const swapResponse = await fetchWaterfall( + fetchCors, + rangoServers, + `basic/swap?${params}`, + { + headers + } + ) + + if (!swapResponse.ok) { + const responseText = await swapResponse.text() + throw new Error(`Rango could not fetch quote: ${responseText}`) + } + + const swap = asSwapResponse(await swapResponse.json()) + const { route, tx } = swap + + if (swap.resultType !== 'OK') { + throw new Error( + `Rango could not proceed with the exchange. : ${swap.resultType}` + ) + } + + if ( + route?.path == null || + route.outputAmount === '' || + tx == null || + tx.txData == null + ) { + throw new Error('Rango could not proceed with the exchange') + } + + const providers = route.path.map(p => p.swapper.title) + + let preTx: EdgeTransaction | undefined + if (tx.type === 'EVM' && tx.approveData != null && tx.approveTo != null) { + const approvalData = tx.approveData.replace('0x', '') + + const spendInfo: EdgeSpendInfo = { + tokenId: null, + memos: [{ type: 'hex', value: approvalData }], + spendTargets: [ + { + nativeAmount: '0', + publicAddress: fromContractAddress + } + ], + assetAction: { + assetActionType: 'tokenApproval' + }, + savedAction: { + actionType: 'tokenApproval', + tokenApproved: { + pluginId: fromWallet.currencyInfo.pluginId, + tokenId: fromTokenId, + nativeAmount + }, + tokenContractAddress: fromContractAddress, + contractAddress: tx.approveTo + } + } + preTx = await request.fromWallet.makeSpend(spendInfo) + } + + const customNetworkFee = { + gasLimit: tx.gasLimit != null ? hexToDecimal(tx.gasLimit) : undefined, + gasPrice: + tx.gasPrice != null ? div18(tx.gasPrice, '1000000000') : undefined, + maxFeePerGas: tx.maxFeePerGas ?? undefined, + maxPriorityFeePerGas: tx.maxPriorityFeePerGas ?? undefined + } + + const networkFeeOption: EdgeSpendInfo['networkFeeOption'] = + customNetworkFee.gasLimit != null || customNetworkFee.gasPrice != null + ? 'custom' + : undefined + + const value = tx.txData.replace('0x', '') + const spendInfo: EdgeSpendInfo = { + tokenId: request.fromTokenId, + memos: [{ type: 'hex', value }], + customNetworkFee, + spendTargets: [ + { + memo: tx.txData, + nativeAmount: nativeAmount, + publicAddress: tx.txTo + } + ], + networkFeeOption, + assetAction: { + assetActionType: 'swap' + }, + savedAction: { + actionType: 'swap', + swapInfo, + isEstimate: true, + toAsset: { + pluginId: toWallet.currencyInfo.pluginId, + tokenId: toTokenId, + nativeAmount: route.outputAmount + }, + fromAsset: { + pluginId: fromWallet.currencyInfo.pluginId, + tokenId: fromTokenId, + nativeAmount: nativeAmount + }, + payoutAddress: toAddress, + payoutWalletId: toWallet.id, + refundAddress: fromAddress + } + } + + const providersStr = providers?.join(' -> ') + const metadataNotes = `DEX Providers: ${providersStr}` + + return { + request, + spendInfo, + swapInfo, + fromNativeAmount: nativeAmount, + expirationDate: new Date(Date.now() + EXPIRATION_MS), + preTx, + metadataNotes + } + } + + const out: EdgeSwapPlugin = { + swapInfo, + + async fetchSwapQuote(req: EdgeSwapRequest): Promise { + const request = convertRequest(req) + + let newRequest = request + if (request.quoteFor === 'max') { + if (request.fromTokenId != null) { + const maxAmount = + request.fromWallet.balanceMap.get(request.fromTokenId) ?? '0' + newRequest = { + ...request, + nativeAmount: maxAmount, + quoteFor: 'from' + } + } else { + newRequest = await getMaxSwappable( + async r => await fetchSwapQuoteInner(r), + request + ) + } + } + const swapOrder = await fetchSwapQuoteInner(newRequest) + return await makeSwapPluginQuote(swapOrder) + } + } + return out +} diff --git a/test/testconfig.ts b/test/testconfig.ts index 7297fa75..125c2a36 100644 --- a/test/testconfig.ts +++ b/test/testconfig.ts @@ -87,6 +87,14 @@ export const asTestConfig = asObject({ PULSECHAIN_INIT: asCorePluginInit(asEvmApiKeys), POLYGON_INIT: asCorePluginInit(asEvmApiKeys), + RANGO_INIT: asCorePluginInit( + asObject({ + appId: asOptional(asString, 'edge'), + rangoApiKey: asOptional(asString, ''), + referrerAddress: asOptional(asString, ''), + referrerFee: asOptional(asString, '0.75') + }).withRest + ), SIDESHIFT_INIT: asCorePluginInit( asObject({ affiliateId: asOptional(asString, '') diff --git a/test/testpartner.ts b/test/testpartner.ts index ddd48980..dc8c66e4 100644 --- a/test/testpartner.ts +++ b/test/testpartner.ts @@ -96,6 +96,7 @@ async function main(): Promise { lifi: config.LIFI_INIT, polygon: config.POLYGON_INIT, piratechain: true, + rango: config.RANGO_INIT, thorchain: config.THORCHAIN_INIT, thorchainda: config.THORCHAIN_INIT } From 0365c445e987c62c58327bb6fff89a8a795f7cfa Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 20 Jun 2024 13:29:03 -0400 Subject: [PATCH 2/3] Add limit errors --- src/swap/defi/rango.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/swap/defi/rango.ts b/src/swap/defi/rango.ts index fbbc1a4f..7d390f47 100644 --- a/src/swap/defi/rango.ts +++ b/src/swap/defi/rango.ts @@ -1,3 +1,4 @@ +import { gte, lte } from 'biggystring' import { asArray, asEither, @@ -16,6 +17,8 @@ import { EdgeSwapQuote, EdgeSwapRequest, EdgeTransaction, + SwapAboveLimitError, + SwapBelowLimitError, SwapCurrencyError } from 'edge-core-js/types' @@ -142,9 +145,16 @@ const asSwapFee = asObject({ amount: asString }) +const asAmountRestriction = asObject({ + min: asString, + max: asString, + type: asString // "EXCLUSIVE" +}) + const asSwapSimulationResult = asObject({ from: asToken, to: asToken, + amountRestriction: asOptional(asAmountRestriction), outputAmount: asString, outputAmountMin: asString, outputAmountUsd: asEither(asNumber, asNull), @@ -407,6 +417,19 @@ export function makeRangoPlugin(opts: EdgeCorePluginOptions): EdgeSwapPlugin { const { route, tx } = swap if (swap.resultType !== 'OK') { + if (swap.resultType === 'INPUT_LIMIT_ISSUE') { + const amountRestriction = swap.route?.amountRestriction + if (amountRestriction == null) { + throw new Error('Rango limit error without values') + } + const { min, max } = amountRestriction + + if (gte(nativeAmount, max)) { + throw new SwapAboveLimitError(swapInfo, max) + } else if (lte(nativeAmount, min)) { + throw new SwapBelowLimitError(swapInfo, min) + } + } throw new Error( `Rango could not proceed with the exchange. : ${swap.resultType}` ) From 6f7be5e04ecca6d5e602a1b86811d43764da27f7 Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 20 Jun 2024 10:03:04 -0400 Subject: [PATCH 3/3] Complete the MAINNET_CODE_TRANSCRIPTION chain list Some chains are disabled until we write code to support them but add them commented out anyway so we know their codes --- src/swap/defi/rango.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/swap/defi/rango.ts b/src/swap/defi/rango.ts index 7d390f47..e2807956 100644 --- a/src/swap/defi/rango.ts +++ b/src/swap/defi/rango.ts @@ -51,15 +51,29 @@ const EXCHANGE_INFO_UPDATE_FREQ_MS = 60000 const MAINNET_CODE_TRANSCRIPTION: StringMap = { arbitrum: 'ARBITRUM', + // axelar: 'AXELAR', avalanche: 'AVAX_CCHAIN', + base: 'BASE', binancesmartchain: 'BSC', + // bitcoin: 'BTC', + // celo: 'CELO', + // cosmoshub: 'COSMOS', + // dash: 'DASH', + // dogecoin: 'DOGE', ethereum: 'ETH', fantom: 'FANTOM', - moonbeam: 'MOONBEAM', - moonriver: 'MOONRIVER', - okexchain: 'OKC', + // injective: 'INJECTIVE', + // litecoin: 'LTC', + // maya: 'MAYA', + // moonbeam: 'MOONBEAM', + // moonriver: 'MOONRIVER', + // okexchain: 'OKC', optimism: 'OPTIMISM', + // osmosis: 'OSMOSIS', polygon: 'POLYGON', + // thorchainrune: 'THOR', + // solana: 'SOLANA', + // tron: 'TRON', zksync: 'ZKSYNC' }