Skip to content

Commit 63c685d

Browse files
Populate chain plugin id, token id, and EVM chain id for nexchange txs
Fetch the n.exchange currency catalog and map each order's deposit and payout assets to Edge's chain plugin id, token id, and EVM chain id so reported transactions comply with the asset-identification requirements. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c36f50f commit 63c685d

2 files changed

Lines changed: 312 additions & 11 deletions

File tree

src/partners/nexchange.ts

Lines changed: 221 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
asBoolean,
44
asEither,
55
asNull,
6+
asNumber,
67
asObject,
78
asOptional,
89
asString,
@@ -19,6 +20,8 @@ import {
1920
Status
2021
} from '../types'
2122
import { datelog, retryFetch, safeParseFloat, snooze } from '../util'
23+
import { createTokenId, tokenTypes } from '../util/asEdgeTokenId'
24+
import { EVM_CHAIN_IDS } from '../util/chainIds'
2225

2326
const asNexchangePluginParams = asObject({
2427
settings: asObject({
@@ -27,6 +30,10 @@ const asNexchangePluginParams = asObject({
2730
apiKeys: asObject({
2831
apiKey: asOptional(asString),
2932
baseUrl: asOptional(asString, 'https://api.n.exchange/en/api/v1'),
33+
currencyUrl: asOptional(
34+
asString,
35+
'https://api.n.exchange/en/api/v2/currency/'
36+
),
3037
authMode: asOptional(asValue('x-api-key', 'authorization', 'both'), 'both')
3138
})
3239
})
@@ -53,6 +60,22 @@ const asNexchangeOrdersResponse = asObject({
5360
hasMore: asBoolean
5461
})
5562

63+
// Each entry from /api/v2/currency/. Many secondary fields exist but only the
64+
// ones below are needed to derive Edge chain plugin / token ids.
65+
const asNexchangeCurrencyMeta = asObject({
66+
code: asString,
67+
is_fiat: asOptional(asBoolean, false),
68+
network: asOptional(asEither(asString, asNull), null),
69+
contract_address: asOptional(asEither(asString, asNull), null),
70+
common_symbol: asOptional(asEither(asString, asNull), null),
71+
decimals: asOptional(asNumber, 8)
72+
})
73+
74+
const asNexchangeCurrencyList = asArray(asNexchangeCurrencyMeta)
75+
76+
export type NexchangeCurrencyMeta = ReturnType<typeof asNexchangeCurrencyMeta>
77+
export type NexchangeCurrencyInfoMap = Record<string, NexchangeCurrencyMeta>
78+
5679
type NexchangeAuthMode = 'x-api-key' | 'authorization' | 'both'
5780

5881
const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days
@@ -80,13 +103,58 @@ const statusMap: { [key: string]: Status } = {
80103
failed: 'other'
81104
}
82105

106+
// Map of n.exchange network identifier -> Edge chain plugin id.
107+
// Networks that have no Edge equivalent are intentionally omitted; those
108+
// transactions will be reported without chain/token id enrichment.
109+
export const NEXCHANGE_NETWORK_TO_PLUGIN_ID: Record<string, string> = {
110+
ADA: 'cardano',
111+
ALGO: 'algorand',
112+
ARB: 'arbitrum',
113+
ATOM: 'cosmoshub',
114+
AVAXC: 'avalanche',
115+
BASE: 'base',
116+
BCH: 'bitcoincash',
117+
BSC: 'binancesmartchain',
118+
BTC: 'bitcoin',
119+
DASH: 'dash',
120+
DOGE: 'dogecoin',
121+
DOT: 'polkadot',
122+
EOS: 'eos',
123+
ETC: 'ethereumclassic',
124+
ETH: 'ethereum',
125+
FIL: 'filecoin',
126+
FILEVM: 'filecoinfevm',
127+
FTM: 'fantom',
128+
HBAR: 'hedera',
129+
HyperEvm: 'hyperevm',
130+
LTC: 'litecoin',
131+
// n.exchange exposes both MATIC and POL networks; both reference the same
132+
// Polygon chain (chain id 137).
133+
MATIC: 'polygon',
134+
OP: 'optimism',
135+
POL: 'polygon',
136+
SOL: 'solana',
137+
SONIC: 'sonic',
138+
SUI: 'sui',
139+
TON: 'ton',
140+
TRON: 'tron',
141+
TRX: 'tron',
142+
XLM: 'stellar',
143+
XMR: 'monero',
144+
XRP: 'ripple',
145+
XTZ: 'tezos',
146+
ZEC: 'zcash'
147+
}
148+
83149
function toQueryIsoDate(latestIsoDate: string): string {
84150
let previousTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK
85151
if (previousTimestamp < 0) previousTimestamp = 0
86152
return new Date(previousTimestamp).toISOString()
87153
}
88154

89-
function parseApiDate(dateString: string): { isoDate: string; timestamp: number } {
155+
function parseApiDate(
156+
dateString: string
157+
): { isoDate: string; timestamp: number } {
90158
const hasTimezone = /(Z|[+-]\d{2}:\d{2})$/.test(dateString)
91159
const normalized = hasTimezone ? dateString : `${dateString}Z`
92160
const date = new Date(normalized)
@@ -113,11 +181,137 @@ export function makeNexchangeHeaders(
113181
return headers
114182
}
115183

184+
/**
185+
* Fetches the n.exchange currency catalog and returns a lookup keyed by the
186+
* uppercased currency code. The catalog supplies the network and contract
187+
* address fields that the audit-orders endpoint omits, which Edge needs to
188+
* populate chain plugin id and token id.
189+
*/
190+
export async function fetchNexchangeCurrencyMap(
191+
currencyUrl: string
192+
): Promise<NexchangeCurrencyInfoMap> {
193+
const response = await retryFetch(currencyUrl, { method: 'GET' })
194+
if (!response.ok) {
195+
const text = await response.text()
196+
throw new Error(`HTTP ${response.status.toString()}: ${text}`)
197+
}
198+
const json = await response.json()
199+
const currencies = asNexchangeCurrencyList(json)
200+
const map: NexchangeCurrencyInfoMap = {}
201+
for (const currency of currencies) {
202+
map[currency.code.toUpperCase()] = currency
203+
}
204+
return map
205+
}
206+
207+
/**
208+
* Resolves an n.exchange currency code into Edge chain plugin and token
209+
* identifiers. Returns `undefined` fields when the network is unknown to
210+
* Edge, when no metadata is available, or when the currency is a fiat. The
211+
* caller is expected to leave the corresponding StandardTx fields undefined
212+
* in that case so downstream rates lookup can fall back to currency-code
213+
* mappings.
214+
*/
215+
export function resolveNexchangeAsset(
216+
currencyCode: string,
217+
currencyMap: NexchangeCurrencyInfoMap
218+
): {
219+
currencyCode: string
220+
chainPluginId?: string
221+
tokenId?: string | null
222+
evmChainId?: number
223+
} {
224+
const upper = currencyCode.toUpperCase()
225+
const meta = currencyMap[upper]
226+
227+
// Default to the raw nexchange code; downstream `standardizeNames` handles
228+
// some of the composite codes (e.g. USDCSOL -> USDC).
229+
let normalizedCode = upper
230+
231+
if (meta == null) {
232+
return { currencyCode: normalizedCode }
233+
}
234+
235+
// Prefer the canonical symbol when n.exchange supplies one.
236+
if (
237+
meta.common_symbol != null &&
238+
meta.common_symbol !== '' &&
239+
// Some entries embed suffixes like "USDT-old"; only use the symbol when
240+
// it looks like a clean ticker.
241+
/^[A-Za-z0-9]+$/.test(meta.common_symbol)
242+
) {
243+
normalizedCode = meta.common_symbol.toUpperCase()
244+
}
245+
246+
if (meta.is_fiat) {
247+
return { currencyCode: normalizedCode }
248+
}
249+
250+
const network = meta.network
251+
if (network == null || network === '') {
252+
return { currencyCode: normalizedCode }
253+
}
254+
255+
const chainPluginId = NEXCHANGE_NETWORK_TO_PLUGIN_ID[network]
256+
if (chainPluginId == null) {
257+
return { currencyCode: normalizedCode }
258+
}
259+
260+
const tokenType = tokenTypes[chainPluginId]
261+
const evmChainId = EVM_CHAIN_IDS[chainPluginId]
262+
263+
// No contract_address means a native chain asset.
264+
const contractAddress = meta.contract_address
265+
if (contractAddress == null || contractAddress === '') {
266+
return {
267+
currencyCode: normalizedCode,
268+
chainPluginId,
269+
tokenId: null,
270+
evmChainId
271+
}
272+
}
273+
274+
// The contract address is present but the chain does not support tokens in
275+
// Edge's model. Fall back to a chain-only mapping so we at least populate
276+
// the chain plugin id for rates lookup.
277+
if (tokenType == null) {
278+
return {
279+
currencyCode: normalizedCode,
280+
chainPluginId,
281+
tokenId: null,
282+
evmChainId
283+
}
284+
}
285+
286+
let tokenId: string | null
287+
try {
288+
tokenId = createTokenId(tokenType, normalizedCode, contractAddress)
289+
} catch (e) {
290+
datelog(
291+
`nexchange: unable to derive tokenId for ${upper} (${chainPluginId}): ${String(
292+
e
293+
)}`
294+
)
295+
return {
296+
currencyCode: normalizedCode,
297+
chainPluginId,
298+
evmChainId
299+
}
300+
}
301+
302+
return {
303+
currencyCode: normalizedCode,
304+
chainPluginId,
305+
tokenId,
306+
evmChainId
307+
}
308+
}
309+
116310
export async function queryNexchange(
117311
pluginParams: PluginParams
118312
): Promise<PluginResult> {
119313
const { settings, apiKeys } = asNexchangePluginParams(pluginParams)
120-
const { apiKey, baseUrl, authMode } = apiKeys
314+
const { apiKey, baseUrl, currencyUrl, authMode } = apiKeys
121315
let { latestIsoDate } = settings
122316

123317
if (apiKey == null || apiKey === '') {
@@ -130,6 +324,15 @@ export async function queryNexchange(
130324
let offset = 0
131325
let retry = 0
132326

327+
// Fetch the currency catalog once per query. An empty map degrades the
328+
// plugin to legacy behaviour (no chain/token enrichment) instead of failing.
329+
let currencyMap: NexchangeCurrencyInfoMap = {}
330+
try {
331+
currencyMap = await fetchNexchangeCurrencyMap(currencyUrl)
332+
} catch (e) {
333+
datelog(`nexchange: failed to fetch currency catalog: ${String(e)}`)
334+
}
335+
133336
const txByOrderId: Map<string, StandardTx> = new Map()
134337

135338
while (true) {
@@ -156,7 +359,7 @@ export async function queryNexchange(
156359
const { orders, nextCursor, hasMore } = asNexchangeOrdersResponse(json)
157360

158361
for (const rawOrder of orders) {
159-
const standardTx = processNexchangeTx(rawOrder)
362+
const standardTx = processNexchangeTx(rawOrder, currencyMap)
160363
txByOrderId.set(standardTx.orderId, standardTx)
161364
if (standardTx.isoDate > latestIsoDate) {
162365
latestIsoDate = standardTx.isoDate
@@ -197,26 +400,38 @@ export const nexchange: PartnerPlugin = {
197400
pluginId: 'nexchange'
198401
}
199402

200-
export function processNexchangeTx(rawTx: unknown): StandardTx {
403+
export function processNexchangeTx(
404+
rawTx: unknown,
405+
currencyMap: NexchangeCurrencyInfoMap = {}
406+
): StandardTx {
201407
const tx = asNexchangeOrder(rawTx)
202408
const lowerStatus = tx.status.toLowerCase()
203409
const status = statusMap[lowerStatus] ?? 'other'
204410
const { isoDate, timestamp } = parseApiDate(tx.createdAt)
205411

412+
const deposit = resolveNexchangeAsset(tx.deposit.currency, currencyMap)
413+
const payout = resolveNexchangeAsset(tx.payout.currency, currencyMap)
414+
206415
return {
207416
status,
208417
orderId: tx.orderId,
209418
countryCode: tx.countryCode,
210419
depositTxid: tx.deposit.txid ?? undefined,
211420
depositAddress: tx.deposit.address ?? undefined,
212-
depositCurrency: tx.deposit.currency.toUpperCase(),
421+
depositCurrency: deposit.currencyCode,
422+
depositChainPluginId: deposit.chainPluginId,
423+
depositTokenId: deposit.tokenId,
424+
depositEvmChainId: deposit.evmChainId,
213425
depositAmount: safeParseFloat(tx.deposit.amount),
214426
direction: null,
215427
exchangeType: 'swap',
216428
paymentType: null,
217429
payoutTxid: tx.payout.txid ?? undefined,
218430
payoutAddress: tx.payout.address ?? undefined,
219-
payoutCurrency: tx.payout.currency.toUpperCase(),
431+
payoutCurrency: payout.currencyCode,
432+
payoutChainPluginId: payout.chainPluginId,
433+
payoutTokenId: payout.tokenId,
434+
payoutEvmChainId: payout.evmChainId,
220435
payoutAmount: safeParseFloat(tx.payout.amount),
221436
timestamp,
222437
isoDate,

0 commit comments

Comments
 (0)