Skip to content

Commit 834e37a

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 834e37a

5 files changed

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

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)