Skip to content

Commit fbc8882

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 fbc8882

5 files changed

Lines changed: 357 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: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import {
2+
asArray,
3+
asMaybe,
4+
asObject,
5+
asOptional,
6+
asString,
7+
asUnknown,
8+
asValue
9+
} from 'cleaners'
10+
11+
import {
12+
PartnerPlugin,
13+
PluginParams,
14+
PluginResult,
15+
StandardTx,
16+
Status
17+
} from '../types'
18+
import {
19+
retryFetch,
20+
safeParseFloat,
21+
smartIsoDateFromTimestamp,
22+
snooze
23+
} from '../util'
24+
25+
// NYM ("nymswap") reporting plugin.
26+
//
27+
// The partner API host and versioned path prefix match the NYM *swap* plugin in
28+
// edge-exchange-plugins (src/swap/central/nym.ts), which authenticates with an
29+
// `x-api-key` header against `https://nym-swap-api.nymtech.cc/api/partner/v1/`
30+
// (`/quote`, `/order`). This reporting plugin queries the partner's completed
31+
// -orders listing for the same host.
32+
//
33+
// UNCONFIRMED (per task note, the auth scheme + reporting shape still need
34+
// confirming with NYM): the listing endpoint path, its query params, the JSON
35+
// envelope, and the per-order field names below are the documented assumption to
36+
// adjust once the partner confirms the scheme. The cleaners lean on
37+
// asMaybe/asOptional so an unexpected encoding degrades to undefined rather than
38+
// throwing and aborting the whole query block.
39+
const NYM_API_BASE = 'https://nym-swap-api.nymtech.cc'
40+
const ORDERS_PATH = '/api/partner/v1/orders'
41+
42+
// Re-query a 5-day window behind the saved progress so in-flight orders that
43+
// settle after the previous run are picked up (mirrors sibling swap partners).
44+
const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days
45+
const MAX_RETRIES = 5
46+
47+
export const asNymPluginParams = asObject({
48+
settings: asObject({
49+
latestIsoDate: asOptional(asString, '1970-01-01T00:00:00.000Z')
50+
}),
51+
apiKeys: asObject({
52+
// Partner-issued reporting key, human/ops-set in production CouchDB
53+
// (reports_apps partnerIds.nymswap.apiKeys). Never fetched or set by code.
54+
apiKey: asString
55+
})
56+
})
57+
58+
// Unknown statuses degrade to 'other' rather than throwing.
59+
const asNymStatus = asMaybe(
60+
asValue(
61+
'waiting',
62+
'pending',
63+
'confirming',
64+
'exchanging',
65+
'processing',
66+
'sending',
67+
'completed',
68+
'complete',
69+
'settled',
70+
'refunded',
71+
'refunding',
72+
'failed',
73+
'expired',
74+
'cancelled'
75+
),
76+
'other'
77+
)
78+
type NymStatus = ReturnType<typeof asNymStatus>
79+
80+
const statusMap: { [key in NymStatus]: Status } = {
81+
waiting: 'pending',
82+
pending: 'pending',
83+
confirming: 'confirming',
84+
exchanging: 'processing',
85+
processing: 'processing',
86+
sending: 'processing',
87+
completed: 'complete',
88+
complete: 'complete',
89+
settled: 'complete',
90+
refunded: 'refunded',
91+
refunding: 'refunded',
92+
failed: 'failed',
93+
expired: 'expired',
94+
cancelled: 'cancelled',
95+
other: 'other'
96+
}
97+
98+
// Amounts may arrive as native-unit strings or plain numbers. Degrade any
99+
// unexpected encoding to 0 rather than throwing (a throw would escape
100+
// processNymTx into the page retry loop and abandon the whole lookback block)
101+
// and clamp non-finite results to 0 so NaN/Infinity never reach StandardTx,
102+
// where they would serialize to CouchDB as null.
103+
const asNymAmount = asMaybe((raw: unknown): number => {
104+
const num = typeof raw === 'number' ? raw : safeParseFloat(asString(raw))
105+
return Number.isFinite(num) ? num : 0
106+
}, 0)
107+
108+
// A single reporting order. `orderId`/`status`/`createdAt` and the currency
109+
// codes are required; the rest use asMaybe so a partial or differently-typed
110+
// record degrades to undefined/0 instead of throwing and aborting the block.
111+
const asNymOrder = asObject({
112+
orderId: asString,
113+
status: asNymStatus,
114+
createdAt: asString,
115+
116+
// Source (deposit) side.
117+
sourceCurrency: asString,
118+
sourceAmount: asNymAmount,
119+
payinAddress: asMaybe(asString),
120+
payinTxid: asMaybe(asString),
121+
122+
// Destination (payout) side.
123+
destinationCurrency: asString,
124+
destinationAmount: asNymAmount,
125+
payoutAddress: asMaybe(asString),
126+
payoutTxid: asMaybe(asString)
127+
})
128+
129+
// Listing envelope: `{ orders: [...] }`. Assumed shape (see file header).
130+
const asNymResult = asObject({
131+
orders: asArray(asUnknown)
132+
})
133+
134+
export async function queryNym(
135+
pluginParams: PluginParams
136+
): Promise<PluginResult> {
137+
const { log } = pluginParams
138+
const { settings, apiKeys } = asNymPluginParams(pluginParams)
139+
const { apiKey } = apiKeys
140+
let { latestIsoDate } = settings
141+
142+
// Progress persisted before this run. Only advance past it when the query
143+
// COMPLETES (an empty page is reached); on an error-driven early exit we keep
144+
// this value so a partial fetch never skips orders we did not page through.
145+
// This does not depend on the API's page ordering, which is unconfirmed.
146+
const savedIsoDate = latestIsoDate
147+
148+
const standardTxs: StandardTx[] = []
149+
const headers = {
150+
'Content-Type': 'application/json',
151+
Accept: 'application/json',
152+
'x-api-key': apiKey
153+
}
154+
155+
let lookbackTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK
156+
if (lookbackTimestamp < 0) lookbackTimestamp = 0
157+
const sinceIsoDate = new Date(lookbackTimestamp).toISOString()
158+
159+
let page = 1
160+
let retry = 0
161+
let completed = false
162+
163+
while (true) {
164+
const url = `${NYM_API_BASE}${ORDERS_PATH}?since=${encodeURIComponent(
165+
sinceIsoDate
166+
)}&page=${page}`
167+
try {
168+
const response = await retryFetch(url, { method: 'GET', headers })
169+
if (!response.ok) {
170+
const text = await response.text()
171+
throw new Error(text)
172+
}
173+
const { orders } = asNymResult(await response.json())
174+
if (orders.length === 0) {
175+
completed = true
176+
break
177+
}
178+
for (const rawTx of orders) {
179+
// Skip a single unparseable order rather than throwing out to the page
180+
// retry below, which would re-append this page's already-pushed orders
181+
// and produce duplicate orderIds.
182+
let standardTx: StandardTx
183+
try {
184+
standardTx = processNymTx(rawTx)
185+
} catch (e) {
186+
log.error(`Skipping unparseable order: ${String(e)}`)
187+
continue
188+
}
189+
standardTxs.push(standardTx)
190+
if (standardTx.isoDate > latestIsoDate) {
191+
latestIsoDate = standardTx.isoDate
192+
}
193+
}
194+
log(`page=${page} latestIsoDate ${latestIsoDate}`)
195+
page++
196+
retry = 0
197+
} catch (e) {
198+
log.error(String(e))
199+
// Retry a few times with a delay to ride out throttling.
200+
retry++
201+
if (retry <= MAX_RETRIES) {
202+
log.warn(`Snoozing ${5 * retry}s`)
203+
await snooze(5000 * retry)
204+
} else {
205+
// Give up this run WITHOUT advancing progress (completed stays false),
206+
// so the unfetched remainder is re-queried next run. Already-fetched
207+
// orders are still returned; the cache engine dedupes them by orderId.
208+
break
209+
}
210+
}
211+
}
212+
213+
const out: PluginResult = {
214+
settings: { latestIsoDate: completed ? latestIsoDate : savedIsoDate },
215+
transactions: standardTxs
216+
}
217+
return out
218+
}
219+
220+
export const nymswap: PartnerPlugin = {
221+
// queryFunc takes PluginParams and returns a PluginResult
222+
queryFunc: queryNym,
223+
pluginName: 'NYM',
224+
pluginId: 'nymswap'
225+
}
226+
227+
export function processNymTx(rawTx: unknown): StandardTx {
228+
const tx = asNymOrder(rawTx)
229+
const { isoDate, timestamp } = smartIsoDateFromTimestamp(tx.createdAt)
230+
231+
// Clean shape (mirrors swapuz): NYM's reporting asset naming is not confirmed,
232+
// so carry currency codes only and leave chain/evm/token fields undefined.
233+
const standardTx: StandardTx = {
234+
status: statusMap[tx.status],
235+
orderId: tx.orderId,
236+
countryCode: null,
237+
depositTxid: tx.payinTxid,
238+
depositAddress: tx.payinAddress,
239+
depositCurrency: tx.sourceCurrency.toUpperCase(),
240+
depositChainPluginId: undefined,
241+
depositEvmChainId: undefined,
242+
depositTokenId: undefined,
243+
depositAmount: tx.sourceAmount,
244+
direction: null,
245+
exchangeType: 'swap',
246+
paymentType: null,
247+
payoutTxid: tx.payoutTxid,
248+
payoutAddress: tx.payoutAddress,
249+
payoutCurrency: tx.destinationCurrency.toUpperCase(),
250+
payoutChainPluginId: undefined,
251+
payoutEvmChainId: undefined,
252+
payoutTokenId: undefined,
253+
payoutAmount: tx.destinationAmount,
254+
timestamp,
255+
isoDate,
256+
usdValue: -1,
257+
rawTx
258+
}
259+
return standardTx
260+
}

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,

