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 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 14e7dc4b..ee8c99e4 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' @@ -109,6 +113,10 @@ export default { type: 'swap', color: '#5891EE' }, + revolut: { + type: 'fiat', + color: '#191C33' + }, safello: { type: 'fiat', color: deprecated @@ -121,6 +129,10 @@ export default { type: 'swap', color: '#E35852' }, + swapter: { + type: 'swap', + color: '#00C9A7' + }, swapuz: { type: 'swap', color: '#56BD7C' 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/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/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..8f476de8 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -22,12 +22,15 @@ 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' +import { revolut } from './partners/revolut' 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' @@ -74,12 +77,15 @@ const plugins = [ lifi, maya, moonpay, + nymswap, paybis, paytrie, rango, + revolut, safello, sideshift, simplex, + swapter, swapuz, switchain, thorchain, 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) + }) +}) 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() + }) +}) 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