From 133722e6519e8dbea9db1fb6a5fc9d3821c63e21 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 31 Jul 2026 14:18:31 -0700 Subject: [PATCH 1/5] Fix broken mocha test suite The suite imported from ../lib/util, a gitignored build artifact that only exists after a build, so a clean checkout could not run any test. Point it at the source module instead. The analytics fixtures also still used the old pluginId key, which the current cleaner rejects. --- test/testData.json | 8 ++++---- test/util.test.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/testData.json b/test/testData.json index 419ab91a..69fff085 100644 --- a/test/testData.json +++ b/test/testData.json @@ -1270,7 +1270,7 @@ "numAllTxs": 165 }, "app": "edge", - "pluginId": "coinswitch", + "partnerId": "coinswitch", "start": 1594023608, "end": 1596055300 }, @@ -1549,7 +1549,7 @@ "numAllTxs": 5 }, "app": "app-dummy", - "pluginId": "partner-dummy", + "partnerId": "partner-dummy", "start": 1300000000, "end": 1300070000 }, @@ -2656,7 +2656,7 @@ "numAllTxs": 5 }, "app": "app-dummy", - "pluginId": "partner-dummy", + "partnerId": "partner-dummy", "start": 1708992000, "end": 1709424000 }, @@ -2815,7 +2815,7 @@ "numAllTxs": 2 }, "app": "app-dummy", - "pluginId": "partner-dummy", + "partnerId": "partner-dummy", "start": 1672444800, "end": 1706918400 } diff --git a/test/util.test.ts b/test/util.test.ts index e486398b..180b89ca 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -5,7 +5,7 @@ import { createQuarterBuckets, movingAveDataSort, sevenDayDataMerge -} from '../lib/util' +} from '../src/demo/clientUtil' import { fixtures } from './utilFixtures.js' // add case with 1, 2, 3, 4 month bucket From 4366aff18dc7b701a76fdce3ee443c8ce2daad17 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 31 Jul 2026 14:18:36 -0700 Subject: [PATCH 2/5] Add CI job to run the test suite The suite was not run by CI, which is how it stayed broken. The job installs with --ignore-scripts and generates the gitignored clientConfig.json before running mocha. --- .github/workflows/test.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..f1eb6e48 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: Test +on: [pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout the latest code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Setup Node + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: 18 + - name: Install dependencies + run: npm install --ignore-scripts + - name: Generate client config + run: node -r sucrase/register src/bin/configure.ts + - name: Run tests + run: npm test From 087a3e9263370eadc3e418990cdfedcca031ea20 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 31 Jul 2026 14:18:46 -0700 Subject: [PATCH 3/5] Add Swapter partner plugin Queries Swapter's tool-history reporting endpoint and maps each order to a StandardTx. Pages are buffered so a mid-page failure retries idempotently, and progress only advances once the full walk completes. --- src/demo/partners.ts | 4 + src/partners/swapter.ts | 268 ++++++++++++++++++++++++++++++++++++++++ src/queryEngine.ts | 2 + 3 files changed, 274 insertions(+) create mode 100644 src/partners/swapter.ts diff --git a/src/demo/partners.ts b/src/demo/partners.ts index 14e7dc4b..52afd35b 100644 --- a/src/demo/partners.ts +++ b/src/demo/partners.ts @@ -121,6 +121,10 @@ export default { type: 'swap', color: '#E35852' }, + swapter: { + type: 'swap', + color: '#00C9A7' + }, swapuz: { type: 'swap', color: '#56BD7C' diff --git a/src/partners/swapter.ts b/src/partners/swapter.ts new file mode 100644 index 00000000..0d119c50 --- /dev/null +++ b/src/partners/swapter.ts @@ -0,0 +1,268 @@ +import { + asArray, + asMaybe, + asNumber, + asObject, + asString, + asUnknown, + asValue +} from 'cleaners' + +import { + asStandardPluginParams, + PartnerPlugin, + PluginParams, + PluginResult, + StandardTx, + Status +} from '../types' +import { retryFetch, smartIsoDateFromTimestamp, snooze } from '../util' + +const asSwapterStatus = asMaybe( + asValue( + 'Waiting', + 'Confirmation', + 'Exchanging', + 'Sending', + 'Success', + 'Frozen', + 'Refunded', + 'Overdue', + 'Suspended' + ), + 'other' +) + +// Only the fields consumed by processSwapterTx are required strictly. Fields +// that never feed StandardTx (info.type, info.link, deposit/withdraw.network, +// the partner block) use asMaybe so an unexpected encoding degrades that field +// instead of throwing out of the whole page (which would abort or, with the +// retry loop, re-fetch the page). +const asSwapterTx = asObject({ + info: asObject({ + uid: asString, + status: asSwapterStatus, + type: asMaybe(asString), + link: asMaybe(asString), + equivalent: asNumber + }), + deposit: asObject({ + coin: asString, + network: asMaybe(asString), + amount: asNumber, + actual: asMaybe(asNumber), + address: asString, + memo: asMaybe(asString) + }), + withdraw: asObject({ + coin: asString, + network: asMaybe(asString), + amount: asNumber, + address: asString, + memo: asMaybe(asString) + }), + time: asObject({ + create: asNumber, + confirmation: asMaybe(asNumber), + exchanging: asMaybe(asNumber), + send: asMaybe(asNumber), + success: asMaybe(asNumber), + overdue: asMaybe(asNumber) + }), + partner: asMaybe( + asObject({ + name: asMaybe(asString), + profit: asMaybe( + asObject({ + amount: asMaybe(asNumber), + percent: asMaybe(asNumber) + }) + ) + }) + ) +}) + +const asSwapterResult = asObject({ + page: asNumber, + total: asNumber, + data: asArray(asUnknown) +}) + +type SwapterTx = ReturnType +type SwapterStatus = ReturnType + +const MAX_RETRIES = 5 +const LIMIT = 200 +const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days + +const statusMap: { [key in SwapterStatus]: Status } = { + Waiting: 'pending', + Confirmation: 'processing', + Exchanging: 'processing', + Sending: 'processing', + Success: 'complete', + Overdue: 'expired', + Refunded: 'refunded', + Frozen: 'other', + Suspended: 'other', + other: 'other' +} + +export const querySwapter = async ( + pluginParams: PluginParams +): Promise => { + const { log } = pluginParams + const { settings, apiKeys } = asStandardPluginParams(pluginParams) + const { apiKey } = apiKeys + const latestIsoDate = + typeof settings.latestIsoDate === 'string' + ? settings.latestIsoDate + : new Date(0).toISOString() + + if (apiKey == null) { + return { settings: { latestIsoDate }, transactions: [] } + } + + const standardTxs: StandardTx[] = [] + // Preserve the pre-run progress marker. latestIsoDate only advances once + // pagination completes cleanly; if retries are exhausted mid-run we return + // the original marker so the next cycle re-fetches the unfinished window + // rather than skipping older, never-fetched pages. + const startIsoDate = latestIsoDate + let newLatestIsoDate = latestIsoDate + let completed = false + + let previousTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK + if (previousTimestamp < 0) previousTimestamp = 0 + + // Freeze the upper time bound for the whole pagination walk. Recomputing + // Date.now() per page would shift the window and page boundaries if orders + // arrive mid-walk, which can skip or duplicate rows before completion. + const queryTimeTo = Date.now() + + let page = 1 + let retry = 0 + + while (true) { + try { + const response = await retryFetch( + 'https://api.swapter.io/personal/exchange/tool-history', + { + method: 'POST', + headers: { + 'X-Api-Key': apiKey, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + page, + items: LIMIT, + timeFrom: previousTimestamp, + timeTo: queryTimeTo + }) + } + ) + + if (!response.ok) { + const text = await response.text() + log.error(`Swapter error on page:${page}`) + throw new Error(text) + } + + const result = asSwapterResult(await response.json()) + const txs = result.data + + if (txs.length === 0) { + completed = true + break + } + + // Buffer this page so a mid-page throw is retried idempotently: the + // buffer is discarded on error, so already-processed rows are never + // appended twice (which would create duplicate orderIds and Couch _id + // conflicts on bulk insert). + const pageTxs: StandardTx[] = [] + for (const rawTx of txs) { + const standardTx = processSwapterTx(rawTx, pluginParams) + pageTxs.push(standardTx) + if (standardTx.isoDate > newLatestIsoDate) { + newLatestIsoDate = standardTx.isoDate + } + } + standardTxs.push(...pageTxs) + + log(`Swapter page ${page} latestIsoDate ${newLatestIsoDate}`) + + const loaded = page * LIMIT + if (loaded >= result.total) { + completed = true + break + } + + page++ + retry = 0 + } catch (e) { + log.error(String(e)) + + retry++ + if (retry <= MAX_RETRIES) { + log.warn(`Snoozing ${5 * retry}s`) + await snooze(5000 * retry) + } else { + break + } + } + } + + return { + settings: { latestIsoDate: completed ? newLatestIsoDate : startIsoDate }, + transactions: standardTxs + } +} + +export const swapter: PartnerPlugin = { + queryFunc: querySwapter, + pluginName: 'Swapter', + pluginId: 'swapter' +} + +export function processSwapterTx( + rawTx: unknown, + pluginParams: PluginParams +): StandardTx { + const tx: SwapterTx = asSwapterTx(rawTx) + + const { timestamp, isoDate } = smartIsoDateFromTimestamp(tx.time.create) + + return { + status: statusMap[tx.info.status], + orderId: tx.info.uid, + countryCode: null, + + depositTxid: undefined, + depositAddress: tx.deposit.address, + depositCurrency: tx.deposit.coin.toUpperCase(), + depositChainPluginId: undefined, + depositEvmChainId: undefined, + depositTokenId: undefined, + depositAmount: tx.deposit.actual ?? tx.deposit.amount, + + direction: null, + exchangeType: 'swap', + paymentType: null, + + payoutTxid: undefined, + payoutAddress: tx.withdraw.address, + payoutCurrency: tx.withdraw.coin.toUpperCase(), + payoutChainPluginId: undefined, + payoutEvmChainId: undefined, + payoutTokenId: undefined, + payoutAmount: tx.withdraw.amount, + + timestamp, + isoDate, + + usdValue: tx.info.equivalent > 0 ? tx.info.equivalent : -1, + + rawTx + } +} diff --git a/src/queryEngine.ts b/src/queryEngine.ts index 5c9ce353..57717465 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -28,6 +28,7 @@ import { rango } from './partners/rango' import { safello } from './partners/safello' import { sideshift } from './partners/sideshift' import { simplex } from './partners/simplex' +import { swapter } from './partners/swapter' import { swapuz } from './partners/swapuz' import { switchain } from './partners/switchain' import { maya, thorchain } from './partners/thorchain' @@ -80,6 +81,7 @@ const plugins = [ safello, sideshift, simplex, + swapter, swapuz, switchain, thorchain, From 96b821abb179b1cd12551de3ff2548069991262f Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 31 Jul 2026 14:18:57 -0700 Subject: [PATCH 4/5] Add NYM Swap (nymswap) reporting plugin Queries NYM's partner reporting endpoint and converts its native-unit amounts to major units using the live currencies list. The report timestamp keys off createdDate because completedDate is null even on settled orders. processNymTx follows the uniform (rawTx, pluginParams) processor contract, and an unparseable order fails its page rather than being skipped: skipping would let the walk complete and advance progress past an order the next run's lookback can no longer reach. --- src/demo/partners.ts | 4 + src/partners/nym.ts | 382 +++++++++++++++++++++++++++++++++++++++++++ src/queryEngine.ts | 2 + test/nym.test.ts | 167 +++++++++++++++++++ 4 files changed, 555 insertions(+) create mode 100644 src/partners/nym.ts create mode 100644 test/nym.test.ts diff --git a/src/demo/partners.ts b/src/demo/partners.ts index 52afd35b..691bff4e 100644 --- a/src/demo/partners.ts +++ b/src/demo/partners.ts @@ -97,6 +97,10 @@ export default { type: 'fiat', color: '#7214F5' }, + nymswap: { + type: 'swap', + color: '#FB6E4E' + }, paybis: { type: 'fiat', color: '#FFB400' diff --git a/src/partners/nym.ts b/src/partners/nym.ts new file mode 100644 index 00000000..4cda90c9 --- /dev/null +++ b/src/partners/nym.ts @@ -0,0 +1,382 @@ +import { + asArray, + asMaybe, + asNumber, + asObject, + asOptional, + asString, + asUnknown, + asValue +} from 'cleaners' + +import { + PartnerPlugin, + PluginParams, + PluginResult, + StandardTx, + Status +} from '../types' +import { + retryFetch, + safeParseFloat, + smartIsoDateFromTimestamp, + snooze +} from '../util' + +// NYM ("nymswap") reporting plugin. +// +// Confirmed against NYM's live "Edge Partner" API (OpenAPI at +// https://nym-swap-api.nymtech.cc/api/docs/) and a live query under the GUI's +// swap key. The reporting endpoint is +// GET /api/partner/v1/reports/transactions +// authenticated with the same `x-api-key` header the swap plugin uses +// (edge-exchange-plugins src/swap/central/nym.ts). Query params are `startDate` +// /`endDate` (ISO date-time), `limit` (<= 500), and an opaque `cursor`; the +// response is +// { transactions: EdgeTransactionRecord[], nextCursor: string | null } +// paged by following `nextCursor` until it is null. +// +// Amounts arrive as native-unit strings (e.g. ETH in wei). StandardTx wants +// major units, so each is divided by 10^decimals using NYM's own +// GET /api/partner/v1/currencies list, keyed by currency code + tokenId. +// +// The report timestamp is keyed off `createdDate`, not `completedDate`. A live +// check across every genuinely-settled swap under the GUI key (status +// `completed` with BOTH a payin and a payout txid) found `completedDate` null on +// all 7 of them; the only `completed` order that carried a `completedDate` was +// an anomalous NYM->NYM order with no payin txid. So `completedDate` is not a +// reliable settlement-time source even for real swaps, and `createdDate` (always +// present) is used uniformly. +const NYM_API_BASE = 'https://nym-swap-api.nymtech.cc' +const REPORTS_PATH = '/api/partner/v1/reports/transactions' +const CURRENCIES_PATH = '/api/partner/v1/currencies' + +// Re-query a 5-day window behind the saved progress so in-flight orders that +// settle after the previous run are re-seen (mirrors sibling swap partners). +const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days +const PAGE_LIMIT = 500 // API max +const MAX_RETRIES = 5 + +// Fallback native-unit decimals for NYM's current asset set, used only when the +// live /currencies fetch fails. The live list overlays this at runtime, so a +// newly listed asset is picked up automatically. Key: `CODE|tokenIdLower`. +const DEFAULT_DECIMALS: { [key: string]: number } = { + 'BTC|': 8, + 'ETH|': 18, + 'NYM|': 6, + 'USDT|0xdac17f958d2ee523a2206206994597c13d831ec7': 6, + 'USDC|0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 6, + 'NYM|0x525a8f6f3ba4752868cde25164382bfbae3990e1': 6 +} + +interface DecimalsMap { + [key: string]: number +} + +const decimalsKey = ( + currencyCode: string, + tokenId: string | undefined +): string => `${currencyCode.toUpperCase()}|${(tokenId ?? '').toLowerCase()}` + +export const asNymPluginParams = asObject({ + settings: asObject({ + latestIsoDate: asOptional(asString, '1970-01-01T00:00:00.000Z') + }), + apiKeys: asObject({ + // Partner-issued reporting key, human/ops-set in production CouchDB + // (reports_apps partnerIds.nymswap.apiKeys). Never fetched or set by code. + // Optional so an unprovisioned partner entry no-ops (returns []) instead of + // throwing every query cycle, matching the other couch plugins. + apiKey: asMaybe(asString) + }) +}) + +// NYM's EdgeStatus enum. Unknown values degrade to 'other' instead of throwing. +const asNymStatus = asMaybe( + asValue( + 'pending', + 'processing', + 'infoNeeded', + 'expired', + 'refunded', + 'completed' + ), + 'other' +) +type NymStatus = ReturnType + +const statusMap: { [key in NymStatus]: Status } = { + pending: 'pending', + processing: 'processing', + infoNeeded: 'blocked', + expired: 'expired', + refunded: 'refunded', + completed: 'complete', + other: 'other' +} + +// A supported-asset entry from GET /api/partner/v1/currencies. Only the fields +// used to convert native amounts to major units are consumed. +const asNymCurrency = asObject({ + currencyCode: asString, + tokenId: asMaybe(asString), + decimals: asNumber +}) +const asNymCurrencies = asArray(asNymCurrency) + +// One EdgeTransactionRecord. `orderId`/`status`/`createdDate`, the currency +// codes, and the native-unit amounts are always present; the nullable/optional +// fields use asMaybe so a partial or differently-typed record degrades to +// undefined instead of throwing and aborting the whole query block. +const asNymTransaction = asObject({ + orderId: asString, + status: asNymStatus, + createdDate: asString, + completedDate: asMaybe(asString), + + // Source (deposit) side. + sourceCurrencyCode: asString, + sourceTokenId: asMaybe(asString), + sourceAmount: asString, + payinAddress: asMaybe(asString), + payinTxid: asMaybe(asString), + + // Destination (payout) side. + destinationCurrencyCode: asString, + destinationTokenId: asMaybe(asString), + destinationAmount: asString, + payoutAddress: asMaybe(asString), + payoutTxid: asMaybe(asString) +}) + +// Reporting page envelope: `{ transactions, nextCursor }`. +const asNymResult = asObject({ + transactions: asArray(asUnknown), + nextCursor: asMaybe(asString) +}) + +// Converts a native-unit amount string to a major-unit number using the asset's +// decimals (live /currencies, then DEFAULT_DECIMALS). Throws for an unknown +// asset so the caller skips the record rather than reporting a mis-scaled +// amount that would corrupt the downstream USD valuation. +const toMajorAmount = ( + nativeAmount: string, + currencyCode: string, + tokenId: string | undefined, + decimals: DecimalsMap +): number => { + const key = decimalsKey(currencyCode, tokenId) + const dec = decimals[key] ?? DEFAULT_DECIMALS[key] + if (dec == null) { + throw new Error(`Unknown decimals for ${key}`) + } + const native = safeParseFloat(nativeAmount) + if (!Number.isFinite(native)) return 0 + return native / 10 ** dec +} + +// Fetches NYM's supported-asset list into a decimals lookup. Non-fatal: on any +// error, processNymTx falls back to DEFAULT_DECIMALS for the known asset set. +const fetchDecimals = async ( + headers: { [key: string]: string }, + log: PluginParams['log'] +): Promise => { + const decimals: DecimalsMap = {} + try { + const response = await retryFetch(`${NYM_API_BASE}${CURRENCIES_PATH}`, { + method: 'GET', + headers + }) + if (!response.ok) throw new Error(await response.text()) + const currencies = asNymCurrencies(await response.json()) + for (const c of currencies) { + decimals[decimalsKey(c.currencyCode, c.tokenId)] = c.decimals + } + } catch (e) { + log.warn( + `Could not fetch NYM currencies, using fallback decimals: ${String(e)}` + ) + } + return decimals +} + +export async function queryNym( + pluginParams: PluginParams +): Promise { + const { log } = pluginParams + const { settings, apiKeys } = asNymPluginParams(pluginParams) + const { apiKey } = apiKeys + let { latestIsoDate } = settings + + // A null/empty apiKey means the partner entry is unprovisioned. Skip silently + // (return no transactions) rather than erroring, so an unconfigured nymswap + // key does not fail every query cycle. + if (apiKey == null || apiKey === '') { + return { settings: { latestIsoDate }, transactions: [] } + } + + // Progress persisted before this run. Only advanced past when the full cursor + // walk COMPLETES (nextCursor null); on an error-driven early exit we keep this + // value so a partial fetch never skips orders we did not page through. This + // does not depend on the API's page ordering. + const savedIsoDate = latestIsoDate + + const standardTxs: StandardTx[] = [] + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-api-key': apiKey + } + + const decimals = await fetchDecimals(headers, log) + + let lookbackTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK + if (lookbackTimestamp < 0) lookbackTimestamp = 0 + const startDate = new Date(lookbackTimestamp).toISOString() + + let cursor: string | undefined + let retry = 0 + let completed = false + + while (true) { + let url = `${NYM_API_BASE}${REPORTS_PATH}?startDate=${encodeURIComponent( + startDate + )}&limit=${PAGE_LIMIT}` + if (cursor != null) url += `&cursor=${encodeURIComponent(cursor)}` + try { + const response = await retryFetch(url, { method: 'GET', headers }) + if (!response.ok) { + const text = await response.text() + throw new Error(text) + } + const { transactions, nextCursor } = asNymResult(await response.json()) + // Buffer this page so a mid-page throw is retried idempotently: the + // buffer is discarded on error, so already-processed records are never + // appended twice (which would create duplicate orderIds and Couch _id + // conflicts on bulk insert). + // + // An unparseable or unpriceable record therefore propagates to the retry + // below rather than being skipped. Dropping it here would lose the order + // permanently: the walk would still complete, `latestIsoDate` would + // advance past it, and the next run's lookback window would no longer + // reach it. Failing the page keeps `completed` false, so progress is not + // advanced and the order is re-queried. + const pageTxs: StandardTx[] = [] + let pageLatestIsoDate = latestIsoDate + for (const rawTx of transactions) { + const standardTx = processNymTx(rawTx, pluginParams, decimals) + pageTxs.push(standardTx) + if (standardTx.isoDate > pageLatestIsoDate) { + pageLatestIsoDate = standardTx.isoDate + } + } + standardTxs.push(...pageTxs) + latestIsoDate = pageLatestIsoDate + log( + `cursor=${cursor ?? 'start'} count=${ + transactions.length + } latestIsoDate ${latestIsoDate}` + ) + retry = 0 + // A null nextCursor marks the last page: the walk is complete. + if (nextCursor == null) { + completed = true + break + } + cursor = nextCursor + } catch (e) { + log.error(String(e)) + // Retry the SAME cursor a few times to ride out throttling. + retry++ + if (retry <= MAX_RETRIES) { + log.warn(`Snoozing ${5 * retry}s`) + await snooze(5000 * retry) + } else { + // Give up this run WITHOUT advancing progress (completed stays false), + // so the unfetched remainder is re-queried next run. Already-fetched + // records are still returned; the cache engine dedupes them by orderId. + break + } + } + } + + const out: PluginResult = { + settings: { latestIsoDate: completed ? latestIsoDate : savedIsoDate }, + transactions: standardTxs + } + return out +} + +export const nymswap: PartnerPlugin = { + // queryFunc takes PluginParams and returns a PluginResult + queryFunc: queryNym, + pluginName: 'NYM', + pluginId: 'nymswap' +} + +// Follows the uniform `(rawTx, pluginParams)` processor contract shared by the +// sibling plugins, so a generic backfill caller can invoke it without knowing +// anything NYM-specific. `decimals` is the live /currencies overlay and is +// optional: a caller that has not fetched it still gets DEFAULT_DECIMALS. +export function processNymTx( + rawTx: unknown, + pluginParams: PluginParams, + decimals: DecimalsMap = {} +): StandardTx { + const { log } = pluginParams + let tx: ReturnType + try { + tx = asNymTransaction(rawTx) + } catch (e) { + log.error(`${String(e)}: ${JSON.stringify(rawTx)}`) + throw e + } + + // completedDate is frequently null even on completed orders, so key the report + // timestamp off createdDate (always present). + const { isoDate, timestamp } = smartIsoDateFromTimestamp(tx.createdDate) + + const depositAmount = toMajorAmount( + tx.sourceAmount, + tx.sourceCurrencyCode, + tx.sourceTokenId, + decimals + ) + const payoutAmount = toMajorAmount( + tx.destinationAmount, + tx.destinationCurrencyCode, + tx.destinationTokenId, + decimals + ) + + // Clean shape (mirrors swapuz): carry currency codes + major-unit amounts and + // leave the chain/evm/token plugin fields undefined (NYM's `sourceNetwork` + // naming does not map 1:1 to Edge plugin ids). + const standardTx: StandardTx = { + status: statusMap[tx.status], + orderId: tx.orderId, + countryCode: null, + depositTxid: tx.payinTxid, + depositAddress: tx.payinAddress, + depositCurrency: tx.sourceCurrencyCode.toUpperCase(), + depositChainPluginId: undefined, + depositEvmChainId: undefined, + depositTokenId: undefined, + depositAmount, + direction: null, + exchangeType: 'swap', + paymentType: null, + payoutTxid: tx.payoutTxid, + payoutAddress: tx.payoutAddress, + payoutCurrency: tx.destinationCurrencyCode.toUpperCase(), + payoutChainPluginId: undefined, + payoutEvmChainId: undefined, + payoutTokenId: undefined, + payoutAmount, + timestamp, + isoDate, + usdValue: -1, + rawTx + } + return standardTx +} diff --git a/src/queryEngine.ts b/src/queryEngine.ts index 57717465..f20716bb 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -22,6 +22,7 @@ import { letsexchange } from './partners/letsexchange' import { libertyx } from './partners/libertyx' import { lifi } from './partners/lifi' import { moonpay } from './partners/moonpay' +import { nymswap } from './partners/nym' import { paybis } from './partners/paybis' import { paytrie } from './partners/paytrie' import { rango } from './partners/rango' @@ -75,6 +76,7 @@ const plugins = [ lifi, maya, moonpay, + nymswap, paybis, paytrie, rango, diff --git a/test/nym.test.ts b/test/nym.test.ts new file mode 100644 index 00000000..d6e37b15 --- /dev/null +++ b/test/nym.test.ts @@ -0,0 +1,167 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { processNymTx } from '../src/partners/nym' +import { PluginParams, ScopedLog } from '../src/types' + +// Fixtures follow the shape of NYM's GET /api/partner/v1/reports/transactions +// payloads. Addresses and txids are SYNTHETIC placeholders (never live +// user-linked identifiers); only the field structure and the native-unit amount +// math are load-bearing. processNymTx converts native-unit amount strings to +// major units via the asset decimals (DEFAULT_DECIMALS when no live map is +// passed). The USDT contract address in the decimals-map case is the public +// canonical USDT token contract, not user data. +// Silent logger so test output stays clean; processNymTx only logs on a +// cleaner failure. +const noopLog: ScopedLog = Object.assign(() => undefined, { + warn: () => undefined, + error: () => undefined +}) + +// processNymTx follows the uniform `(rawTx, pluginParams)` processor contract, +// so every case passes params even though only `log` is consumed. +const pluginParams: PluginParams = { + apiKeys: {}, + settings: {}, + log: noopLog +} + +describe('processNymTx', function() { + it('maps a completed order to a StandardTx with major-unit amounts', function() { + const rawTx = { + orderId: 'order_e73fd5b1c95f4f58', + status: 'completed', + createdDate: '2026-07-23T16:39:52.238Z', + // Real data leaves completedDate null even when completed. + completedDate: null, + sourceNetwork: 'ethereum', + sourceTokenId: null, + sourceCurrencyCode: 'ETH', + sourceAmount: '15899800000000000', // 0.0158998 ETH (18 decimals) + sourceEvmChainId: 1, + destinationNetwork: 'NYM', + destinationTokenId: null, + destinationCurrencyCode: 'NYM', + destinationAmount: '1633042311', // 1633.042311 NYM (6 decimals) + destinationEvmChainId: null, + payinAddress: '0x1111111111111111111111111111111111111111', + payoutAddress: 'n1exampledepositaddr00000000000000000000', + payinTxid: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + payoutTxid: + 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + } + + const standardTx = processNymTx(rawTx, pluginParams) + + expect(standardTx.status).to.equal('complete') + expect(standardTx.orderId).to.equal('order_e73fd5b1c95f4f58') + expect(standardTx.exchangeType).to.equal('swap') + expect(standardTx.depositCurrency).to.equal('ETH') + expect(standardTx.depositAmount).to.equal(0.0158998) + expect(standardTx.depositAddress).to.equal( + '0x1111111111111111111111111111111111111111' + ) + expect(standardTx.depositTxid).to.equal(rawTx.payinTxid) + expect(standardTx.payoutCurrency).to.equal('NYM') + expect(standardTx.payoutAmount).to.equal(1633.042311) + expect(standardTx.payoutAddress).to.equal( + 'n1exampledepositaddr00000000000000000000' + ) + expect(standardTx.payoutTxid).to.equal(rawTx.payoutTxid) + // Timestamp keys off createdDate (completedDate is null here). + expect(standardTx.isoDate).to.equal('2026-07-23T16:39:52.238Z') + expect(standardTx.usdValue).to.equal(-1) + expect(standardTx.rawTx).to.deep.equal(rawTx) + }) + + it('converts a token amount using a passed-in live decimals map', function() { + const standardTx = processNymTx( + { + orderId: 'order_ffdd5efef0d54a85', + status: 'expired', + createdDate: '2026-07-24T04:08:20.021Z', + completedDate: null, + sourceCurrencyCode: 'USDT', + sourceTokenId: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + sourceAmount: '32468489', // 32.468489 USDT (6 decimals) + payinAddress: '0x1111111111111111111111111111111111111111', + payinTxid: null, + destinationCurrencyCode: 'NYM', + destinationTokenId: null, + destinationAmount: '1901660695', // 1901.660695 NYM (6 decimals) + payoutAddress: 'n1examplepayoutaddr000000000000000000000', + payoutTxid: null + }, + pluginParams, + { + // Live /currencies overlay keyed CODE|tokenIdLower. + 'USDT|0xdac17f958d2ee523a2206206994597c13d831ec7': 6, + 'NYM|': 6 + } + ) + + expect(standardTx.status).to.equal('expired') + expect(standardTx.depositCurrency).to.equal('USDT') + expect(standardTx.depositAmount).to.equal(32.468489) + expect(standardTx.payoutAmount).to.equal(1901.660695) + // Nullable txids degrade to undefined, not throw. + expect(standardTx.depositTxid).to.equal(undefined) + expect(standardTx.payoutTxid).to.equal(undefined) + }) + + it('degrades an unknown status to other', function() { + const standardTx = processNymTx( + { + orderId: 'order_unknownstatus', + status: 'some-new-status', + createdDate: '2026-07-22T00:00:00.000Z', + sourceCurrencyCode: 'BTC', + sourceAmount: '10000', // 0.0001 BTC (8 decimals) + destinationCurrencyCode: 'NYM', + destinationAmount: '5000000' // 5 NYM (6 decimals) + }, + pluginParams + ) + + expect(standardTx.status).to.equal('other') + expect(standardTx.depositAmount).to.equal(0.0001) + expect(standardTx.payoutAmount).to.equal(5) + }) + + it('throws for an asset with no known decimals so the caller can skip it', function() { + expect(() => + processNymTx( + { + orderId: 'order_unknownasset', + status: 'completed', + createdDate: '2026-07-22T00:00:00.000Z', + sourceCurrencyCode: 'FOO', + sourceAmount: '100', + destinationCurrencyCode: 'NYM', + destinationAmount: '5000000' + }, + pluginParams + ) + ).to.throw(/Unknown decimals for FOO/) + }) + + it('clamps a non-numeric native amount to 0', function() { + const standardTx = processNymTx( + { + orderId: 'order_badamount', + status: 'completed', + createdDate: '2026-07-22T00:00:00.000Z', + sourceCurrencyCode: 'BTC', + sourceAmount: 'not-a-number', + destinationCurrencyCode: 'NYM', + destinationAmount: '5000000' + }, + pluginParams + ) + + // NaN must never reach StandardTx (it serializes to null in CouchDB). + expect(standardTx.depositAmount).to.equal(0) + expect(standardTx.payoutAmount).to.equal(5) + }) +}) From 74cfeeeed4bd7ce9c9c9949309c51f6930dec806 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 31 Jul 2026 14:19:13 -0700 Subject: [PATCH 5/5] Add Revolut fiat payment provider Queries Revolut Ramp's orders endpoint, authenticated with the X-API-KEY header that the GUI's existing ramp key uses. Two shapes of that API are easy to get wrong and are handled explicitly: the start/end bounds are date-only, and the response is a bare array paged by skip/limit rather than a cursor envelope. An order id is not unique in the response. Revolut returns one row per payment attempt, so the same id arrives both COMPLETED and FAILED. Since orderId keys the StandardTx document, attempts are collapsed to one winner per id, settled beating unsettled, before anything is emitted. Native assets resolve to their Edge chain with a null tokenId. Tokens resolve their chain but leave tokenId undefined: Revolut reports no contract address, and a guessed one would mis-price the asset. --- CHANGELOG.md | 5 + src/demo/partners.ts | 4 + src/partners/revolut.ts | 379 ++++++++++++++++++++++++++++++++++++++++ src/queryEngine.ts | 2 + test/revolut.test.ts | 262 +++++++++++++++++++++++++++ 5 files changed, 652 insertions(+) create mode 100644 src/partners/revolut.ts create mode 100644 test/revolut.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 766cf5df..9ef4adf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- added: CI job that runs the mocha test suite on every pull request +- added: Add Revolut fiat payment provider +- added: Add Swapter reporting +- added: Add NYM Swap (nymswap) reporting - changed: Update sideshift plugin with new optional API fields - changed: Query both old and new Sideshift affiliate accounts and merge completed orders to preserve full shift history across an affiliate-account rotation - changed: Add signature header support to Exolix @@ -11,6 +15,7 @@ - changed: Use rates V3 for transactions with pluginId/tokenId - fixed: Moonpay by adding Revolut payment type - fixed: Use v2 rates API +- fixed: Repair the broken mocha test suite (correct util.test.ts import and stale analytics fixtures) so npm test passes ## 0.2.0 diff --git a/src/demo/partners.ts b/src/demo/partners.ts index 691bff4e..ee8c99e4 100644 --- a/src/demo/partners.ts +++ b/src/demo/partners.ts @@ -113,6 +113,10 @@ export default { type: 'swap', color: '#5891EE' }, + revolut: { + type: 'fiat', + color: '#191C33' + }, safello: { type: 'fiat', color: deprecated diff --git a/src/partners/revolut.ts b/src/partners/revolut.ts new file mode 100644 index 00000000..23625746 --- /dev/null +++ b/src/partners/revolut.ts @@ -0,0 +1,379 @@ +import { + asArray, + asMaybe, + asNumber, + asObject, + asString, + asUnknown, + asValue +} from 'cleaners' + +import { + asStandardPluginParams, + EDGE_APP_START_DATE, + FiatPaymentType, + PartnerPlugin, + PluginParams, + PluginResult, + StandardTx, + Status +} from '../types' +import { retryFetch, smartIsoDateFromTimestamp, snooze } from '../util' +import { EVM_CHAIN_IDS } from '../util/chainIds' + +// Revolut Ramp reporting plugin. +// +// Confirmed against the live API with the key Edge already ships in the GUI +// (env.json RAMP_PLUGIN_INITS.revolut, which also carries `apiUrl`): +// GET https://ramp-partners.revolut.com/partners/api/2.0/orders +// authenticated with an `X-API-KEY` header. Docs: +// https://developer.revolut.com/docs/crypto-ramp/retrieve-all-orders +// +// Two shapes of this API are easy to get wrong: +// * `start`/`end` are DATE-ONLY (`YYYY-MM-DD`). An ISO date-time, an epoch +// seconds value, or an epoch millis value all return HTTP 400 "Invalid +// field 'start'. Date value parsing error". +// * The response is a BARE JSON ARRAY of orders, not an envelope with a +// cursor. Paging is `skip`/`limit`, walked until a short page arrives. +const DEFAULT_API_URL = 'https://ramp-partners.revolut.com' +const ORDERS_PATH = '/partners/api/2.0/orders' + +// Revolut has no orders before this; starting earlier only wastes empty pages. +const PLUGIN_START_DATE = '2024-01-01T00:00:00.000Z' +// Re-query a window behind saved progress so orders that settle after a run are +// re-seen. Date-only bounds mean the smallest meaningful window is a day. +const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 7 // 7 days +const PAGE_LIMIT = 1000 +const MAX_RETRIES = 5 + +const asRevolutAmount = asObject({ + amount: asNumber, + currency: asString +}) + +// Statuses observed across the full live order history. Revolut does not +// publish the enum (the docs host rejects unauthenticated reads), so an +// unrecognised value degrades to 'other' rather than throwing and stalling the +// whole run on one in-flight order. +const asRevolutStatus = asMaybe( + asValue('COMPLETED', 'FAILED', 'AWAITING_PAYMENT'), + 'OTHER' +) +type RevolutStatus = ReturnType + +const statusMap: { [key in RevolutStatus]: Status } = { + COMPLETED: 'complete', + FAILED: 'failed', + AWAITING_PAYMENT: 'pending', + OTHER: 'other' +} + +// One order. Only `id`, `fiat`, `crypto`, `created_at` and `status` are +// guaranteed across the live set; everything else is absent on some rows (a +// FAILED order commonly has no `payment`, `wallet` or `transaction_hash`), so +// those use asMaybe rather than aborting the page on an ordinary failed order. +const asRevolutOrder = asObject({ + id: asString, + fiat: asRevolutAmount, + crypto: asObject({ + amount: asNumber, + currencyId: asString + }), + created_at: asString, + updated_at: asMaybe(asString), + status: asRevolutStatus, + payment: asMaybe(asString), + wallet: asMaybe(asString), + transaction_hash: asMaybe(asString) +}) +type RevolutOrder = ReturnType + +const asRevolutOrders = asArray(asUnknown) + +// Revolut's `crypto.currencyId` is either a bare code for a native asset +// ("BTC") or `CODE-CHAIN` for a token ("USDT-TRON"). This maps the chain suffix +// to an Edge pluginId. Codes seen across the full live order history: +// BTC ETH LTC SOL XRP DOGE XLM POL ADA AVAX ALGO, plus USDT/USDC/UNI on +// TRON, SOL, ETH and POL. +const NATIVE_PLUGIN_IDS: { [currencyCode: string]: string } = { + ADA: 'cardano', + ALGO: 'algorand', + AVAX: 'avalanche', + BTC: 'bitcoin', + DOGE: 'dogecoin', + ETH: 'ethereum', + LTC: 'litecoin', + POL: 'polygon', + SOL: 'solana', + XLM: 'stellar', + XRP: 'ripple' +} + +const CHAIN_SUFFIX_PLUGIN_IDS: { [suffix: string]: string } = { + ETH: 'ethereum', + POL: 'polygon', + SOL: 'solana', + TRON: 'tron' +} + +interface ResolvedRevolutAsset { + currencyCode: string + chainPluginId: string | undefined + tokenId: string | null | undefined + evmChainId: number | undefined +} + +/** + * Resolves a Revolut `currencyId` into Edge chain and token identifiers. + * + * A native asset resolves fully, with `tokenId: null` per Edge's convention for + * the chain's own gas asset. A token resolves its chain but leaves `tokenId` + * undefined: Revolut reports no contract address, and minting a tokenId from a + * guessed contract would mis-price the asset. Undefined leaves downstream rates + * lookup to fall back to the currency code, which is why the code is returned + * with the chain suffix stripped ("USDT-TRON" -> "USDT"). + */ +export function resolveRevolutAsset(currencyId: string): ResolvedRevolutAsset { + const upper = currencyId.toUpperCase() + + const nativePluginId = NATIVE_PLUGIN_IDS[upper] + if (nativePluginId != null) { + return { + currencyCode: upper, + chainPluginId: nativePluginId, + tokenId: null, + evmChainId: EVM_CHAIN_IDS[nativePluginId] + } + } + + const separatorIndex = upper.lastIndexOf('-') + if (separatorIndex > 0) { + const currencyCode = upper.slice(0, separatorIndex) + const chainPluginId = + CHAIN_SUFFIX_PLUGIN_IDS[upper.slice(separatorIndex + 1)] + if (chainPluginId != null) { + return { + currencyCode, + chainPluginId, + tokenId: undefined, + evmChainId: EVM_CHAIN_IDS[chainPluginId] + } + } + // An unknown chain suffix still yields a usable currency code. + return { + currencyCode, + chainPluginId: undefined, + tokenId: undefined, + evmChainId: undefined + } + } + + return { + currencyCode: upper, + chainPluginId: undefined, + tokenId: undefined, + evmChainId: undefined + } +} + +const toDateParam = (timestamp: number): string => + new Date(timestamp).toISOString().slice(0, 10) + +export interface RevolutAttempt { + order: RevolutOrder + raw: unknown +} + +const isBetterAttempt = ( + candidate: RevolutOrder, + incumbent: RevolutOrder +): boolean => { + const candidateComplete = candidate.status === 'COMPLETED' + const incumbentComplete = incumbent.status === 'COMPLETED' + if (candidateComplete !== incumbentComplete) return candidateComplete + return (candidate.updated_at ?? '') > (incumbent.updated_at ?? '') +} + +/** + * Collapses Revolut's per-attempt rows into one winner per order id. + * + * An order id is NOT unique in the response: Revolut returns one row per + * payment attempt, so the same id commonly appears both COMPLETED and FAILED + * (88 of 500 rows in one live sample, 197 across a full run). `orderId` keys + * the StandardTx document, so without this the same order is written twice and + * an arbitrary attempt wins. A settled attempt always beats an unsettled one; + * between two attempts of the same standing the later `updated_at` wins. + * + * Accumulates into the caller's map so the collapse spans pages, not just the + * page in hand. + */ +export function collectRevolutOrders( + rawOrders: unknown[], + bestByOrderId: Map +): void { + for (const rawOrder of rawOrders) { + const order = asRevolutOrder(rawOrder) + const incumbent = bestByOrderId.get(order.id) + if (incumbent == null || isBetterAttempt(order, incumbent.order)) { + bestByOrderId.set(order.id, { order, raw: rawOrder }) + } + } +} + +export async function queryRevolut( + pluginParams: PluginParams +): Promise { + const { log } = pluginParams + const { settings, apiKeys } = asStandardPluginParams(pluginParams) + const { apiKey } = apiKeys + + // An unprovisioned partner entry no-ops instead of failing every cycle. + if (apiKey == null || apiKey === '') { + return { + settings: { latestIsoDate: settings.latestIsoDate }, + transactions: [] + } + } + + let { latestIsoDate } = settings + if (latestIsoDate === EDGE_APP_START_DATE) { + latestIsoDate = PLUGIN_START_DATE + } + + // Progress persisted before this run. Only advanced past once the full walk + // completes, so an error-driven exit never skips unpaged orders. + const savedIsoDate = latestIsoDate + + let startTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK + if (startTimestamp < 0) startTimestamp = 0 + const now = Date.now() + + // `end` is exclusive of nothing in particular and date-only, so pad a day to + // be sure today's orders are inside the window. + const start = toDateParam(startTimestamp) + const end = toDateParam(now + 1000 * 60 * 60 * 24) + + const headers = { 'X-API-KEY': apiKey } + // The winning attempt per order id, with its untouched payload so StandardTx + // still carries the exact row Revolut sent. + const bestByOrderId = new Map() + + let skip = 0 + let retry = 0 + let completed = false + + while (true) { + const url = `${DEFAULT_API_URL}${ORDERS_PATH}?start=${start}&end=${end}&skip=${skip}&limit=${PAGE_LIMIT}` + try { + log(`Querying Revolut start:${start} end:${end} skip:${skip}`) + const response = await retryFetch(url, { method: 'GET', headers }) + if (!response.ok) { + throw new Error(await response.text()) + } + const rawOrders = asRevolutOrders(await response.json()) + + collectRevolutOrders(rawOrders, bestByOrderId) + + log(`Revolut skip:${skip} count:${rawOrders.length}`) + retry = 0 + + // A short page is the last page: there is no cursor or total to consult. + if (rawOrders.length < PAGE_LIMIT) { + completed = true + break + } + skip += rawOrders.length + } catch (e) { + log.error(String(e)) + retry++ + if (retry <= MAX_RETRIES) { + log.warn(`Snoozing ${5 * retry}s`) + await snooze(5000 * retry) + } else { + // Give up without advancing progress so the remainder is re-queried. + break + } + } + } + + const standardTxs: StandardTx[] = [] + for (const { raw } of bestByOrderId.values()) { + const standardTx = processRevolutTx(raw, pluginParams) + standardTxs.push(standardTx) + if (standardTx.isoDate > latestIsoDate) { + latestIsoDate = standardTx.isoDate + } + } + + return { + settings: { latestIsoDate: completed ? latestIsoDate : savedIsoDate }, + transactions: standardTxs + } +} + +export const revolut: PartnerPlugin = { + queryFunc: queryRevolut, + pluginName: 'Revolut', + pluginId: 'revolut' +} + +export function processRevolutTx( + rawTx: unknown, + pluginParams: PluginParams +): StandardTx { + const { log } = pluginParams + let tx: RevolutOrder + try { + tx = asRevolutOrder(rawTx) + } catch (e) { + log.error(`${String(e)}: ${JSON.stringify(rawTx)}`) + throw e + } + + const { isoDate, timestamp } = smartIsoDateFromTimestamp(tx.created_at) + const payout = resolveRevolutAsset(tx.crypto.currencyId) + + // Revolut Ramp only reports on-ramp orders, so fiat is always the deposit + // side and crypto always the payout side. `wallet` is the user's receiving + // address and `transaction_hash` the payout transaction. + return { + status: statusMap[tx.status], + orderId: tx.id, + countryCode: null, + depositTxid: undefined, + depositAddress: undefined, + depositCurrency: tx.fiat.currency.toUpperCase(), + depositChainPluginId: undefined, + depositEvmChainId: undefined, + depositTokenId: undefined, + depositAmount: tx.fiat.amount, + direction: 'buy', + exchangeType: 'fiat', + paymentType: getRevolutPaymentType(tx), + payoutTxid: tx.transaction_hash, + payoutAddress: tx.wallet, + payoutCurrency: payout.currencyCode, + payoutChainPluginId: payout.chainPluginId, + payoutEvmChainId: payout.evmChainId, + payoutTokenId: payout.tokenId, + payoutAmount: tx.crypto.amount, + timestamp, + isoDate, + usdValue: -1, + rawTx + } +} + +function getRevolutPaymentType(tx: RevolutOrder): FiatPaymentType | null { + switch (tx.payment) { + case 'revolut': + return 'revolut' + case 'card': + return 'credit' + default: + // Failed orders frequently carry no payment method at all, and a new + // method must not abort the run: a null paymentType is a supported + // StandardTx value, so degrade rather than throw. + return null + } +} diff --git a/src/queryEngine.ts b/src/queryEngine.ts index f20716bb..8f476de8 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -26,6 +26,7 @@ import { nymswap } from './partners/nym' import { paybis } from './partners/paybis' import { paytrie } from './partners/paytrie' import { rango } from './partners/rango' +import { revolut } from './partners/revolut' import { safello } from './partners/safello' import { sideshift } from './partners/sideshift' import { simplex } from './partners/simplex' @@ -80,6 +81,7 @@ const plugins = [ paybis, paytrie, rango, + revolut, safello, sideshift, simplex, diff --git a/test/revolut.test.ts b/test/revolut.test.ts new file mode 100644 index 00000000..39aca6d5 --- /dev/null +++ b/test/revolut.test.ts @@ -0,0 +1,262 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { + collectRevolutOrders, + processRevolutTx, + resolveRevolutAsset, + RevolutAttempt +} from '../src/partners/revolut' +import { PluginParams, ScopedLog } from '../src/types' + +// Fixtures follow the shape of Revolut Ramp's GET /partners/api/2.0/orders +// payloads. Wallet addresses and transaction hashes are SYNTHETIC placeholders, +// never live user-linked identifiers; only the field structure and the mapping +// behaviour are load-bearing. + +const noopLog: ScopedLog = Object.assign(() => undefined, { + warn: () => undefined, + error: () => undefined +}) + +const pluginParams: PluginParams = { + apiKeys: {}, + settings: {}, + log: noopLog +} + +const makeOrder = (overrides: object = {}): object => ({ + id: '00000000-0000-4000-8000-000000000001', + fiat: { amount: 100.5, currency: 'EUR' }, + crypto: { amount: 0.001, currencyId: 'BTC' }, + created_at: '2026-06-01T08:09:57.677612Z', + updated_at: '2026-06-01T08:14:11.888199Z', + status: 'COMPLETED', + payment: 'revolut', + wallet: 'bc1qexamplewalletaddress00000000000000000', + transaction_hash: 'a'.repeat(64), + ...overrides +}) + +describe('resolveRevolutAsset', function() { + it('maps a native asset to its chain with a null tokenId', function() { + expect(resolveRevolutAsset('BTC')).to.deep.equal({ + currencyCode: 'BTC', + chainPluginId: 'bitcoin', + tokenId: null, + evmChainId: undefined + }) + }) + + it('supplies an evmChainId for a native EVM asset', function() { + expect(resolveRevolutAsset('ETH')).to.deep.equal({ + currencyCode: 'ETH', + chainPluginId: 'ethereum', + tokenId: null, + evmChainId: 1 + }) + }) + + it('splits a CODE-CHAIN token into a clean code and its chain', function() { + // Revolut reports no contract address, so tokenId stays undefined rather + // than being minted from a guess. + expect(resolveRevolutAsset('USDT-POL')).to.deep.equal({ + currencyCode: 'USDT', + chainPluginId: 'polygon', + tokenId: undefined, + evmChainId: 137 + }) + }) + + it('maps a non-EVM token chain with no evmChainId', function() { + expect(resolveRevolutAsset('USDT-TRON')).to.deep.equal({ + currencyCode: 'USDT', + chainPluginId: 'tron', + tokenId: undefined, + evmChainId: undefined + }) + }) + + it('still yields a usable currency code for an unknown chain suffix', function() { + expect(resolveRevolutAsset('FOO-NEWCHAIN')).to.deep.equal({ + currencyCode: 'FOO', + chainPluginId: undefined, + tokenId: undefined, + evmChainId: undefined + }) + }) + + it('leaves an unknown bare code unmapped', function() { + expect(resolveRevolutAsset('FOO')).to.deep.equal({ + currencyCode: 'FOO', + chainPluginId: undefined, + tokenId: undefined, + evmChainId: undefined + }) + }) +}) + +describe('collectRevolutOrders', function() { + it('collapses repeated attempts of one order id to a single entry', function() { + const best = new Map() + collectRevolutOrders( + [ + makeOrder({ status: 'FAILED', updated_at: '2026-06-01T08:16:00.000Z' }), + makeOrder({ + status: 'COMPLETED', + updated_at: '2026-06-01T08:14:11.000Z' + }) + ], + best + ) + + expect(best.size).to.equal(1) + // A settled attempt wins even though the failed one was updated later. + expect( + best.get('00000000-0000-4000-8000-000000000001')?.order.status + ).to.equal('COMPLETED') + }) + + it('prefers the later attempt when neither is completed', function() { + const best = new Map() + collectRevolutOrders( + [ + makeOrder({ status: 'FAILED', updated_at: '2026-06-01T08:10:00.000Z' }), + makeOrder({ status: 'FAILED', updated_at: '2026-06-01T08:20:00.000Z' }) + ], + best + ) + + expect(best.size).to.equal(1) + expect( + best.get('00000000-0000-4000-8000-000000000001')?.order.updated_at + ).to.equal('2026-06-01T08:20:00.000Z') + }) + + it('collapses across pages, not just within one page', function() { + const best = new Map() + collectRevolutOrders( + [makeOrder({ status: 'FAILED', updated_at: '2026-06-01T08:10:00.000Z' })], + best + ) + collectRevolutOrders([makeOrder({ status: 'COMPLETED' })], best) + + expect(best.size).to.equal(1) + expect( + best.get('00000000-0000-4000-8000-000000000001')?.order.status + ).to.equal('COMPLETED') + }) + + it('keeps distinct order ids apart', function() { + const best = new Map() + collectRevolutOrders( + [makeOrder(), makeOrder({ id: '00000000-0000-4000-8000-000000000002' })], + best + ) + expect(best.size).to.equal(2) + }) + + it('preserves the untouched payload of the winning attempt', function() { + const winner = makeOrder({ status: 'COMPLETED' }) + const best = new Map() + collectRevolutOrders([makeOrder({ status: 'FAILED' }), winner], best) + + expect(best.get('00000000-0000-4000-8000-000000000001')?.raw).to.deep.equal( + winner + ) + }) +}) + +describe('processRevolutTx', function() { + it('maps a completed order to a buy-direction fiat StandardTx', function() { + const rawTx = makeOrder() + const standardTx = processRevolutTx(rawTx, pluginParams) + + expect(standardTx.status).to.equal('complete') + expect(standardTx.orderId).to.equal('00000000-0000-4000-8000-000000000001') + expect(standardTx.direction).to.equal('buy') + expect(standardTx.exchangeType).to.equal('fiat') + expect(standardTx.paymentType).to.equal('revolut') + // Fiat is always the deposit side on an on-ramp order. + expect(standardTx.depositCurrency).to.equal('EUR') + expect(standardTx.depositAmount).to.equal(100.5) + expect(standardTx.payoutCurrency).to.equal('BTC') + expect(standardTx.payoutAmount).to.equal(0.001) + expect(standardTx.payoutChainPluginId).to.equal('bitcoin') + expect(standardTx.payoutTokenId).to.equal(null) + expect(standardTx.payoutAddress).to.equal( + 'bc1qexamplewalletaddress00000000000000000' + ) + expect(standardTx.payoutTxid).to.equal('a'.repeat(64)) + expect(standardTx.isoDate).to.equal('2026-06-01T08:09:57.677Z') + expect(standardTx.usdValue).to.equal(-1) + expect(standardTx.rawTx).to.deep.equal(rawTx) + }) + + it('maps a failed order that carries no payment, wallet or hash', function() { + // The common shape of a FAILED row: the optional fields are simply absent. + const standardTx = processRevolutTx( + makeOrder({ + status: 'FAILED', + payment: null, + wallet: null, + transaction_hash: null + }), + pluginParams + ) + + expect(standardTx.status).to.equal('failed') + expect(standardTx.paymentType).to.equal(null) + expect(standardTx.payoutAddress).to.equal(undefined) + expect(standardTx.payoutTxid).to.equal(undefined) + }) + + it('maps an in-flight order to pending', function() { + const standardTx = processRevolutTx( + makeOrder({ status: 'AWAITING_PAYMENT' }), + pluginParams + ) + expect(standardTx.status).to.equal('pending') + }) + + it('degrades an unrecognised status to other instead of throwing', function() { + const standardTx = processRevolutTx( + makeOrder({ status: 'SOME_NEW_STATUS' }), + pluginParams + ) + expect(standardTx.status).to.equal('other') + }) + + it('maps a card payment to the credit payment type', function() { + const standardTx = processRevolutTx( + makeOrder({ payment: 'card' }), + pluginParams + ) + expect(standardTx.paymentType).to.equal('credit') + }) + + it('degrades an unknown payment method to null rather than throwing', function() { + const standardTx = processRevolutTx( + makeOrder({ payment: 'some_new_method' }), + pluginParams + ) + expect(standardTx.paymentType).to.equal(null) + }) + + it('carries the token chain through to the payout fields', function() { + const standardTx = processRevolutTx( + makeOrder({ crypto: { amount: 25.5, currencyId: 'USDT-ETH' } }), + pluginParams + ) + expect(standardTx.payoutCurrency).to.equal('USDT') + expect(standardTx.payoutChainPluginId).to.equal('ethereum') + expect(standardTx.payoutEvmChainId).to.equal(1) + expect(standardTx.payoutTokenId).to.equal(undefined) + }) + + it('throws on a structurally invalid order', function() { + expect(() => + processRevolutTx({ id: 'no-amounts' }, pluginParams) + ).to.throw() + }) +})