|
| 1 | +import { |
| 2 | + asArray, |
| 3 | + asEither, |
| 4 | + asMaybe, |
| 5 | + asNull, |
| 6 | + asNumber, |
| 7 | + asObject, |
| 8 | + asOptional, |
| 9 | + asString, |
| 10 | + asUnknown, |
| 11 | + asValue |
| 12 | +} from 'cleaners' |
| 13 | + |
| 14 | +import { |
| 15 | + PartnerPlugin, |
| 16 | + PluginParams, |
| 17 | + PluginResult, |
| 18 | + StandardTx, |
| 19 | + Status |
| 20 | +} from '../types' |
| 21 | +import { retryFetch } from '../util' |
| 22 | +import { EVM_CHAIN_IDS } from '../util/chainIds' |
| 23 | + |
| 24 | +// Start date for Rango transactions (first Edge transaction was 2024-06-23) |
| 25 | +const RANGO_START_DATE = '2024-06-01T00:00:00.000Z' |
| 26 | + |
| 27 | +const asRangoPluginParams = asObject({ |
| 28 | + settings: asObject({ |
| 29 | + latestIsoDate: asOptional(asString, RANGO_START_DATE) |
| 30 | + }), |
| 31 | + apiKeys: asObject({ |
| 32 | + apiKey: asOptional(asString), |
| 33 | + secret: asOptional(asString) |
| 34 | + }) |
| 35 | +}) |
| 36 | + |
| 37 | +const asRangoStatus = asMaybe( |
| 38 | + asValue('success', 'failed', 'running', 'pending'), |
| 39 | + 'other' |
| 40 | +) |
| 41 | + |
| 42 | +const asBlockchainData = asObject({ |
| 43 | + blockchain: asString, |
| 44 | + type: asOptional(asString), |
| 45 | + displayName: asOptional(asString) |
| 46 | +}) |
| 47 | + |
| 48 | +const asToken = asObject({ |
| 49 | + blockchainData: asBlockchainData, |
| 50 | + symbol: asString, |
| 51 | + address: asOptional(asEither(asString, asNull)), |
| 52 | + decimals: asNumber, |
| 53 | + expectedAmount: asOptional(asNumber), |
| 54 | + realAmount: asOptional(asNumber) |
| 55 | +}) |
| 56 | + |
| 57 | +const asStepSummary = asObject({ |
| 58 | + swapper: asObject({ |
| 59 | + swapperId: asString, |
| 60 | + swapperTitle: asOptional(asString) |
| 61 | + }), |
| 62 | + fromToken: asToken, |
| 63 | + toToken: asToken, |
| 64 | + status: asRangoStatus, |
| 65 | + stepNumber: asNumber, |
| 66 | + sender: asOptional(asString), |
| 67 | + recipient: asOptional(asString), |
| 68 | + affiliates: asOptional(asArray(asUnknown)) |
| 69 | +}) |
| 70 | + |
| 71 | +const asRangoTx = asObject({ |
| 72 | + requestId: asString, |
| 73 | + transactionTime: asString, |
| 74 | + status: asRangoStatus, |
| 75 | + stepsSummary: asArray(asStepSummary), |
| 76 | + feeUsd: asOptional(asNumber), |
| 77 | + referrerCode: asOptional(asString) |
| 78 | +}) |
| 79 | + |
| 80 | +const asRangoResult = asObject({ |
| 81 | + page: asOptional(asNumber), |
| 82 | + offset: asOptional(asNumber), |
| 83 | + total: asNumber, |
| 84 | + transactions: asArray(asUnknown) |
| 85 | +}) |
| 86 | + |
| 87 | +const PAGE_LIMIT = 20 // API max is 20 per page |
| 88 | +const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 3 // 3 days |
| 89 | + |
| 90 | +type RangoTx = ReturnType<typeof asRangoTx> |
| 91 | +type RangoStatus = ReturnType<typeof asRangoStatus> |
| 92 | + |
| 93 | +const statusMap: { [key in RangoStatus]: Status } = { |
| 94 | + success: 'complete', |
| 95 | + failed: 'failed', |
| 96 | + running: 'processing', |
| 97 | + pending: 'pending', |
| 98 | + other: 'other' |
| 99 | +} |
| 100 | + |
| 101 | +// Map Rango blockchain names to Edge pluginIds |
| 102 | +const RANGO_BLOCKCHAIN_TO_PLUGIN_ID: Record<string, string> = { |
| 103 | + ARBITRUM: 'arbitrum', |
| 104 | + AVAX_CCHAIN: 'avalanche', |
| 105 | + BASE: 'base', |
| 106 | + BCH: 'bitcoincash', |
| 107 | + BINANCE: 'binance', |
| 108 | + BSC: 'binancesmartchain', |
| 109 | + BTC: 'bitcoin', |
| 110 | + CELO: 'celo', |
| 111 | + COSMOS: 'cosmoshub', |
| 112 | + DOGE: 'dogecoin', |
| 113 | + ETH: 'ethereum', |
| 114 | + FANTOM: 'fantom', |
| 115 | + LTC: 'litecoin', |
| 116 | + MATIC: 'polygon', |
| 117 | + OPTIMISM: 'optimism', |
| 118 | + OSMOSIS: 'osmosis', |
| 119 | + POLYGON: 'polygon', |
| 120 | + SOLANA: 'solana', |
| 121 | + TRON: 'tron', |
| 122 | + ZKSYNC: 'zksync' |
| 123 | +} |
| 124 | + |
| 125 | +export async function queryRango( |
| 126 | + pluginParams: PluginParams |
| 127 | +): Promise<PluginResult> { |
| 128 | + const { log } = pluginParams |
| 129 | + const { settings, apiKeys } = asRangoPluginParams(pluginParams) |
| 130 | + const { apiKey, secret } = apiKeys |
| 131 | + let { latestIsoDate } = settings |
| 132 | + |
| 133 | + if (apiKey == null || secret == null) { |
| 134 | + return { settings: { latestIsoDate }, transactions: [] } |
| 135 | + } |
| 136 | + |
| 137 | + const standardTxs: StandardTx[] = [] |
| 138 | + let startMs = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK |
| 139 | + if (startMs < 0) startMs = 0 |
| 140 | + |
| 141 | + let done = false |
| 142 | + let page = 1 |
| 143 | + |
| 144 | + try { |
| 145 | + while (!done) { |
| 146 | + // API: https://api-docs.rango.exchange/reference/filtertransactions |
| 147 | + // Endpoint: GET https://api.rango.exchange/scanner/tx/filter |
| 148 | + // Auth: apiKey and token (secret) in query params |
| 149 | + // Date range: start/end in milliseconds |
| 150 | + const queryParams = new URLSearchParams({ |
| 151 | + apiKey, |
| 152 | + token: secret, |
| 153 | + limit: String(PAGE_LIMIT), |
| 154 | + page: String(page), |
| 155 | + order: 'asc', // Oldest to newest |
| 156 | + start: String(startMs) |
| 157 | + }) |
| 158 | + |
| 159 | + const request = `https://api.rango.exchange/scanner/tx/filter?${queryParams.toString()}` |
| 160 | + |
| 161 | + const response = await retryFetch(request, { |
| 162 | + method: 'GET', |
| 163 | + headers: { 'Content-Type': 'application/json' } |
| 164 | + }) |
| 165 | + |
| 166 | + if (!response.ok) { |
| 167 | + const text = await response.text() |
| 168 | + throw new Error(`Rango API error ${response.status}: ${text}`) |
| 169 | + } |
| 170 | + |
| 171 | + const json = await response.json() |
| 172 | + const result = asRangoResult(json) |
| 173 | + |
| 174 | + const txs = result.transactions |
| 175 | + let processedCount = 0 |
| 176 | + |
| 177 | + for (const rawTx of txs) { |
| 178 | + try { |
| 179 | + const standardTx = processRangoTx(rawTx, pluginParams) |
| 180 | + standardTxs.push(standardTx) |
| 181 | + processedCount++ |
| 182 | + |
| 183 | + if (standardTx.isoDate > latestIsoDate) { |
| 184 | + latestIsoDate = standardTx.isoDate |
| 185 | + } |
| 186 | + } catch (e) { |
| 187 | + // Log but continue processing other transactions |
| 188 | + log.warn(`Failed to process tx: ${String(e)}`) |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + const currentOffset = (page - 1) * PAGE_LIMIT + txs.length |
| 193 | + log( |
| 194 | + `Page ${page} (offset ${currentOffset}/${result.total}): processed ${processedCount}, latestIsoDate ${latestIsoDate}` |
| 195 | + ) |
| 196 | + |
| 197 | + page++ |
| 198 | + |
| 199 | + // Reached end of results |
| 200 | + if (txs.length < PAGE_LIMIT || currentOffset >= result.total) { |
| 201 | + done = true |
| 202 | + } |
| 203 | + } |
| 204 | + } catch (e) { |
| 205 | + log.error(String(e)) |
| 206 | + // Do not throw - save progress since we query from oldest to newest |
| 207 | + // This ensures we don't lose transactions on transient failures |
| 208 | + } |
| 209 | + |
| 210 | + const out: PluginResult = { |
| 211 | + settings: { latestIsoDate }, |
| 212 | + transactions: standardTxs |
| 213 | + } |
| 214 | + return out |
| 215 | +} |
| 216 | + |
| 217 | +export const rango: PartnerPlugin = { |
| 218 | + queryFunc: queryRango, |
| 219 | + pluginName: 'Rango', |
| 220 | + pluginId: 'rango' |
| 221 | +} |
| 222 | + |
| 223 | +export function processRangoTx( |
| 224 | + rawTx: unknown, |
| 225 | + pluginParams: PluginParams |
| 226 | +): StandardTx { |
| 227 | + const { log } = pluginParams |
| 228 | + const tx: RangoTx = asRangoTx(rawTx) |
| 229 | + |
| 230 | + // Parse the ISO date string (e.g., "2025-12-24T15:43:46.926+00:00") |
| 231 | + const date = new Date(tx.transactionTime) |
| 232 | + const timestamp = Math.floor(date.getTime() / 1000) |
| 233 | + const isoDate = date.toISOString() |
| 234 | + |
| 235 | + // Get first and last steps for deposit/payout info |
| 236 | + const firstStep = tx.stepsSummary[0] |
| 237 | + const lastStep = tx.stepsSummary[tx.stepsSummary.length - 1] |
| 238 | + |
| 239 | + if (firstStep == null || lastStep == null) { |
| 240 | + throw new Error(`Transaction ${tx.requestId} has no steps`) |
| 241 | + } |
| 242 | + |
| 243 | + // Deposit info from first step |
| 244 | + const depositBlockchain = firstStep.fromToken.blockchainData.blockchain |
| 245 | + const depositChainPluginId = RANGO_BLOCKCHAIN_TO_PLUGIN_ID[depositBlockchain] |
| 246 | + if (depositChainPluginId == null) { |
| 247 | + throw new Error( |
| 248 | + `Unknown Rango blockchain "${depositBlockchain}". Add mapping to RANGO_BLOCKCHAIN_TO_PLUGIN_ID.` |
| 249 | + ) |
| 250 | + } |
| 251 | + const depositEvmChainId = EVM_CHAIN_IDS[depositChainPluginId] |
| 252 | + |
| 253 | + // Payout info from last step |
| 254 | + const payoutBlockchain = lastStep.toToken.blockchainData.blockchain |
| 255 | + const payoutChainPluginId = RANGO_BLOCKCHAIN_TO_PLUGIN_ID[payoutBlockchain] |
| 256 | + if (payoutChainPluginId == null) { |
| 257 | + throw new Error( |
| 258 | + `Unknown Rango blockchain "${payoutBlockchain}". Add mapping to RANGO_BLOCKCHAIN_TO_PLUGIN_ID.` |
| 259 | + ) |
| 260 | + } |
| 261 | + const payoutEvmChainId = EVM_CHAIN_IDS[payoutChainPluginId] |
| 262 | + |
| 263 | + // Get amounts - prefer realAmount, fall back to expectedAmount |
| 264 | + const depositAmount = |
| 265 | + firstStep.fromToken.realAmount ?? firstStep.fromToken.expectedAmount ?? 0 |
| 266 | + const payoutAmount = |
| 267 | + lastStep.toToken.realAmount ?? lastStep.toToken.expectedAmount ?? 0 |
| 268 | + |
| 269 | + const dateStr = isoDate.split('T')[0] |
| 270 | + const depositCurrency = firstStep.fromToken.symbol |
| 271 | + const depositTokenId = firstStep.fromToken.address ?? null |
| 272 | + const payoutCurrency = lastStep.toToken.symbol |
| 273 | + const payoutTokenId = lastStep.toToken.address ?? null |
| 274 | + |
| 275 | + log( |
| 276 | + `${dateStr} ${depositCurrency} ${depositAmount} ${depositChainPluginId}${ |
| 277 | + depositTokenId != null ? ` ${depositTokenId}` : '' |
| 278 | + } -> ${payoutCurrency} ${payoutAmount} ${payoutChainPluginId}${ |
| 279 | + payoutTokenId != null ? ` ${payoutTokenId}` : '' |
| 280 | + }` |
| 281 | + ) |
| 282 | + |
| 283 | + const standardTx: StandardTx = { |
| 284 | + status: statusMap[tx.status], |
| 285 | + orderId: tx.requestId, |
| 286 | + countryCode: null, |
| 287 | + depositTxid: undefined, |
| 288 | + depositAddress: firstStep.sender, |
| 289 | + depositCurrency: firstStep.fromToken.symbol, |
| 290 | + depositChainPluginId, |
| 291 | + depositEvmChainId, |
| 292 | + depositTokenId, |
| 293 | + depositAmount, |
| 294 | + direction: null, |
| 295 | + exchangeType: 'swap', |
| 296 | + paymentType: null, |
| 297 | + payoutTxid: undefined, |
| 298 | + payoutAddress: lastStep.recipient, |
| 299 | + payoutCurrency: lastStep.toToken.symbol, |
| 300 | + payoutChainPluginId, |
| 301 | + payoutEvmChainId, |
| 302 | + payoutTokenId: lastStep.toToken.address ?? null, |
| 303 | + payoutAmount, |
| 304 | + timestamp, |
| 305 | + isoDate, |
| 306 | + usdValue: -1, |
| 307 | + rawTx |
| 308 | + } |
| 309 | + |
| 310 | + return standardTx |
| 311 | +} |
0 commit comments