test/nym.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { expect } from 'chai'
2+
import { describe, it } from 'mocha'
3+
4+
import { processNymTx } from '../src/partners/nym'
5+
6+
describe('processNymTx', function() {
7+
it('maps a completed order to a StandardTx', function() {
8+
const rawTx = {
9+
orderId: 'nym-order-123',
10+
status: 'completed',
11+
createdAt: '2026-07-22T23:45:35.987Z',
12+
sourceCurrency: 'btc',
13+
sourceAmount: '0.05',
14+
payinAddress: 'bc1qsourceaddress',
15+
payinTxid: 'deposit-hash-abc',
16+
destinationCurrency: 'nym',
17+
destinationAmount: '1234.5',
18+
payoutAddress: 'nym-payout-address',
19+
payoutTxid: 'payout-hash-def'
20+
}
21+
22+
const standardTx = processNymTx(rawTx)
23+
24+
expect(standardTx.status).to.equal('complete')
25+
expect(standardTx.orderId).to.equal('nym-order-123')
26+
expect(standardTx.exchangeType).to.equal('swap')
27+
expect(standardTx.depositCurrency).to.equal('BTC')
28+
expect(standardTx.depositAmount).to.equal(0.05)
29+
expect(standardTx.depositAddress).to.equal('bc1qsourceaddress')
30+
expect(standardTx.depositTxid).to.equal('deposit-hash-abc')
31+
expect(standardTx.payoutCurrency).to.equal('NYM')
32+
expect(standardTx.payoutAmount).to.equal(1234.5)
33+
expect(standardTx.payoutAddress).to.equal('nym-payout-address')
34+
expect(standardTx.payoutTxid).to.equal('payout-hash-def')
35+
expect(standardTx.isoDate).to.equal('2026-07-22T23:45:35.987Z')
36+
expect(standardTx.usdValue).to.equal(-1)
37+
expect(standardTx.rawTx).to.deep.equal(rawTx)
38+
})
39+
40+
it('degrades an unknown status to other', function() {
41+
const standardTx = processNymTx({
42+
orderId: 'nym-order-456',
43+
status: 'some-new-status',
44+
createdAt: '2026-07-22T00:00:00.000Z',
45+
sourceCurrency: 'eth',
46+
destinationCurrency: 'nym'
47+
})
48+
49+
expect(standardTx.status).to.equal('other')
50+
// Missing amounts default to 0 rather than throwing.
51+
expect(standardTx.depositAmount).to.equal(0)
52+
expect(standardTx.payoutAmount).to.equal(0)
53+
})
54+
55+
it('accepts numeric amounts and degrades a bad-typed field instead of throwing', function() {
56+
const standardTx = processNymTx({
57+
orderId: 'nym-order-789',
58+
status: 'settled',
59+
createdAt: '2026-07-22T00:00:00.000Z',
60+
sourceCurrency: 'btc',
61+
// Numeric amount (not a string) must be preserved, not throw.
62+
sourceAmount: 0.25,
63+
// Wrong-typed optional field must degrade to undefined, not abort.
64+
payinAddress: { unexpected: 'object' },
65+
destinationCurrency: 'nym',
66+
destinationAmount: '500'
67+
})
68+
69+
expect(standardTx.status).to.equal('complete')
70+
expect(standardTx.depositAmount).to.equal(0.25)
71+
expect(standardTx.payoutAmount).to.equal(500)
72+
expect(standardTx.depositAddress).to.equal(undefined)
73+
})
74+
75+
it('clamps non-finite and non-numeric amounts to 0', function() {
76+
const standardTx = processNymTx({
77+
orderId: 'nym-order-999',
78+
status: 'complete',
79+
createdAt: '2026-07-22T00:00:00.000Z',
80+
sourceCurrency: 'btc',
81+
sourceAmount: 'not-a-number',
82+
destinationCurrency: 'nym',
83+
destinationAmount: Infinity
84+
})
85+
86+
// NaN/Infinity must never reach StandardTx (they serialize to null in CouchDB).
87+
expect(standardTx.depositAmount).to.equal(0)
88+
expect(standardTx.payoutAmount).to.equal(0)
89+
})
90+
})

0 commit comments

Comments
 (0)