33 asBoolean ,
44 asEither ,
55 asNull ,
6+ asNumber ,
67 asObject ,
78 asOptional ,
89 asString ,
@@ -19,6 +20,8 @@ import {
1920 Status
2021} from '../types'
2122import { datelog , retryFetch , safeParseFloat , snooze } from '../util'
23+ import { createTokenId , tokenTypes } from '../util/asEdgeTokenId'
24+ import { EVM_CHAIN_IDS } from '../util/chainIds'
2225
2326const 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+
5679type NexchangeAuthMode = 'x-api-key' | 'authorization' | 'both'
5780
5881const 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+
83149function 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 - Z a - z 0 - 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+
116310export 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