Skip to content

Commit 971a2d7

Browse files
committed
Add NYM Swap (nymswap) reporting plugin
Query NYM's partner reporting API for completed swap orders and map them into StandardTx, modeled on the clean shape of swapuz. Register nymswap in queryEngine and the demo partners config. Add a transform unit test. The reporting endpoint, auth scheme, and order field names are the documented assumption to confirm with NYM; credentials are human/ops set.
1 parent d9c6f34 commit 971a2d7

5 files changed

Lines changed: 496 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- added: Add NYM Swap (nymswap) reporting
56
- changed: Update sideshift plugin with new optional API fields
67
- changed: Add signature header support to Exolix
78
- changed: Add index for orderId

src/demo/partners.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ export default {
9797
type: 'fiat',
9898
color: '#7214F5'
9999
},
100+
nymswap: {
101+
type: 'swap',
102+
color: '#FB6E4E'
103+
},
100104
paybis: {
101105
type: 'fiat',
102106
color: '#FFB400'

src/partners/nym.ts

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import {
2+
asArray,
3+
asMaybe,
4+
asNumber,
5+
asObject,
6+
asOptional,
7+
asString,
8+
asUnknown,
9+
asValue
10+
} from 'cleaners'
11+
12+
import {
13+
PartnerPlugin,
14+
PluginParams,
15+
PluginResult,
16+
StandardTx,
17+
Status
18+
} from '../types'
19+
import {
20+
retryFetch,
21+
safeParseFloat,
22+
smartIsoDateFromTimestamp,
23+
snooze
24+
} from '../util'
25+
26+
// NYM ("nymswap") reporting plugin.
27+
//
28+
// Confirmed against NYM's live "Edge Partner" API (OpenAPI at
29+
// https://nym-swap-api.nymtech.cc/api/docs/) and a live query under the GUI's
30+
// swap key. The reporting endpoint is
31+
// GET /api/partner/v1/reports/transactions
32+
// authenticated with the same `x-api-key` header the swap plugin uses
33+
// (edge-exchange-plugins src/swap/central/nym.ts). Query params are `startDate`
34+
// /`endDate` (ISO date-time), `limit` (<= 500), and an opaque `cursor`; the
35+
// response is
36+
// { transactions: EdgeTransactionRecord[], nextCursor: string | null }
37+
// paged by following `nextCursor` until it is null.
38+
//
39+
// Amounts arrive as native-unit strings (e.g. ETH in wei). StandardTx wants
40+
// major units, so each is divided by 10^decimals using NYM's own
41+
// GET /api/partner/v1/currencies list, keyed by currency code + tokenId.
42+
//
43+
// The report timestamp is keyed off `createdDate`: NYM leaves `completedDate`
44+
// null on most completed orders (observed 7 of 8 in the live sample), so it is
45+
// not a reliable time source.
46+
const NYM_API_BASE = 'https://nym-swap-api.nymtech.cc'
47+
const REPORTS_PATH = '/api/partner/v1/reports/transactions'
48+
const CURRENCIES_PATH = '/api/partner/v1/currencies'
49+
50+
// Re-query a 5-day window behind the saved progress so in-flight orders that
51+
// settle after the previous run are re-seen (mirrors sibling swap partners).
52+
const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days
53+
const PAGE_LIMIT = 500 // API max
54+
const MAX_RETRIES = 5
55+
56+
// Fallback native-unit decimals for NYM's current asset set, used only when the
57+
// live /currencies fetch fails. The live list overlays this at runtime, so a
58+
// newly listed asset is picked up automatically. Key: `CODE|tokenIdLower`.
59+
const DEFAULT_DECIMALS: { [key: string]: number } = {
60+
'BTC|': 8,
61+
'ETH|': 18,
62+
'NYM|': 6,
63+
'USDT|0xdac17f958d2ee523a2206206994597c13d831ec7': 6,
64+
'USDC|0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 6,
65+
'NYM|0x525a8f6f3ba4752868cde25164382bfbae3990e1': 6
66+
}
67+
68+
interface DecimalsMap {
69+
[key: string]: number
70+
}
71+
72+
const decimalsKey = (
73+
currencyCode: string,
74+
tokenId: string | undefined
75+
): string => `${currencyCode.toUpperCase()}|${(tokenId ?? '').toLowerCase()}`
76+
77+
export const asNymPluginParams = asObject({
78+
settings: asObject({
79+
latestIsoDate: asOptional(asString, '1970-01-01T00:00:00.000Z')
80+
}),
81+
apiKeys: asObject({
82+
// Partner-issued reporting key, human/ops-set in production CouchDB
83+
// (reports_apps partnerIds.nymswap.apiKeys). Never fetched or set by code.
84+
apiKey: asString
85+
})
86+
})
87+
88+
// NYM's EdgeStatus enum. Unknown values degrade to 'other' instead of throwing.
89+
const asNymStatus = asMaybe(
90+
asValue(
91+
'pending',
92+
'processing',
93+
'infoNeeded',
94+
'expired',
95+
'refunded',
96+
'completed'
97+
),
98+
'other'
99+
)
100+
type NymStatus = ReturnType<typeof asNymStatus>
101+
102+
const statusMap: { [key in NymStatus]: Status } = {
103+
pending: 'pending',
104+
processing: 'processing',
105+
infoNeeded: 'blocked',
106+
expired: 'expired',
107+
refunded: 'refunded',
108+
completed: 'complete',
109+
other: 'other'
110+
}
111+
112+
// A supported-asset entry from GET /api/partner/v1/currencies. Only the fields
113+
// used to convert native amounts to major units are consumed.
114+
const asNymCurrency = asObject({
115+
currencyCode: asString,
116+
tokenId: asMaybe(asString),
117+
decimals: asNumber
118+
})
119+
const asNymCurrencies = asArray(asNymCurrency)
120+
121+
// One EdgeTransactionRecord. `orderId`/`status`/`createdDate`, the currency
122+
// codes, and the native-unit amounts are always present; the nullable/optional
123+
// fields use asMaybe so a partial or differently-typed record degrades to
124+
// undefined instead of throwing and aborting the whole query block.
125+
const asNymTransaction = asObject({
126+
orderId: asString,
127+
status: asNymStatus,
128+
createdDate: asString,
129+
completedDate: asMaybe(asString),
130+
131+
// Source (deposit) side.
132+
sourceCurrencyCode: asString,
133+
sourceTokenId: asMaybe(asString),
134+
sourceAmount: asString,
135+
payinAddress: asMaybe(asString),
136+
payinTxid: asMaybe(asString),
137+
138+
// Destination (payout) side.
139+
destinationCurrencyCode: asString,
140+
destinationTokenId: asMaybe(asString),
141+
destinationAmount: asString,
142+
payoutAddress: asMaybe(asString),
143+
payoutTxid: asMaybe(asString)
144+
})
145+
146+
// Reporting page envelope: `{ transactions, nextCursor }`.
147+
const asNymResult = asObject({
148+
transactions: asArray(asUnknown),
149+
nextCursor: asMaybe(asString)
150+
})
151+
152+
// Converts a native-unit amount string to a major-unit number using the asset's
153+
// decimals (live /currencies, then DEFAULT_DECIMALS). Throws for an unknown
154+
// asset so the caller skips the record rather than reporting a mis-scaled
155+
// amount that would corrupt the downstream USD valuation.
156+
const toMajorAmount = (
157+
nativeAmount: string,
158+
currencyCode: string,
159+
tokenId: string | undefined,
160+
decimals: DecimalsMap
161+
): number => {
162+
const key = decimalsKey(currencyCode, tokenId)
163+
const dec = decimals[key] ?? DEFAULT_DECIMALS[key]
164+
if (dec == null) {
165+
throw new Error(`Unknown decimals for ${key}`)
166+
}
167+
const native = safeParseFloat(nativeAmount)
168+
if (!Number.isFinite(native)) return 0
169+
return native / 10 ** dec
170+
}
171+
172+
// Fetches NYM's supported-asset list into a decimals lookup. Non-fatal: on any
173+
// error, processNymTx falls back to DEFAULT_DECIMALS for the known asset set.
174+
const fetchDecimals = async (
175+
headers: { [key: string]: string },
176+
log: PluginParams['log']
177+
): Promise<DecimalsMap> => {
178+
const decimals: DecimalsMap = {}
179+
try {
180+
const response = await retryFetch(`${NYM_API_BASE}${CURRENCIES_PATH}`, {
181+
method: 'GET',
182+
headers
183+
})
184+
if (!response.ok) throw new Error(await response.text())
185+
const currencies = asNymCurrencies(await response.json())
186+
for (const c of currencies) {
187+
decimals[decimalsKey(c.currencyCode, c.tokenId)] = c.decimals
188+
}
189+
} catch (e) {
190+
log.warn(
191+
`Could not fetch NYM currencies, using fallback decimals: ${String(e)}`
192+
)
193+
}
194+
return decimals
195+
}
196+
197+
export async function queryNym(
198+
pluginParams: PluginParams
199+
): Promise<PluginResult> {
200+
const { log } = pluginParams
201+
const { settings, apiKeys } = asNymPluginParams(pluginParams)
202+
const { apiKey } = apiKeys
203+
let { latestIsoDate } = settings
204+
205+
// Progress persisted before this run. Only advanced past when the full cursor
206+
// walk COMPLETES (nextCursor null); on an error-driven early exit we keep this
207+
// value so a partial fetch never skips orders we did not page through. This
208+
// does not depend on the API's page ordering.
209+
const savedIsoDate = latestIsoDate
210+
211+
const standardTxs: StandardTx[] = []
212+
const headers = {
213+
'Content-Type': 'application/json',
214+
Accept: 'application/json',
215+
'x-api-key': apiKey
216+
}
217+
218+
const decimals = await fetchDecimals(headers, log)
219+
220+
let lookbackTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK
221+
if (lookbackTimestamp < 0) lookbackTimestamp = 0
222+
const startDate = new Date(lookbackTimestamp).toISOString()
223+
224+
let cursor: string | undefined
225+
let retry = 0
226+
let completed = false
227+
228+
while (true) {
229+
let url = `${NYM_API_BASE}${REPORTS_PATH}?startDate=${encodeURIComponent(
230+
startDate
231+
)}&limit=${PAGE_LIMIT}`
232+
if (cursor != null) url += `&cursor=${encodeURIComponent(cursor)}`
233+
try {
234+
const response = await retryFetch(url, { method: 'GET', headers })
235+
if (!response.ok) {
236+
const text = await response.text()
237+
throw new Error(text)
238+
}
239+
const { transactions, nextCursor } = asNymResult(await response.json())
240+
for (const rawTx of transactions) {
241+
// Skip a single unparseable/unpriceable record rather than throwing out
242+
// to the page retry below, which would re-append this page's already
243+
// -pushed records and produce duplicate orderIds.
244+
let standardTx: StandardTx
245+
try {
246+
standardTx = processNymTx(rawTx, decimals)
247+
} catch (e) {
248+
log.error(`Skipping unparseable transaction: ${String(e)}`)
249+
continue
250+
}
251+
standardTxs.push(standardTx)
252+
if (standardTx.isoDate > latestIsoDate) {
253+
latestIsoDate = standardTx.isoDate
254+
}
255+
}
256+
log(
257+
`cursor=${cursor ?? 'start'} count=${
258+
transactions.length
259+
} latestIsoDate ${latestIsoDate}`
260+
)
261+
retry = 0
262+
// A null nextCursor marks the last page: the walk is complete.
263+
if (nextCursor == null) {
264+
completed = true
265+
break
266+
}
267+
cursor = nextCursor
268+
} catch (e) {
269+
log.error(String(e))
270+
// Retry the SAME cursor a few times to ride out throttling.
271+
retry++
272+
if (retry <= MAX_RETRIES) {
273+
log.warn(`Snoozing ${5 * retry}s`)
274+
await snooze(5000 * retry)
275+
} else {
276+
// Give up this run WITHOUT advancing progress (completed stays false),
277+
// so the unfetched remainder is re-queried next run. Already-fetched
278+
// records are still returned; the cache engine dedupes them by orderId.
279+
break
280+
}
281+
}
282+
}
283+
284+
const out: PluginResult = {
285+
settings: { latestIsoDate: completed ? latestIsoDate : savedIsoDate },
286+
transactions: standardTxs
287+
}
288+
return out
289+
}
290+
291+
export const nymswap: PartnerPlugin = {
292+
// queryFunc takes PluginParams and returns a PluginResult
293+
queryFunc: queryNym,
294+
pluginName: 'NYM',
295+
pluginId: 'nymswap'
296+
}
297+
298+
export function processNymTx(
299+
rawTx: unknown,
300+
decimals: DecimalsMap = {}
301+
): StandardTx {
302+
const tx = asNymTransaction(rawTx)
303+
304+
// completedDate is frequently null even on completed orders, so key the report
305+
// timestamp off createdDate (always present).
306+
const { isoDate, timestamp } = smartIsoDateFromTimestamp(tx.createdDate)
307+
308+
const depositAmount = toMajorAmount(
309+
tx.sourceAmount,
310+
tx.sourceCurrencyCode,
311+
tx.sourceTokenId,
312+
decimals
313+
)
314+
const payoutAmount = toMajorAmount(
315+
tx.destinationAmount,
316+
tx.destinationCurrencyCode,
317+
tx.destinationTokenId,
318+
decimals
319+
)
320+
321+
// Clean shape (mirrors swapuz): carry currency codes + major-unit amounts and
322+
// leave the chain/evm/token plugin fields undefined (NYM's `sourceNetwork`
323+
// naming does not map 1:1 to Edge plugin ids).
324+
const standardTx: StandardTx = {
325+
status: statusMap[tx.status],
326+
orderId: tx.orderId,
327+
countryCode: null,
328+
depositTxid: tx.payinTxid,
329+
depositAddress: tx.payinAddress,
330+
depositCurrency: tx.sourceCurrencyCode.toUpperCase(),
331+
depositChainPluginId: undefined,
332+
depositEvmChainId: undefined,
333+
depositTokenId: undefined,
334+
depositAmount,
335+
direction: null,
336+
exchangeType: 'swap',
337+
paymentType: null,
338+
payoutTxid: tx.payoutTxid,
339+
payoutAddress: tx.payoutAddress,
340+
payoutCurrency: tx.destinationCurrencyCode.toUpperCase(),
341+
payoutChainPluginId: undefined,
342+
payoutEvmChainId: undefined,
343+
payoutTokenId: undefined,
344+
payoutAmount,
345+
timestamp,
346+
isoDate,
347+
usdValue: -1,
348+
rawTx
349+
}
350+
return standardTx
351+
}

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 { nymswap } from './partners/nym'
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+
nymswap,
7779
paybis,
7880
paytrie,
7981
rango,

0 commit comments

Comments
 (0)