Skip to content

Commit c36f50f

Browse files
MMrj9paullinator
authored andcommitted
Feat: nexchange plugin
1 parent 7c5f13e commit c36f50f

3 files changed

Lines changed: 285 additions & 0 deletions

File tree

src/partners/nexchange.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import {
2+
asArray,
3+
asBoolean,
4+
asEither,
5+
asNull,
6+
asObject,
7+
asOptional,
8+
asString,
9+
asUnknown,
10+
asValue
11+
} from 'cleaners'
12+
13+
import {
14+
EDGE_APP_START_DATE,
15+
PartnerPlugin,
16+
PluginParams,
17+
PluginResult,
18+
StandardTx,
19+
Status
20+
} from '../types'
21+
import { datelog, retryFetch, safeParseFloat, snooze } from '../util'
22+
23+
const asNexchangePluginParams = asObject({
24+
settings: asObject({
25+
latestIsoDate: asOptional(asString, EDGE_APP_START_DATE)
26+
}),
27+
apiKeys: asObject({
28+
apiKey: asOptional(asString),
29+
baseUrl: asOptional(asString, 'https://api.n.exchange/en/api/v1'),
30+
authMode: asOptional(asValue('x-api-key', 'authorization', 'both'), 'both')
31+
})
32+
})
33+
34+
const asNexchangeTransfer = asObject({
35+
currency: asString,
36+
amount: asString,
37+
address: asOptional(asEither(asString, asNull), null),
38+
txid: asOptional(asEither(asString, asNull), null)
39+
})
40+
41+
const asNexchangeOrder = asObject({
42+
orderId: asString,
43+
status: asString,
44+
createdAt: asString,
45+
deposit: asNexchangeTransfer,
46+
payout: asNexchangeTransfer,
47+
countryCode: asOptional(asEither(asString, asNull), null)
48+
})
49+
50+
const asNexchangeOrdersResponse = asObject({
51+
orders: asArray(asUnknown),
52+
nextCursor: asOptional(asEither(asString, asNull), null),
53+
hasMore: asBoolean
54+
})
55+
56+
type NexchangeAuthMode = 'x-api-key' | 'authorization' | 'both'
57+
58+
const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days
59+
const MAX_RETRIES = 5
60+
const LIMIT = 200
61+
62+
const statusMap: { [key: string]: Status } = {
63+
released: 'complete',
64+
complete: 'complete',
65+
completed: 'complete',
66+
done: 'complete',
67+
processing: 'processing',
68+
exchanging: 'processing',
69+
confirming: 'processing',
70+
waiting: 'pending',
71+
pending: 'pending',
72+
created: 'pending',
73+
new: 'pending',
74+
expired: 'expired',
75+
blocked: 'blocked',
76+
refund: 'refunded',
77+
refunded: 'refunded',
78+
cancelled: 'other',
79+
canceled: 'other',
80+
failed: 'other'
81+
}
82+
83+
function toQueryIsoDate(latestIsoDate: string): string {
84+
let previousTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK
85+
if (previousTimestamp < 0) previousTimestamp = 0
86+
return new Date(previousTimestamp).toISOString()
87+
}
88+
89+
function parseApiDate(dateString: string): { isoDate: string; timestamp: number } {
90+
const hasTimezone = /(Z|[+-]\d{2}:\d{2})$/.test(dateString)
91+
const normalized = hasTimezone ? dateString : `${dateString}Z`
92+
const date = new Date(normalized)
93+
if (isNaN(date.getTime())) {
94+
throw new Error(`Invalid createdAt date: ${dateString}`)
95+
}
96+
return {
97+
isoDate: date.toISOString(),
98+
timestamp: date.getTime() / 1000
99+
}
100+
}
101+
102+
export function makeNexchangeHeaders(
103+
apiKey: string,
104+
authMode: NexchangeAuthMode
105+
): Record<string, string> {
106+
const headers: Record<string, string> = {}
107+
if (authMode === 'x-api-key' || authMode === 'both') {
108+
headers['x-api-key'] = apiKey
109+
}
110+
if (authMode === 'authorization' || authMode === 'both') {
111+
headers.Authorization = `ApiKey ${apiKey}`
112+
}
113+
return headers
114+
}
115+
116+
export async function queryNexchange(
117+
pluginParams: PluginParams
118+
): Promise<PluginResult> {
119+
const { settings, apiKeys } = asNexchangePluginParams(pluginParams)
120+
const { apiKey, baseUrl, authMode } = apiKeys
121+
let { latestIsoDate } = settings
122+
123+
if (apiKey == null || apiKey === '') {
124+
return { settings: { latestIsoDate }, transactions: [] }
125+
}
126+
127+
const headers = makeNexchangeHeaders(apiKey, authMode)
128+
const queryDateFrom = toQueryIsoDate(latestIsoDate)
129+
let cursor: string | undefined
130+
let offset = 0
131+
let retry = 0
132+
133+
const txByOrderId: Map<string, StandardTx> = new Map()
134+
135+
while (true) {
136+
const params: string[] = [
137+
`dateFrom=${encodeURIComponent(queryDateFrom)}`,
138+
`limit=${LIMIT.toString()}`,
139+
'sortDirection=ASC'
140+
]
141+
if (cursor != null && cursor !== '') {
142+
params.push(`cursor=${encodeURIComponent(cursor)}`)
143+
} else {
144+
params.push(`offset=${offset.toString()}`)
145+
}
146+
147+
const url = `${baseUrl}/audits/edge/orders?${params.join('&')}`
148+
149+
try {
150+
const response = await retryFetch(url, { headers, method: 'GET' })
151+
if (!response.ok) {
152+
const text = await response.text()
153+
throw new Error(`HTTP ${response.status.toString()}: ${text}`)
154+
}
155+
const json = await response.json()
156+
const { orders, nextCursor, hasMore } = asNexchangeOrdersResponse(json)
157+
158+
for (const rawOrder of orders) {
159+
const standardTx = processNexchangeTx(rawOrder)
160+
txByOrderId.set(standardTx.orderId, standardTx)
161+
if (standardTx.isoDate > latestIsoDate) {
162+
latestIsoDate = standardTx.isoDate
163+
}
164+
}
165+
166+
if (!hasMore) break
167+
if (orders.length === 0) break
168+
169+
if (nextCursor != null && nextCursor !== '') {
170+
cursor = nextCursor
171+
} else {
172+
offset += orders.length
173+
}
174+
retry = 0
175+
} catch (e) {
176+
datelog(e)
177+
retry++
178+
if (retry <= MAX_RETRIES) {
179+
datelog(`Snoozing ${5 * retry}s`)
180+
await snooze(5000 * retry)
181+
} else {
182+
// We can safely save progress because pagination is oldest -> newest.
183+
break
184+
}
185+
}
186+
}
187+
188+
return {
189+
settings: { latestIsoDate },
190+
transactions: Array.from(txByOrderId.values())
191+
}
192+
}
193+
194+
export const nexchange: PartnerPlugin = {
195+
queryFunc: queryNexchange,
196+
pluginName: 'Nexchange',
197+
pluginId: 'nexchange'
198+
}
199+
200+
export function processNexchangeTx(rawTx: unknown): StandardTx {
201+
const tx = asNexchangeOrder(rawTx)
202+
const lowerStatus = tx.status.toLowerCase()
203+
const status = statusMap[lowerStatus] ?? 'other'
204+
const { isoDate, timestamp } = parseApiDate(tx.createdAt)
205+
206+
return {
207+
status,
208+
orderId: tx.orderId,
209+
countryCode: tx.countryCode,
210+
depositTxid: tx.deposit.txid ?? undefined,
211+
depositAddress: tx.deposit.address ?? undefined,
212+
depositCurrency: tx.deposit.currency.toUpperCase(),
213+
depositAmount: safeParseFloat(tx.deposit.amount),
214+
direction: null,
215+
exchangeType: 'swap',
216+
paymentType: null,
217+
payoutTxid: tx.payout.txid ?? undefined,
218+
payoutAddress: tx.payout.address ?? undefined,
219+
payoutCurrency: tx.payout.currency.toUpperCase(),
220+
payoutAmount: safeParseFloat(tx.payout.amount),
221+
timestamp,
222+
isoDate,
223+
usdValue: -1,
224+
rawTx
225+
}
226+
}

