From b7b0ab610e6e6c0463005e87e1fc7b13f3008ca9 Mon Sep 17 00:00:00 2001 From: Changelly Date: Fri, 27 Mar 2026 11:46:42 +0300 Subject: [PATCH 1/2] Feat: changelly integration Signed-off-by: Changelly --- scripts/mappings/changellyMappings.ts | 102 +++++ src/index.ts | 2 + src/mappings/changelly.ts | 101 +++++ src/swap/central/changelly.ts | 631 ++++++++++++++++++++++++++ 4 files changed, 836 insertions(+) create mode 100644 scripts/mappings/changellyMappings.ts create mode 100644 src/mappings/changelly.ts create mode 100644 src/swap/central/changelly.ts diff --git a/scripts/mappings/changellyMappings.ts b/scripts/mappings/changellyMappings.ts new file mode 100644 index 00000000..4faebd5c --- /dev/null +++ b/scripts/mappings/changellyMappings.ts @@ -0,0 +1,102 @@ +import { EdgeCurrencyPluginId } from '../../src/util/edgeCurrencyPluginIds' + +export const changelly = new Map() +changelly.set('ADA', 'cardano') + +changelly.set('ALGO', 'algorand') + +changelly.set('ARB', 'arbitrum') + +changelly.set('ARRR', 'piratechain') + +changelly.set('ATOM', 'cosmoshub') + +changelly.set('AVAXC', 'avalanche') + +changelly.set('BCH', 'bitcoincash') + +changelly.set('BCHSV', 'bitcoinsv') + +changelly.set('BSC', 'binancesmartchain') + +changelly.set('BTC', 'bitcoin') + +changelly.set('CELO', 'celo') + +changelly.set('DASH', 'dash') + +changelly.set('DGB', 'digibyte') + +changelly.set('DOGE', 'dogecoin') + +changelly.set('DOT', 'polkadot') + +changelly.set('ETC', 'ethereumclassic') + +changelly.set('ETH', 'ethereum') + +changelly.set('ETHW', 'ethereumpow') + +changelly.set('FIL', 'filecoin') + +changelly.set('FIO', 'fio') + +changelly.set('FIRO', 'zcoin') + +changelly.set('GRS', 'groestlcoin') + +changelly.set('HBAR', 'hedera') + +changelly.set('LTC', 'litecoin') + +changelly.set('MATIC', 'polygon') + +changelly.set('MON', 'monad') + +changelly.set('OPTIMISM', 'optimism') + +changelly.set('PIVX', 'pivx') + +changelly.set('QTUM', 'qtum') + +changelly.set('RVN', 'ravencoin') + +changelly.set('SOL', 'solana') + +changelly.set('TON', 'ton') + +changelly.set('TRX', 'tron') + +changelly.set('XEC', 'ecash') + +changelly.set('XLM', 'stellar') + +changelly.set('XMR', 'monero') + +changelly.set('XRP', 'ripple') + +changelly.set('XTZ', 'tezos') + +changelly.set('XVG', null) + +changelly.set('ZEC', 'zcash') + +changelly.set('base', 'base') + +changelly.set('coreum', 'coreum') + +changelly.set('hypeevm', 'hyperevm') + +changelly.set('osmo', 'osmosis') + +changelly.set('rootstock', 'rsk') + +changelly.set('rune', 'thorchainrune') + +changelly.set('sonic', 'sonic') + +changelly.set('sui', 'sui') + +changelly.set('wax', 'wax') + +changelly.set('zksync', 'zksync') diff --git a/src/index.ts b/src/index.ts index fb3c6c7d..88aa04ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import 'regenerator-runtime/runtime' import type { EdgeCorePlugins } from 'edge-core-js/types' import { makeChangeHeroPlugin } from './swap/central/changehero' +import makeChangellyPlugin from './swap/central/changelly' import { makeChangeNowPlugin } from './swap/central/changenow' import { makeExolixPlugin } from './swap/central/exolix' import { makeGodexPlugin } from './swap/central/godex' @@ -33,6 +34,7 @@ const plugins = { bridgeless: makeBridgelessPlugin, changehero: makeChangeHeroPlugin, changenow: makeChangeNowPlugin, + changelly: makeChangellyPlugin, cosmosibc: makeCosmosIbcPlugin, exolix: makeExolixPlugin, godex: makeGodexPlugin, diff --git a/src/mappings/changelly.ts b/src/mappings/changelly.ts new file mode 100644 index 00000000..0ab16e9d --- /dev/null +++ b/src/mappings/changelly.ts @@ -0,0 +1,101 @@ +/** + * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY + * + * This file is automatically generated from scripts/mappings/changellyMappings.ts + * To regenerate this file, run: yarn mapctl update-mappings + * + * To edit mappings: + * 1. Edit scripts/mappings/changellyMappings.ts + * 2. Run: yarn mapctl update-mappings + * + * This file maps EdgeCurrencyPluginId -> synchronizer network identifier (or null) + */ + +import { EdgeCurrencyPluginId } from '../util/edgeCurrencyPluginIds' + +export const changelly = new Map() +changelly.set('abstract', null) +changelly.set('algorand', 'ALGO') +changelly.set('amoy', null) +changelly.set('arbitrum', 'ARB') +changelly.set('avalanche', 'AVAXC') +changelly.set('axelar', null) +changelly.set('badcoin', null) +changelly.set('base', 'base') +changelly.set('binance', null) +changelly.set('binancesmartchain', 'BSC') +changelly.set('bitcoin', 'BTC') +changelly.set('bitcoincash', 'BCH') +changelly.set('bitcoincashtestnet', null) +changelly.set('bitcoingold', null) +changelly.set('bitcoingoldtestnet', null) +changelly.set('bitcoinsv', 'BCHSV') +changelly.set('bitcointestnet', null) +changelly.set('bitcointestnet4', null) +changelly.set('bobevm', null) +changelly.set('botanix', null) +changelly.set('calibration', null) +changelly.set('cardano', 'ADA') +changelly.set('cardanotestnet', null) +changelly.set('celo', 'CELO') +changelly.set('coreum', 'coreum') +changelly.set('cosmoshub', 'ATOM') +changelly.set('dash', 'DASH') +changelly.set('digibyte', 'DGB') +changelly.set('dogecoin', 'DOGE') +changelly.set('eboost', null) +changelly.set('ecash', 'XEC') +changelly.set('eos', null) +changelly.set('ethDev', null) +changelly.set('ethereum', 'ETH') +changelly.set('ethereumclassic', 'ETC') +changelly.set('ethereumpow', 'ETHW') +changelly.set('fantom', null) +changelly.set('feathercoin', null) +changelly.set('filecoin', 'FIL') +changelly.set('filecoinfevm', null) +changelly.set('filecoinfevmcalibration', null) +changelly.set('fio', 'FIO') +changelly.set('groestlcoin', 'GRS') +changelly.set('hedera', 'HBAR') +changelly.set('holesky', null) +changelly.set('hyperevm', 'hypeevm') +changelly.set('liberland', null) +changelly.set('liberlandtestnet', null) +changelly.set('litecoin', 'LTC') +changelly.set('mayachain', null) +changelly.set('monad', 'MON') +changelly.set('monero', 'XMR') +changelly.set('nym', null) +changelly.set('opbnb', null) +changelly.set('optimism', 'OPTIMISM') +changelly.set('osmosis', 'osmo') +changelly.set('piratechain', 'ARRR') +changelly.set('pivx', 'PIVX') +changelly.set('polkadot', 'DOT') +changelly.set('polygon', 'MATIC') +changelly.set('pulsechain', null) +changelly.set('qtum', 'QTUM') +changelly.set('ravencoin', 'RVN') +changelly.set('ripple', 'XRP') +changelly.set('rsk', 'rootstock') +changelly.set('sepolia', null) +changelly.set('smartcash', null) +changelly.set('solana', 'SOL') +changelly.set('sonic', 'sonic') +changelly.set('stellar', 'XLM') +changelly.set('sui', 'sui') +changelly.set('suitestnet', null) +changelly.set('telos', null) +changelly.set('tezos', 'XTZ') +changelly.set('thorchainrune', 'rune') +changelly.set('thorchainrunestagenet', null) +changelly.set('ton', 'TON') +changelly.set('tron', 'TRX') +changelly.set('ufo', null) +changelly.set('vertcoin', null) +changelly.set('wax', 'wax') +changelly.set('zano', null) +changelly.set('zcash', 'ZEC') +changelly.set('zcoin', 'FIRO') +changelly.set('zksync', 'zksync') diff --git a/src/swap/central/changelly.ts b/src/swap/central/changelly.ts new file mode 100644 index 00000000..c58cf0bd --- /dev/null +++ b/src/swap/central/changelly.ts @@ -0,0 +1,631 @@ +import { + EdgeCorePluginOptions, + EdgeFetchFunction, + EdgeMemo, + EdgeSpendInfo, + EdgeSwapInfo, + EdgeSwapPlugin, + EdgeSwapQuote, + EdgeSwapRequest, + SwapAboveLimitError, + SwapBelowLimitError, + SwapCurrencyError, + SwapPermissionError +} from 'edge-core-js/types' + +import { changelly as changellyMapping } from '../../mappings/changelly' +import { + checkInvalidTokenIds, + checkWhitelistedMainnetCodes, + CurrencyPluginIdSwapChainCodeMap, + getCodesWithTranscription, + getMaxSwappable, + InvalidTokenIds, + makeSwapPluginQuote, + mapToRecord, + SwapOrder +} from '../../util/swapHelpers' +import { + convertRequest, + denominationToNative, + getAddress, + memoType, + nativeToDenomination +} from '../../util/utils' +import { EdgeSwapRequestPlugin, StringMap } from '../types' + +const pluginId = 'changelly' + +const CHANGELLY_V2_URL = 'https://api-relay.changelly.com/'; + +const expirationFixedMs = 1000 * 60 + +const INVALID_TOKEN_IDS: InvalidTokenIds = { + from: {}, + to: {} +} + +const addressTypeMap: StringMap = { + zcash: 'transparentAddress' +} + +export const MAINNET_CODE_TRANSCRIPTION: CurrencyPluginIdSwapChainCodeMap = + mapToRecord(changellyMapping) + +// // Unused for now +// const CHANGELLY_STATUS_MAP: { [status: string]: string } = { +// waiting: 'pending', +// confirming: 'processing', +// exchanging: 'processing', +// sending: 'processing', +// finished: 'completed', +// failed: 'failed', +// refunded: 'failed', +// expired: 'expired' +// } + +// #region Utility function +const isError = function ( + data: ErrorResult | Result +): data is ErrorResult { + return 'error' in data +} +// #endregion + +// #region Utility types +interface RPCWrapper { + jsonrpc: '2.0' + id: string +} + +type Body = RPCWrapper & { + method: string + params?: T +} + +type Result = RPCWrapper & { + result: R +} + +type ErrorResult = RPCWrapper & { + error: { + code: number + message: string + data?: any + } +} +// #endregion + +// #region Internal changelly types +type CurrenciesRequest = undefined +interface CurrenciesResponse { + name: string + ticker: string + enabled: boolean + fixRateEnabled: boolean + transactionUrl: string + protocol: string + blockchain: string + contractAddress: string +} + +interface EstimationRequest { + from: string + to: string + amountFrom: string +} + +type FixEstimationRequest = EstimationRequest & + ( + | { + amountFrom: string + } + | { + amountTo: string + } + ) + +interface EstimationResponse { + from: string + to: string + networkFee: string + amountFrom: string + amountTo: string + max: string + maxFrom: string + maxTo: string + min: string + minFrom: string + minTo: string + visibleAmount: string + rate: string + fee: string +} + +type FixedEstimationResponse = EstimationResponse & { + id: string + result: string +} + +interface StatusRequest { + id: string +} + +type StatusResponse = string + +interface TransactionRequest { + id?: string | string[] +} + +interface TransactionResponse { + id: string + payoutHashLink: string + refundHashLink: string +} + +interface CreateFixTransactionRequest { + from: string + to: string + rateId: string + address: string + extraId?: string + amountFrom?: string + amountTo?: string + refundAddress?: string + refundExtraId?: string + fromAddress?: string + fromExtraId?: string + userMetadata?: string +} + +interface CreateFixTransactionResponse { + id: string + trackUrl: string; + type: string + payinAddress: string + payinExtraId: string + payoutAddress: string + payoutExtraId: string + refundAddress: string + refundExtraId: string + amountExpectedFrom: string + amountExpectedTo: string + status: string + payTill: string + currencyTo: string + currencyFrom: string + createdAt: number + networkFee: string +} +// #endregion + +interface ChangellyClient { + getCurrenciesFull: () => Promise< + Result | ErrorResult + > + getExchangeAmount: ( + params: EstimationRequest, + method?: string + ) => Promise | ErrorResult> + getFixRateForAmount: ( + params: FixEstimationRequest + ) => Promise | ErrorResult> + getStatus: ( + params: StatusRequest + ) => Promise | ErrorResult> + getTransactions: ( + params: TransactionRequest + ) => Promise | ErrorResult> + createTransaction: ( + params: CreateFixTransactionRequest, + method?: string + ) => Promise | ErrorResult> +} + +function createClient( + fetch: EdgeFetchFunction, + disklet: { list: (path: string) => Promise>; getText: (path: string) => Promise } +): ChangellyClient { + const getUserParams = ((disklet) => async (ts = Date.now()) => { + const loginItems = Object.entries(await disklet.list('logins')) + .filter((listing): listing is [string, 'file'] => listing[1] === 'file') + // eslint-disable-next-line @typescript-eslint/promise-function-async + .map(async ([name]) => await disklet.getText(name)) + + const profiles = (await Promise.all(loginItems)) + .map((item) => JSON.parse(item)) + .sort((a, b) => { + return ( + new Date(b.lastLogin).getTime() - new Date(a.lastLogin).getTime() + ) + }) + + if (profiles.length === 0) throw new Error('Unable to detect user params') + + return { + userId: profiles[0].userId, + username: profiles[0].username, + ts + } + })(disklet) + + const changellyClientRequest = async < + T extends Object | undefined, + R = any + >( + body: Omit, 'jsonrpc' | 'id'>, + promoCode?: string, + ignoreUserInfo = false + ): Promise | ErrorResult> => { + const params = ignoreUserInfo + ? { + userId: 'BVEeyRKyD1g6awUBc+EJxsFRI9irdsAe5O6lWsZRsxg=', + username: 'test', + ts: Date.now() + } + : await getUserParams() + const jsonBody = JSON.stringify({ + ...body, + jsonrpc: '2.0', + id: body.method + ':' + String(params.userId) + }) + + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Auth': btoa([params.username, params.userId, params.ts].join(':')) + } + + const response = await fetch(CHANGELLY_V2_URL, { + method: 'POST', + body: jsonBody, + headers + }) + + const result = await response.json() + return result as Result | ErrorResult + } + + return { + getCurrenciesFull: async function < + T extends CurrenciesRequest = CurrenciesRequest, + R extends CurrenciesResponse[] = CurrenciesResponse[] + >(): ReturnType< + // prettier can't parse typeof w/ generic type arguments + // eslint-disable-next-line prettier/prettier + typeof changellyClientRequest + > { + return await changellyClientRequest( + { method: 'getCurrenciesFull' }, + undefined, + true + ) + }, + + getExchangeAmount: async function < + T extends EstimationRequest = EstimationRequest, + R extends EstimationResponse[] = EstimationResponse[] + >( + params: T, + method = 'getExchangeAmount' + ): ReturnType> { + return await changellyClientRequest({ method, params }) + }, + + getFixRateForAmount: async function < + T extends FixEstimationRequest = FixEstimationRequest, + R extends FixedEstimationResponse[] = FixedEstimationResponse[] + >( + params: T + ): ReturnType> { + return await changellyClientRequest({ + method: 'getFixRateForAmount', + params + }) + }, + + getStatus: async function < + T extends StatusRequest = StatusRequest, + R extends StatusResponse = StatusResponse + >(params: T): ReturnType> { + return await changellyClientRequest({ method: 'getStatus', params }) + }, + + getTransactions: async function < + T extends TransactionRequest = TransactionRequest, + R extends TransactionResponse[] = TransactionResponse[] + >(params: T): ReturnType> { + return await changellyClientRequest({ method: 'getTransactions', params }) + }, + + createTransaction: async function < + T extends CreateFixTransactionRequest = CreateFixTransactionRequest, + R extends CreateFixTransactionResponse = CreateFixTransactionResponse + >( + params: T, + method = 'createFixTransaction' + ): ReturnType> { + return await changellyClientRequest({ method, params }) + } + } +} + +const CACHE_TTL_MS = 1000 * 60 * 10 + +const cache = { + map: new Map(), + time: 0, + pending: undefined as Promise | undefined +} + +const pluginFactory = ({ + log, + ...env +}: EdgeCorePluginOptions): EdgeSwapPlugin => { + const { io } = env + const { fetch, disklet } = io + const client = createClient(fetch, disklet) + + const swapInfo: EdgeSwapInfo = { + pluginId, + isDex: false, + displayName: 'Changelly', + supportEmail: 'support@changelly.com' + } + + const refreshCache = async (): Promise => { + if (Date.now() - cache.time < CACHE_TTL_MS) return await Promise.resolve() + if (cache.pending != null) return await cache.pending + + cache.pending = client + .getCurrenciesFull() + .then((data) => { + if (isError(data) && data.error.code === -32600) { + throw new SwapPermissionError(swapInfo, 'noVerification') + } + if (isError(data)) { + throw new Error('Currencies result cannot be processed') + } + + const newMap = new Map() + data.result.forEach((item) => { + if (!item.enabled) return + newMap.set(item.name, item) + newMap.set(item.ticker, item) + }) + cache.map = newMap + cache.time = Date.now() + }) + .finally(() => { + cache.pending = undefined + }) + + return await cache.pending + } + + refreshCache().catch((e) => { + log.warn('Changelly: Error refreshing cache', e) + }) + + const fetchSwapQuoteInner = async ( + request: EdgeSwapRequestPlugin, + opts: { promoCode?: string } + ): Promise => { + const reverseQuote = request.quoteFor === 'to' + + const [fromAddress, toAddress] = await Promise.all([ + getAddress( + request.fromWallet, + addressTypeMap[request.fromWallet.currencyInfo.pluginId] + ), + getAddress( + request.toWallet, + addressTypeMap[request.toWallet.currencyInfo.pluginId] + ) + ]) + + const { + fromCurrencyCode, + toCurrencyCode + } = getCodesWithTranscription(request, MAINNET_CODE_TRANSCRIPTION) + + const fromTicker = + cache.map.get(fromCurrencyCode)?.ticker ?? fromCurrencyCode.toLowerCase() + const toTicker = + cache.map.get(toCurrencyCode)?.ticker ?? toCurrencyCode.toLowerCase() + + const quoteAmount = reverseQuote + ? nativeToDenomination( + request.toWallet, + request.nativeAmount, + request.toTokenId + ) + : nativeToDenomination( + request.fromWallet, + request.nativeAmount, + request.fromTokenId + ) + + const fixRateParams: any = { + from: fromTicker, + to: toTicker + } + if (reverseQuote) { + fixRateParams.amountTo = quoteAmount + } else { + fixRateParams.amountFrom = quoteAmount + } + + const rateResponse = await client.getFixRateForAmount(fixRateParams) + + if (isError(rateResponse)) { + const { message: msg, data: errorData } = rateResponse.error + if (msg.startsWith('Invalid amount')) { + const limits = errorData?.limits + if (limits != null) { + const wallet = reverseQuote ? request.toWallet : request.fromWallet + const tokenId = reverseQuote + ? request.toTokenId + : request.fromTokenId + const direction = reverseQuote ? 'to' : undefined + + if (msg.includes('Minimal')) { + const minFrom = limits.min?.from + if (minFrom != null) { + const minNativeAmount = denominationToNative( + wallet, + minFrom, + tokenId + ) + throw new SwapBelowLimitError( + swapInfo, + minNativeAmount, + direction + ) + } + } + if (msg.includes('Maximum')) { + const maxFrom = limits.max?.from + if (maxFrom != null) { + const maxNativeAmount = denominationToNative( + wallet, + maxFrom, + tokenId + ) + throw new SwapAboveLimitError( + swapInfo, + maxNativeAmount, + direction + ) + } + } + } + } + throw new SwapCurrencyError(swapInfo, request) + } + + const rateResult = rateResponse.result + if (rateResult == null || rateResult.length === 0) { + throw new SwapCurrencyError(swapInfo, request) + } + + const rateId = rateResult[0].id + + const txParams: CreateFixTransactionRequest = { + from: fromTicker, + to: toTicker, + rateId, + address: toAddress, + refundAddress: fromAddress + } + if (reverseQuote) { + txParams.amountTo = quoteAmount + } else { + txParams.amountFrom = quoteAmount + } + + const txResponse = await client.createTransaction(txParams) + + if (isError(txResponse)) { + throw new SwapCurrencyError(swapInfo, request) + } + + const txResult = txResponse.result + + const fromNativeAmount = denominationToNative( + request.fromWallet, + txResult.amountExpectedFrom, + request.fromTokenId + ).split('.')[0] + + const toNativeAmount = denominationToNative( + request.toWallet, + txResult.amountExpectedTo, + request.toTokenId + ).split('.')[0] + + const memos: EdgeMemo[] = + txResult.payinExtraId == null || txResult.payinExtraId === '' + ? [] + : [ + { + type: memoType(request.fromWallet.currencyInfo.pluginId), + value: txResult.payinExtraId + } + ] + + const spendInfo: EdgeSpendInfo = { + tokenId: request.fromTokenId, + spendTargets: [ + { + nativeAmount: fromNativeAmount, + publicAddress: txResult.payinAddress + } + ], + memos, + networkFeeOption: 'high', + assetAction: { + assetActionType: 'swap' + }, + savedAction: { + actionType: 'swap', + swapInfo, + orderId: txResult.id, + orderUri: txResult.trackUrl, + isEstimate: false, + toAsset: { + pluginId: request.toWallet.currencyInfo.pluginId, + tokenId: request.toTokenId, + nativeAmount: toNativeAmount + }, + fromAsset: { + pluginId: request.fromWallet.currencyInfo.pluginId, + tokenId: request.fromTokenId, + nativeAmount: fromNativeAmount + }, + payoutAddress: toAddress, + payoutWalletId: request.toWallet.id, + refundAddress: fromAddress + } + } + + return { + request, + spendInfo, + swapInfo, + fromNativeAmount, + expirationDate: new Date(Date.now() + expirationFixedMs) + } + } + + const out: EdgeSwapPlugin = { + swapInfo, + + async fetchSwapQuote( + req: EdgeSwapRequest, + userSettings: Object | undefined, + opts: { promoCode?: string } + ): Promise { + const request = convertRequest(req) + + await refreshCache() + + checkInvalidTokenIds(INVALID_TOKEN_IDS, request, swapInfo) + checkWhitelistedMainnetCodes( + MAINNET_CODE_TRANSCRIPTION, + request, + swapInfo + ) + + const newRequest = await getMaxSwappable( + fetchSwapQuoteInner, + request, + opts + ) + const swapOrder = await fetchSwapQuoteInner(newRequest, opts) + return await makeSwapPluginQuote(swapOrder) + } + } + return out +} + +export default pluginFactory From 9386c73fe21983f3ebdd7a9b85f51997cfa5e5ef Mon Sep 17 00:00:00 2001 From: Changelly Date: Fri, 27 Mar 2026 11:48:20 +0300 Subject: [PATCH 2/2] add CHANGELOG.md entry Signed-off-by: Changelly --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f53b1a09..f1332b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: changelly support + ## 2.43.0 (2026-03-10) - added: Xgram support