Skip to content

Commit 2f72472

Browse files
committed
Add rango query plugin
1 parent 913a852 commit 2f72472

File tree

3 files changed

+317
-0
lines changed

3 files changed

+317
-0
lines changed

src/demo/partners.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ export default {
105105
type: 'fiat',
106106
color: '#99A5DE'
107107
},
108+
rango: {
109+
type: 'swap',
110+
color: '#5891EE'
111+
},
108112
safello: {
109113
type: 'fiat',
110114
color: deprecated

src/partners/rango.ts

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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+
}

src/queryEngine.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { lifi } from './partners/lifi'
2424
import { moonpay } from './partners/moonpay'
2525
import { paybis } from './partners/paybis'
2626
import { paytrie } from './partners/paytrie'
27+
import { rango } from './partners/rango'
2728
import { safello } from './partners/safello'
2829
import { sideshift } from './partners/sideshift'
2930
import { simplex } from './partners/simplex'
@@ -76,6 +77,7 @@ const plugins = [
7677
moonpay,
7778
paybis,
7879
paytrie,
80+
rango,
7981
safello,
8082
sideshift,
8183
simplex,

0 commit comments

Comments
 (0)