src/queryEngine.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { letsexchange } from './partners/letsexchange'
2222
import { libertyx } from './partners/libertyx'
2323
import { lifi } from './partners/lifi'
2424
import { moonpay } from './partners/moonpay'
25+
import { nexchange } from './partners/nexchange'
2526
import { paybis } from './partners/paybis'
2627
import { paytrie } from './partners/paytrie'
2728
import { rango } from './partners/rango'
@@ -74,6 +75,7 @@ const plugins = [
7475
lifi,
7576
maya,
7677
moonpay,
78+
nexchange,
7779
paybis,
7880
paytrie,
7981
rango,

test/nexchange.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { expect } from 'chai'
2+
import { describe, it } from 'mocha'
3+
4+
import {
5+
makeNexchangeHeaders,
6+
processNexchangeTx
7+
} from '../src/partners/nexchange'
8+
9+
describe('nexchange plugin', () => {
10+
it('maps Edge audit order payload into StandardTx', () => {
11+
const raw = {
12+
orderId: 'NEX-ABCD1234',
13+
status: 'Released',
14+
createdAt: '2026-01-20T11:43:10+00:00',
15+
deposit: {
16+
currency: 'USDT',
17+
amount: '100.00000000',
18+
address: 'TQhaM...sample',
19+
txid: '0xdep123'
20+
},
21+
payout: {
22+
currency: 'btc',
23+
amount: '0.00145000',
24+
address: 'bc1q...sample',
25+
txid: '0xpay123'
26+
},
27+
countryCode: 'PT'
28+
}
29+
30+
const tx = processNexchangeTx(raw)
31+
32+
expect(tx.orderId).to.equal('NEX-ABCD1234')
33+
expect(tx.status).to.equal('complete')
34+
expect(tx.exchangeType).to.equal('swap')
35+
expect(tx.direction).to.equal(null)
36+
expect(tx.depositCurrency).to.equal('USDT')
37+
expect(tx.payoutCurrency).to.equal('BTC')
38+
expect(tx.depositAmount).to.equal(100)
39+
expect(tx.payoutAmount).to.equal(0.00145)
40+
expect(tx.countryCode).to.equal('PT')
41+
expect(tx.isoDate).to.equal('2026-01-20T11:43:10.000Z')
42+
expect(tx.timestamp).to.equal(1768909390)
43+
})
44+
45+
it('supports both x-api-key and Authorization header modes', () => {
46+
const both = makeNexchangeHeaders('secret', 'both')
47+
expect(both).to.deep.equal({
48+
'x-api-key': 'secret',
49+
Authorization: 'ApiKey secret'
50+
})
51+
52+
const legacy = makeNexchangeHeaders('secret', 'authorization')
53+
expect(legacy).to.deep.equal({
54+
Authorization: 'ApiKey secret'
55+
})
56+
})
57+
})

0 commit comments

Comments
 (0)