From 3dce03f186a127c05846f6ad219f4c28725222ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Moruj=C3=A3o?= Date: Thu, 26 Feb 2026 16:37:41 +0000 Subject: [PATCH 01/24] Feat: nexchange plugin Co-authored-by: Cursor --- src/demo/partners.ts | 4 + src/partners/nexchange.ts | 421 ++++++++++++++++++++++++++++++++++++++ src/queryEngine.ts | 2 + test/nexchange.test.ts | 276 +++++++++++++++++++++++++ 4 files changed, 703 insertions(+) create mode 100644 src/partners/nexchange.ts create mode 100644 test/nexchange.test.ts diff --git a/src/demo/partners.ts b/src/demo/partners.ts index 14e7dc4b..71244537 100644 --- a/src/demo/partners.ts +++ b/src/demo/partners.ts @@ -97,6 +97,10 @@ export default { type: 'fiat', color: '#7214F5' }, + nexchange: { + type: 'swap', + color: '#1D31B6' + }, paybis: { type: 'fiat', color: '#FFB400' diff --git a/src/partners/nexchange.ts b/src/partners/nexchange.ts new file mode 100644 index 00000000..798d9019 --- /dev/null +++ b/src/partners/nexchange.ts @@ -0,0 +1,421 @@ +import { + asArray, + asBoolean, + asEither, + asNull, + asObject, + asOptional, + asString, + asUnknown +} from 'cleaners' + +import { + asStandardPluginParams, + PartnerPlugin, + PluginParams, + PluginResult, + StandardTx, + Status +} from '../types' +import { retryFetch, safeParseFloat } from '../util' +import { createTokenId, tokenTypes } from '../util/asEdgeTokenId' +import { EVM_CHAIN_IDS } from '../util/chainIds' + +// n.exchange endpoints are fixed for all deployments; they intentionally are +// not exposed via apiKeys. Auth uses the modern `x-api-key` header — the +// legacy `Authorization: ApiKey ` form is not used. +const BASE_URL = 'https://api.n.exchange/en/api/v1' +const CURRENCY_URL = 'https://api.n.exchange/en/api/v2/currency/' + +const asNexchangeTransfer = asObject({ + currency: asString, + amount: asString, + address: asOptional(asEither(asString, asNull), null), + txid: asOptional(asEither(asString, asNull), null) +}) + +const asNexchangeOrder = asObject({ + orderId: asString, + status: asString, + createdAt: asString, + deposit: asNexchangeTransfer, + payout: asNexchangeTransfer, + countryCode: asOptional(asEither(asString, asNull), null) +}) + +const asNexchangeOrdersResponse = asObject({ + orders: asArray(asUnknown), + nextCursor: asOptional(asEither(asString, asNull), null), + hasMore: asBoolean +}) + +// Each entry from /api/v2/currency/. Only the fields below are needed to +// derive Edge chain plugin / token ids; other catalog fields (decimals, +// withdrawal_fee, etc.) are intentionally ignored. +const asNexchangeCurrencyMeta = asObject({ + code: asString, + is_fiat: asOptional(asBoolean, false), + network: asOptional(asEither(asString, asNull), null), + contract_address: asOptional(asEither(asString, asNull), null), + common_symbol: asOptional(asEither(asString, asNull), null) +}) + +const asNexchangeCurrencyList = asArray(asNexchangeCurrencyMeta) + +export type NexchangeCurrencyMeta = ReturnType +export type NexchangeCurrencyInfoMap = Record + +const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days +const LIMIT = 200 +const MAX_ERROR_TEXT_LENGTH = 500 + +const statusMap: { [key: string]: Status } = { + released: 'complete', + complete: 'complete', + completed: 'complete', + done: 'complete', + processing: 'processing', + exchanging: 'processing', + confirming: 'processing', + waiting: 'pending', + pending: 'pending', + created: 'pending', + new: 'pending', + expired: 'expired', + blocked: 'blocked', + refund: 'refunded', + refunded: 'refunded', + cancelled: 'other', + canceled: 'other', + failed: 'other' +} + +// Map of n.exchange network identifier -> Edge chain plugin id. +// Network strings are lowercased before lookup so we are resilient to casing +// changes from n.exchange (e.g. HyperEvm vs HYPEREVM). Networks that have no +// Edge equivalent are intentionally omitted; those transactions will be +// reported without chain/token id enrichment. +// +// n.exchange uses TRON as the canonical network name in the v2 currency +// catalog, but historical Edge audit-orders payloads have also been observed +// to reference TRX — both are mapped so the plugin works regardless of which +// the API returns. +export const NEXCHANGE_NETWORK_TO_PLUGIN_ID: Record = { + ada: 'cardano', + algo: 'algorand', + arb: 'arbitrum', + atom: 'cosmoshub', + avaxc: 'avalanche', + base: 'base', + bch: 'bitcoincash', + bsc: 'binancesmartchain', + btc: 'bitcoin', + dash: 'dash', + doge: 'dogecoin', + dot: 'polkadot', + eos: 'eos', + etc: 'ethereumclassic', + eth: 'ethereum', + fil: 'filecoin', + filevm: 'filecoinfevm', + ftm: 'fantom', + hbar: 'hedera', + hyperevm: 'hyperevm', + ltc: 'litecoin', + // n.exchange exposes both MATIC and POL networks; both reference the same + // Polygon chain (chain id 137). + matic: 'polygon', + op: 'optimism', + pol: 'polygon', + sol: 'solana', + sonic: 'sonic', + sui: 'sui', + ton: 'ton', + tron: 'tron', + trx: 'tron', + xlm: 'stellar', + xmr: 'monero', + xrp: 'ripple', + xtz: 'tezos', + zec: 'zcash' +} + +export function toQueryIsoDate(latestIsoDate: string): string { + let previousTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK + if (previousTimestamp < 0) previousTimestamp = 0 + return new Date(previousTimestamp).toISOString() +} + +export function parseApiDate( + dateString: string +): { isoDate: string; timestamp: number } { + const hasTimezone = /(Z|[+-]\d{2}:\d{2})$/.test(dateString) + const normalized = hasTimezone ? dateString : `${dateString}Z` + const date = new Date(normalized) + if (isNaN(date.getTime())) { + throw new Error(`Invalid createdAt date: ${dateString}`) + } + return { + isoDate: date.toISOString(), + timestamp: date.getTime() / 1000 + } +} + +function truncateForError(text: string): string { + return text.length > MAX_ERROR_TEXT_LENGTH + ? `${text.slice(0, MAX_ERROR_TEXT_LENGTH)}…` + : text +} + +/** + * Fetches the n.exchange currency catalog and returns a lookup keyed by the + * uppercased currency code. The catalog supplies the network and contract + * address fields that the audit-orders endpoint omits, which Edge needs to + * populate chain plugin id and token id. + */ +export async function fetchNexchangeCurrencyMap(): Promise< + NexchangeCurrencyInfoMap +> { + const response = await retryFetch(CURRENCY_URL, { method: 'GET' }) + if (!response.ok) { + const text = await response.text() + throw new Error( + `HTTP ${response.status.toString()}: ${truncateForError(text)}` + ) + } + const json = await response.json() + const currencies = asNexchangeCurrencyList(json) + const map: NexchangeCurrencyInfoMap = {} + for (const currency of currencies) { + map[currency.code.toUpperCase()] = currency + } + return map +} + +/** + * Returned by `resolveNexchangeAsset`. The shape is consistent across all + * exit branches so callers can rely on the field set. `chainPluginId`, + * `tokenId`, and `evmChainId` are `undefined` whenever the asset cannot be + * mapped to an Edge chain/token; `tokenId` is `null` to mean "native chain + * asset" (per Edge's tokenId conventions), so the distinction between + * "unmapped" and "native" is preserved. + */ +export interface ResolvedNexchangeAsset { + currencyCode: string + chainPluginId: string | undefined + tokenId: string | null | undefined + evmChainId: number | undefined +} + +function asUnmapped(currencyCode: string): ResolvedNexchangeAsset { + return { + currencyCode, + chainPluginId: undefined, + tokenId: undefined, + evmChainId: undefined + } +} + +/** + * Resolves an n.exchange currency code into Edge chain plugin and token + * identifiers. Returns an "unmapped" shape (all chain fields undefined) when + * the network is unknown to Edge, when no metadata is available, or when the + * currency is fiat. Callers should leave the corresponding StandardTx + * fields undefined in that case so downstream rates lookup can fall back to + * currency-code mappings. + * + * Throws if a token-supporting chain has a contract address that cannot be + * converted into an Edge tokenId, so the bad payload is surfaced rather than + * silently producing an unenriched transaction. + */ +export function resolveNexchangeAsset( + currencyCode: string, + currencyMap: NexchangeCurrencyInfoMap +): ResolvedNexchangeAsset { + const upper = currencyCode.toUpperCase() + const meta = currencyMap[upper] + + // Default to the raw nexchange code; downstream `standardizeNames` handles + // some of the composite codes (e.g. USDCSOL -> USDC). + let normalizedCode = upper + + if (meta == null) return asUnmapped(normalizedCode) + + // Prefer the canonical symbol when n.exchange supplies a clean ticker. + // Some entries embed suffixes like "USDT-old"; only use the symbol when it + // is alphanumeric. + if ( + meta.common_symbol != null && + meta.common_symbol !== '' && + /^[A-Za-z0-9]+$/.test(meta.common_symbol) + ) { + normalizedCode = meta.common_symbol.toUpperCase() + } + + if (meta.is_fiat) return asUnmapped(normalizedCode) + + const network = meta.network + if (network == null || network === '') return asUnmapped(normalizedCode) + + const chainPluginId = NEXCHANGE_NETWORK_TO_PLUGIN_ID[network.toLowerCase()] + if (chainPluginId == null) return asUnmapped(normalizedCode) + + const evmChainId = EVM_CHAIN_IDS[chainPluginId] + const contractAddress = meta.contract_address + + // No contract_address means a native chain asset. + if (contractAddress == null || contractAddress === '') { + return { + currencyCode: normalizedCode, + chainPluginId, + tokenId: null, + evmChainId + } + } + + // The contract address is present but the chain does not support tokens in + // Edge's model; fall back to a chain-only mapping so we at least populate + // the chain plugin id for rates lookup. + const tokenType = tokenTypes[chainPluginId] + if (tokenType == null) { + return { + currencyCode: normalizedCode, + chainPluginId, + tokenId: null, + evmChainId + } + } + + const tokenId = createTokenId(tokenType, normalizedCode, contractAddress) + return { currencyCode: normalizedCode, chainPluginId, tokenId, evmChainId } +} + +export async function queryNexchange( + pluginParams: PluginParams +): Promise { + const { log } = pluginParams + const { settings, apiKeys } = asStandardPluginParams(pluginParams) + const { apiKey } = apiKeys + let { latestIsoDate } = settings + + if (apiKey == null || apiKey === '') { + return { settings: { latestIsoDate }, transactions: [] } + } + + const headers = { 'x-api-key': apiKey } + const queryDateFrom = toQueryIsoDate(latestIsoDate) + const txByOrderId: Map = new Map() + let cursor: string | undefined + let offset = 0 + + try { + // The currency catalog supplies the network/contract metadata that the + // audit-orders endpoint omits, so it is required for chain/token + // enrichment. Fetch it up front; a failure aborts the run (saving + // nothing) rather than persisting a batch of unenriched transactions. + const currencyMap = await fetchNexchangeCurrencyMap() + + while (true) { + const params: string[] = [ + `dateFrom=${encodeURIComponent(queryDateFrom)}`, + `limit=${LIMIT.toString()}`, + 'sortDirection=ASC' + ] + if (cursor != null && cursor !== '') { + params.push(`cursor=${encodeURIComponent(cursor)}`) + } else { + params.push(`offset=${offset.toString()}`) + } + + const url = `${BASE_URL}/audits/edge/orders?${params.join('&')}` + const response = await retryFetch(url, { headers, method: 'GET' }) + if (!response.ok) { + const text = await response.text() + throw new Error( + `HTTP ${response.status.toString()}: ${truncateForError(text)}` + ) + } + const json = await response.json() + const { orders, nextCursor, hasMore } = asNexchangeOrdersResponse(json) + + for (const rawOrder of orders) { + const standardTx = processNexchangeTx(rawOrder, currencyMap) + txByOrderId.set(standardTx.orderId, standardTx) + if (standardTx.isoDate > latestIsoDate) { + latestIsoDate = standardTx.isoDate + } + } + log(`latestIsoDate ${latestIsoDate}`) + + if (!hasMore || orders.length === 0) break + + if (nextCursor != null && nextCursor !== '') { + cursor = nextCursor + } else { + // Reset cursor when falling back to offset, otherwise the previous + // cursor value would re-pin pagination to the wrong position next + // iteration. + cursor = undefined + offset += orders.length + } + } + } catch (e) { + log.error(String(e)) + // Do not re-throw. Pagination is oldest -> newest, so any transactions + // already collected are fully processed and older than latestIsoDate; we + // can safely persist that progress and resume from it next run. A failing + // order halts pagination (it is never silently skipped) so its volume is + // retried rather than lost. + } + + return { + settings: { latestIsoDate }, + transactions: Array.from(txByOrderId.values()) + } +} + +export const nexchange: PartnerPlugin = { + queryFunc: queryNexchange, + pluginName: 'Nexchange', + pluginId: 'nexchange' +} + +export function processNexchangeTx( + rawTx: unknown, + currencyMap: NexchangeCurrencyInfoMap +): StandardTx { + const tx = asNexchangeOrder(rawTx) + const lowerStatus = tx.status.toLowerCase() + const status = statusMap[lowerStatus] ?? 'other' + const { isoDate, timestamp } = parseApiDate(tx.createdAt) + + const deposit = resolveNexchangeAsset(tx.deposit.currency, currencyMap) + const payout = resolveNexchangeAsset(tx.payout.currency, currencyMap) + + return { + status, + orderId: tx.orderId, + countryCode: tx.countryCode, + depositTxid: tx.deposit.txid ?? undefined, + depositAddress: tx.deposit.address ?? undefined, + depositCurrency: deposit.currencyCode, + depositChainPluginId: deposit.chainPluginId, + depositTokenId: deposit.tokenId, + depositEvmChainId: deposit.evmChainId, + depositAmount: safeParseFloat(tx.deposit.amount), + direction: null, + exchangeType: 'swap', + paymentType: null, + payoutTxid: tx.payout.txid ?? undefined, + payoutAddress: tx.payout.address ?? undefined, + payoutCurrency: payout.currencyCode, + payoutChainPluginId: payout.chainPluginId, + payoutTokenId: payout.tokenId, + payoutEvmChainId: payout.evmChainId, + payoutAmount: safeParseFloat(tx.payout.amount), + timestamp, + isoDate, + usdValue: -1, + rawTx + } +} diff --git a/src/queryEngine.ts b/src/queryEngine.ts index 5c9ce353..0d7cea08 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 { nexchange } from './partners/nexchange' import { paybis } from './partners/paybis' import { paytrie } from './partners/paytrie' import { rango } from './partners/rango' @@ -74,6 +75,7 @@ const plugins = [ lifi, maya, moonpay, + nexchange, paybis, paytrie, rango, diff --git a/test/nexchange.test.ts b/test/nexchange.test.ts new file mode 100644 index 00000000..38b3e406 --- /dev/null +++ b/test/nexchange.test.ts @@ -0,0 +1,276 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { + NEXCHANGE_NETWORK_TO_PLUGIN_ID, + NexchangeCurrencyInfoMap, + parseApiDate, + processNexchangeTx, + resolveNexchangeAsset, + toQueryIsoDate +} from '../src/partners/nexchange' + +const currencyMap: NexchangeCurrencyInfoMap = { + BTC: { + code: 'BTC', + is_fiat: false, + network: 'BTC', + contract_address: null, + common_symbol: 'BTC' + }, + USDTTRX: { + code: 'USDTTRX', + is_fiat: false, + network: 'TRON', + contract_address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + common_symbol: 'USDT' + }, + USDCSOL: { + code: 'USDCSOL', + is_fiat: false, + network: 'SOL', + contract_address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + common_symbol: 'USDC' + }, + USDTERC: { + code: 'USDTERC', + is_fiat: false, + network: 'ETH', + contract_address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + common_symbol: 'USDT' + }, + ETHBASE: { + code: 'ETHBASE', + is_fiat: false, + network: 'BASE', + contract_address: null, + common_symbol: 'ETH' + }, + HYPE: { + code: 'HYPE', + is_fiat: false, + network: 'HyperEvm', + contract_address: null, + common_symbol: null + }, + USDTMATIC: { + code: 'USDTMATIC', + is_fiat: false, + network: 'MATIC', + contract_address: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', + common_symbol: 'USDT-old' + }, + USD: { + code: 'USD', + is_fiat: true, + network: null, + contract_address: null, + common_symbol: null + }, + XYZTOKEN: { + code: 'XYZTOKEN', + is_fiat: false, + network: 'UNKNOWNNET', + contract_address: '0xdeadbeef', + common_symbol: 'XYZ' + }, + BADTOKEN: { + code: 'BADTOKEN', + is_fiat: false, + network: 'ATOM', + contract_address: 'NOT A VALID DENOM', + common_symbol: 'BAD' + } +} + +function makeRawOrder(overrides: { [key: string]: any } = {}): unknown { + return { + orderId: 'NEX-DEFAULT', + status: 'Released', + createdAt: '2026-01-20T11:43:10+00:00', + deposit: { + currency: 'USDTTRX', + amount: '100.00000000', + address: 'TQhaM...sample', + txid: '0xdep123' + }, + payout: { + currency: 'BTC', + amount: '0.00145000', + address: 'bc1q...sample', + txid: '0xpay123' + }, + countryCode: 'PT', + ...overrides + } +} + +describe('nexchange plugin', () => { + describe('processNexchangeTx', () => { + it('maps Edge audit order payload into StandardTx with chain plugin and token ids', () => { + const tx = processNexchangeTx( + makeRawOrder({ orderId: 'NEX-ABCD1234' }), + currencyMap + ) + + expect(tx.orderId).to.equal('NEX-ABCD1234') + expect(tx.status).to.equal('complete') + expect(tx.exchangeType).to.equal('swap') + expect(tx.direction).to.equal(null) + expect(tx.depositCurrency).to.equal('USDT') + expect(tx.depositChainPluginId).to.equal('tron') + expect(tx.depositTokenId).to.equal('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t') + expect(tx.depositEvmChainId).to.equal(undefined) + expect(tx.payoutCurrency).to.equal('BTC') + expect(tx.payoutChainPluginId).to.equal('bitcoin') + expect(tx.payoutTokenId).to.equal(null) + expect(tx.depositAmount).to.equal(100) + expect(tx.payoutAmount).to.equal(0.00145) + expect(tx.countryCode).to.equal('PT') + expect(tx.isoDate).to.equal('2026-01-20T11:43:10.000Z') + expect(tx.timestamp).to.equal(1768909390) + }) + + const statusCases: Array<[string, string]> = [ + ['Released', 'complete'], + ['completed', 'complete'], + ['done', 'complete'], + ['processing', 'processing'], + ['confirming', 'processing'], + ['pending', 'pending'], + ['NEW', 'pending'], + ['Waiting', 'pending'], + ['expired', 'expired'], + ['blocked', 'blocked'], + ['Refund', 'refunded'], + ['refunded', 'refunded'], + ['cancelled', 'other'], + ['canceled', 'other'], + ['failed', 'other'], + ['something-else', 'other'] + ] + for (const [rawStatus, expected] of statusCases) { + it(`maps status "${rawStatus}" to "${expected}"`, () => { + const tx = processNexchangeTx( + makeRawOrder({ status: rawStatus }), + currencyMap + ) + expect(tx.status).to.equal(expected) + }) + } + }) + + describe('resolveNexchangeAsset', () => { + it('lowercases and 0x-strips EVM token addresses and returns the EVM chain id', () => { + const asset = resolveNexchangeAsset('USDTERC', currencyMap) + expect(asset.currencyCode).to.equal('USDT') + expect(asset.chainPluginId).to.equal('ethereum') + expect(asset.tokenId).to.equal('dac17f958d2ee523a2206206994597c13d831ec7') + expect(asset.evmChainId).to.equal(1) + }) + + it('returns null tokenId for native EVM assets (e.g. ETHBASE)', () => { + const asset = resolveNexchangeAsset('ETHBASE', currencyMap) + expect(asset.currencyCode).to.equal('ETH') + expect(asset.chainPluginId).to.equal('base') + expect(asset.tokenId).to.equal(null) + expect(asset.evmChainId).to.equal(8453) + }) + + it('matches mixed-case n.exchange networks case-insensitively', () => { + const asset = resolveNexchangeAsset('HYPE', currencyMap) + expect(asset.chainPluginId).to.equal('hyperevm') + expect(asset.tokenId).to.equal(null) + expect(asset.evmChainId).to.equal(999) + }) + + it('returns unmapped fields when the currency is not in the catalog', () => { + const asset = resolveNexchangeAsset('XYZ', currencyMap) + expect(asset.currencyCode).to.equal('XYZ') + expect(asset.chainPluginId).to.equal(undefined) + expect(asset.tokenId).to.equal(undefined) + expect(asset.evmChainId).to.equal(undefined) + }) + + it('returns unmapped fields for fiat currencies', () => { + const asset = resolveNexchangeAsset('USD', currencyMap) + expect(asset.currencyCode).to.equal('USD') + expect(asset.chainPluginId).to.equal(undefined) + expect(asset.tokenId).to.equal(undefined) + expect(asset.evmChainId).to.equal(undefined) + }) + + it('returns unmapped fields when the network is unknown to Edge', () => { + const asset = resolveNexchangeAsset('XYZTOKEN', currencyMap) + expect(asset.currencyCode).to.equal('XYZ') + expect(asset.chainPluginId).to.equal(undefined) + expect(asset.tokenId).to.equal(undefined) + expect(asset.evmChainId).to.equal(undefined) + }) + + it('keeps the raw currency code when common_symbol is non-alphanumeric (e.g. "USDT-old")', () => { + const asset = resolveNexchangeAsset('USDTMATIC', currencyMap) + expect(asset.currencyCode).to.equal('USDTMATIC') + expect(asset.chainPluginId).to.equal('polygon') + expect(asset.tokenId).to.equal('c2132d05d31c914a87c6611c10748aeb04b58e8f') + }) + + it('throws when a token chain has a contract address that fails createTokenId', () => { + expect(() => resolveNexchangeAsset('BADTOKEN', currencyMap)).to.throw( + /Invalid contract address/ + ) + }) + }) + + describe('parseApiDate', () => { + it('parses an offset-suffixed date', () => { + const result = parseApiDate('2026-01-20T11:43:10+00:00') + expect(result.isoDate).to.equal('2026-01-20T11:43:10.000Z') + expect(result.timestamp).to.equal(1768909390) + }) + + it('parses a Z-suffixed date', () => { + const result = parseApiDate('2026-01-20T11:43:10Z') + expect(result.isoDate).to.equal('2026-01-20T11:43:10.000Z') + }) + + it('appends Z when no timezone suffix is present', () => { + const result = parseApiDate('2026-01-20T11:43:10') + expect(result.isoDate).to.equal('2026-01-20T11:43:10.000Z') + }) + + it('throws on an invalid date string', () => { + expect(() => parseApiDate('not-a-date')).to.throw(/Invalid createdAt/) + }) + }) + + describe('toQueryIsoDate', () => { + it('rewinds latestIsoDate by the lookback window', () => { + const result = toQueryIsoDate('2026-01-20T00:00:00.000Z') + expect(result).to.equal('2026-01-15T00:00:00.000Z') + }) + + it('clamps to the epoch when latestIsoDate is near zero', () => { + const result = toQueryIsoDate('1970-01-01T00:00:00.000Z') + expect(result).to.equal('1970-01-01T00:00:00.000Z') + }) + }) + + describe('NEXCHANGE_NETWORK_TO_PLUGIN_ID', () => { + it('covers the representative n.exchange networks used by Edge users', () => { + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.eth).to.equal('ethereum') + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.bsc).to.equal('binancesmartchain') + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.sol).to.equal('solana') + }) + + it('maps both TRON and TRX (the v2 catalog and historical audit forms)', () => { + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.tron).to.equal('tron') + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.trx).to.equal('tron') + }) + + it('maps both MATIC and POL to the same Polygon chain id', () => { + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.matic).to.equal('polygon') + expect(NEXCHANGE_NETWORK_TO_PLUGIN_ID.pol).to.equal('polygon') + }) + }) +}) From 98dcb27b332101947356181cd7d42ace47ca33d5 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Thu, 28 May 2026 16:11:43 -0700 Subject: [PATCH 02/24] Throw instead of pricing tokens as native gas tokens When an asset has a contract address it is a token, but several plugins silently fell back to a native (tokenId: null) mapping when the token could not be resolved. That prices the token with the chain's gas-token rate and overcounts volume whenever the token is worth less than the gas token. - nexchange: throw when a contract-bearing asset is on a chain whose tokenType is missing, instead of returning tokenId: null. - changenow: drop the try/catch around createTokenId that swallowed failures and returned tokenId: null. - rango: drop the per-tx try/catch that logged and continued, silently dropping any transaction whose asset could not be resolved. All three plugins paginate oldest-to-newest and persist progress on throw, so a failing order halts and is retried next run rather than being mispriced or silently dropped. Also add the SUI and MONAD chain mappings to rango: these were previously dropped silently and would now halt the plugin. Verified by reprocessing the last three months of orders (nexchange 22.7k, changenow 61.9k, rango 2.9k) with zero processing failures. Co-authored-by: Cursor --- src/partners/changenow.ts | 31 +++++++++++++------------------ src/partners/nexchange.ts | 26 ++++++++++++++------------ src/partners/rango.ts | 24 +++++++++++++----------- test/nexchange.test.ts | 18 ++++++++++++++++++ 4 files changed, 58 insertions(+), 41 deletions(-) diff --git a/src/partners/changenow.ts b/src/partners/changenow.ts index e4eb6814..f91c9ca6 100644 --- a/src/partners/changenow.ts +++ b/src/partners/changenow.ts @@ -376,24 +376,19 @@ function getAssetInfo(network: string, currencyCode: string): EdgeAssetInfo { ) } - try { - const tokenId = createTokenId( - tokenType, - currencyCode.toUpperCase(), - contractAddress - ) - return { - chainPluginId, - evmChainId, - tokenId - } - } catch (e) { - // If tokenId creation fails, treat as native (no log available in this sync function) - return { - chainPluginId, - evmChainId, - tokenId: null - } + // Let createTokenId throw if the contract address cannot be converted: a + // token must never be silently downgraded to a native (tokenId: null) + // mapping, which would price it with the chain's gas-token rate and + // overcount volume whenever the token is worth less than the gas token. + const tokenId = createTokenId( + tokenType, + currencyCode.toUpperCase(), + contractAddress + ) + return { + chainPluginId, + evmChainId, + tokenId } } diff --git a/src/partners/nexchange.ts b/src/partners/nexchange.ts index 798d9019..bf27fcb0 100644 --- a/src/partners/nexchange.ts +++ b/src/partners/nexchange.ts @@ -224,9 +224,12 @@ function asUnmapped(currencyCode: string): ResolvedNexchangeAsset { * fields undefined in that case so downstream rates lookup can fall back to * currency-code mappings. * - * Throws if a token-supporting chain has a contract address that cannot be - * converted into an Edge tokenId, so the bad payload is surfaced rather than - * silently producing an unenriched transaction. + * Throws when an asset has a contract address (i.e. it is a token) but cannot + * be converted into an Edge tokenId — either because Edge does not model + * tokens on that chain, or because the address fails createTokenId. This is + * deliberate: a token must never be silently downgraded to a native + * (tokenId: null) mapping, which would price it with the chain's gas-token + * rate and overcount volume. */ export function resolveNexchangeAsset( currencyCode: string, @@ -273,17 +276,16 @@ export function resolveNexchangeAsset( } } - // The contract address is present but the chain does not support tokens in - // Edge's model; fall back to a chain-only mapping so we at least populate - // the chain plugin id for rates lookup. + // The contract address is present, so this is a token. If Edge does not + // model tokens on this chain we must NOT fall back to a native + // (tokenId: null) mapping: that would price the token using the chain's + // gas-token rate and overcount volume whenever the token is worth less than + // the gas token. Surface the gap loudly instead. const tokenType = tokenTypes[chainPluginId] if (tokenType == null) { - return { - currencyCode: normalizedCode, - chainPluginId, - tokenId: null, - evmChainId - } + throw new Error( + `Unknown tokenType for chainPluginId "${chainPluginId}" (currency: ${normalizedCode}, contract: ${contractAddress}). Add tokenType to tokenTypes.` + ) } const tokenId = createTokenId(tokenType, normalizedCode, contractAddress) diff --git a/src/partners/rango.ts b/src/partners/rango.ts index 62445ee8..8a122089 100644 --- a/src/partners/rango.ts +++ b/src/partners/rango.ts @@ -115,10 +115,12 @@ const RANGO_BLOCKCHAIN_TO_PLUGIN_ID: Record = { FANTOM: 'fantom', LTC: 'litecoin', MATIC: 'polygon', + MONAD: 'monad', OPTIMISM: 'optimism', OSMOSIS: 'osmosis', POLYGON: 'polygon', SOLANA: 'solana', + SUI: 'sui', TON: 'ton', TRON: 'tron', XRPL: 'ripple', @@ -178,17 +180,17 @@ export async function queryRango( let processedCount = 0 for (const rawTx of txs) { - try { - const standardTx = processRangoTx(rawTx, pluginParams) - standardTxs.push(standardTx) - processedCount++ - - if (standardTx.isoDate > latestIsoDate) { - latestIsoDate = standardTx.isoDate - } - } catch (e) { - // Log but continue processing other transactions - log.warn(`Failed to process tx: ${String(e)}`) + // Do not catch per-tx errors: a failure here (e.g. a token that cannot + // be resolved to a tokenId) must halt the run rather than silently + // dropping the transaction. The outer catch saves progress up to the + // last fully processed tx, and the oldest-to-newest ordering means the + // failing tx is retried on the next run. + const standardTx = processRangoTx(rawTx, pluginParams) + standardTxs.push(standardTx) + processedCount++ + + if (standardTx.isoDate > latestIsoDate) { + latestIsoDate = standardTx.isoDate } } diff --git a/test/nexchange.test.ts b/test/nexchange.test.ts index 38b3e406..b287b0c2 100644 --- a/test/nexchange.test.ts +++ b/test/nexchange.test.ts @@ -80,6 +80,15 @@ const currencyMap: NexchangeCurrencyInfoMap = { network: 'ATOM', contract_address: 'NOT A VALID DENOM', common_symbol: 'BAD' + }, + // A token (has a contract address) on a chain Edge does not model tokens for. + USDCXLM: { + code: 'USDCXLM', + is_fiat: false, + network: 'XLM', + contract_address: + 'USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + common_symbol: 'USDC' } } @@ -220,6 +229,15 @@ describe('nexchange plugin', () => { /Invalid contract address/ ) }) + + it('throws for a token (contract address) on a chain Edge does not model tokens for', () => { + // USDC on Stellar: pricing it as native XLM would overcount volume, so + // the unmapped token type must surface as an error rather than fall back + // to tokenId: null. + expect(() => resolveNexchangeAsset('USDCXLM', currencyMap)).to.throw( + /Unknown tokenType for chainPluginId "stellar"/ + ) + }) }) describe('parseApiDate', () => { From cb7944ee7e5d20fc58d3a4c94fa032f75b86207d Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Fri, 29 May 2026 07:03:45 -0700 Subject: [PATCH 03/24] Add Xgram Co-authored-by: Cursor --- src/demo/partners.ts | 4 + src/partners/xgram.ts | 506 ++++++++++++++++++++++++++++++++++++++++++ src/queryEngine.ts | 4 +- test/xgram.test.ts | 126 +++++++++++ 4 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 src/partners/xgram.ts create mode 100644 test/xgram.test.ts diff --git a/src/demo/partners.ts b/src/demo/partners.ts index 71244537..97048697 100644 --- a/src/demo/partners.ts +++ b/src/demo/partners.ts @@ -156,5 +156,9 @@ export default { xanpool: { type: 'fiat', color: '#46228B' + }, + xgram: { + type: 'swap', + color: '#0FA7B1' } } as const diff --git a/src/partners/xgram.ts b/src/partners/xgram.ts new file mode 100644 index 00000000..e09a8f49 --- /dev/null +++ b/src/partners/xgram.ts @@ -0,0 +1,506 @@ +import { + asArray, + asEither, + asMap, + asMaybe, + asNumber, + asObject, + asOptional, + asString, + asUnknown, + asValue +} from 'cleaners' + +import { + asStandardPluginParams, + PartnerPlugin, + PluginParams, + PluginResult, + StandardTx, + Status +} from '../types' +import { retryFetch, safeParseFloat, snooze } from '../util' +import { createTokenId, EdgeTokenId, tokenTypes } from '../util/asEdgeTokenId' +import { EVM_CHAIN_IDS } from '../util/chainIds' + +const asXgramStatus = asMaybe( + asValue( + 'x-new', + 'x-awaiting_funds', + 'x-funds_received', + 'x-processing_exchange', + 'x-transferring', + 'x-completed', + 'x-timeout', + 'x-error', + 'x-transfer_error', + 'x-returned' + ), + 'other' +) + +const asXgramAmount = asMaybe(asEither(asNumber, asString), null) + +const asXgramTx = asObject({ + date: asString, + id: asString, + 'x-status': asXgramStatus, + 'x-fromCcy': asString, + 'x-toCcy': asString, + 'x-ccyDepositAddress': asString, + 'x-ccyDepositHash': asMaybe(asString, undefined), + 'x-ccyExpectedAmountFrom': asNumber, + 'x-ccyExpectedAmountTo': asNumber, + 'x-ccyAmountFrom': asXgramAmount, + 'x-ccyDestinationAddress': asString, + 'x-ccyAmountTo': asXgramAmount, + txId: asMaybe(asString, undefined) +}) + +const asXgramResult = asObject({ exchanges: asArray(asUnknown) }) +const asXgramCurrency = asObject({ + coinName: asString, + network: asString, + contract: asOptional(asString, '') +}) +const asXgramCurrencies = asMap(asXgramCurrency) + +type XgramTxTx = ReturnType +type XgramStatus = ReturnType +export type XgramCurrencies = ReturnType + +interface EdgeAssetInfo { + chainPluginId: string + evmChainId: number | undefined + tokenId: EdgeTokenId +} + +const MAX_RETRIES = 5 +const LIMIT = 50 +const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days +const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours + +const statusMap: { [key in XgramStatus]: Status } = { + 'x-new': 'pending', + 'x-awaiting_funds': 'confirming', + 'x-funds_received': 'processing', + 'x-processing_exchange': 'processing', + 'x-transferring': 'withdrawing', + 'x-completed': 'complete', + 'x-timeout': 'expired', + 'x-error': 'failed', + 'x-transfer_error': 'failed', + 'x-returned': 'refunded', + other: 'other' +} + +const XGRAM_NETWORK_TO_PLUGIN_ID: Record = { + ADA: 'cardano', + Algorand: 'algorand', + ARBITRUM: 'arbitrum', + AVAX: 'avalanche', + 'AVAX C-Chain': 'avalanche', + AVAXC: 'avalanche', + BASE: 'base', + BEP20: 'binancesmartchain', + Bitcoin: 'bitcoin', + BitcoinCash: 'bitcoincash', + 'Bitcoin SV': 'bitcoinsv', + BitcoinGold: 'bitcoingold', + CELO: 'celo', + Cosmos: 'cosmoshub', + 'Digital Cash': 'dash', + EOS: 'eos', + ERC20: 'ethereum', + ETH: 'ethereum', + EthereumPoW: 'ethereumpow', + Fantom: 'fantom', + Filecoin: 'filecoin', + FIO: 'fio', + Hedera: 'hedera', + Litecoin: 'litecoin', + Monero: 'monero', + OPTIMISM: 'optimism', + Polkadot: 'polkadot', + POLYGON: 'polygon', + Quantum: 'qtum', + Ravencoin: 'ravencoin', + RBTC: 'rsk', + Ripple: 'ripple', + SOL: 'solana', + 'Stellar Lumens': 'stellar', + SUI: 'sui', + Tezos: 'tezos', + TON: 'ton', + TRC20: 'tron', + Vertcoin: 'vertcoin', + Wax: 'wax', + XEC: 'ecash', + ZANO: 'zano', + Zcash: 'zcash', + Zcoin: 'zcoin', + ZKSYNC: 'zksync' +} + +const NATIVE_TICKERS: Record> = { + algorand: new Set(['ALGO']), + arbitrum: new Set(['ETH']), + avalanche: new Set(['AVAX']), + base: new Set(['ETH']), + binancesmartchain: new Set(['BNB']), + bitcoin: new Set(['BTC']), + bitcoincash: new Set(['BCH']), + bitcoingold: new Set(['BTG']), + bitcoinsv: new Set(['BSV']), + cardano: new Set(['ADA']), + celo: new Set(['CELO']), + cosmoshub: new Set(['ATOM']), + dash: new Set(['DASH']), + ecash: new Set(['XEC']), + eos: new Set(['EOS']), + ethereum: new Set(['ETH']), + ethereumpow: new Set(['ETHW']), + fantom: new Set(['FTM']), + filecoin: new Set(['FIL']), + fio: new Set(['FIO']), + hedera: new Set(['HBAR']), + litecoin: new Set(['LTC']), + monero: new Set(['XMR']), + optimism: new Set(['ETH']), + polkadot: new Set(['DOT']), + polygon: new Set(['MATIC', 'POL']), + qtum: new Set(['QTUM']), + ravencoin: new Set(['RVN']), + rsk: new Set(['RBTC']), + ripple: new Set(['XRP']), + solana: new Set(['SOL']), + stellar: new Set(['XLM']), + sui: new Set(['SUI']), + tezos: new Set(['XTZ']), + ton: new Set(['TON']), + tron: new Set(['TRX']), + vertcoin: new Set(['VTC']), + wax: new Set(['WAXP']), + zano: new Set(['ZANO']), + zcash: new Set(['ZEC']), + zcoin: new Set(['XZC']), + zksync: new Set(['ETHZKSYNC']) +} + +const GASTOKEN_CONTRACTS = new Set([ + '0x0000000000000000000000000000000000000000', + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + 'So11111111111111111111111111111111111111111', + 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c' +]) + +let currencyCache: XgramCurrencies | undefined +let currencyCacheTimestamp = 0 + +const MISSING_CURRENCIES: XgramCurrencies = { + ADA: { + coinName: 'Cardano', + network: 'ADA', + contract: '' + }, + ATOM: { + coinName: 'Cosmos', + network: 'Cosmos', + contract: '' + }, + LINK: { + coinName: 'Chainlink', + network: 'ERC20', + contract: '0x514910771af9ca656af840dff83e8264ecf986ca' + }, + USDC: { + coinName: 'USD Coin', + network: 'ERC20', + contract: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + }, + USDCSOLANA: { + coinName: 'USD Coin', + network: 'SOL', + contract: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + }, + USDT: { + coinName: 'Tether', + network: 'ERC20', + contract: '0xdac17f958d2ee523a2206206994597c13d831ec7' + }, + USDTSOLANA: { + coinName: 'Tether', + network: 'SOL', + contract: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB' + }, + USDTTRC20: { + coinName: 'Tether', + network: 'TRC20', + contract: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + }, + ZEC: { + coinName: 'Zcash', + network: 'Zcash', + contract: '' + } +} + +async function fetchCurrencyCache( + apiKey: string, + log: PluginParams['log'] +): Promise { + if ( + currencyCache != null && + Date.now() - currencyCacheTimestamp < CACHE_TTL_MS + ) { + return currencyCache + } + + const response = await retryFetch( + 'https://xgram.io/api/v1/list-currency-options', + { + method: 'GET', + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json' + } + } + ) + if (!response.ok) { + const text = await response.text() + throw new Error(`Xgram currency list error ${response.status}: ${text}`) + } + + const result = await response.json() + currencyCache = { + ...asXgramCurrencies(result), + ...MISSING_CURRENCIES + } + currencyCacheTimestamp = Date.now() + log(`Cached ${Object.keys(currencyCache).length} Xgram currencies`) + return currencyCache +} + +function isNativeTicker(chainPluginId: string, currencyCode: string): boolean { + return NATIVE_TICKERS[chainPluginId]?.has(currencyCode.toUpperCase()) ?? false +} + +function isGasTokenContract(contract: string): boolean { + return ( + GASTOKEN_CONTRACTS.has(contract) || + GASTOKEN_CONTRACTS.has(contract.toLowerCase()) + ) +} + +function getAssetInfo( + currencyCode: string, + currencies: XgramCurrencies +): EdgeAssetInfo { + const currency = currencies[currencyCode] + if (currency == null) { + throw new Error(`Unknown Xgram currency: ${currencyCode}`) + } + + const chainPluginId = XGRAM_NETWORK_TO_PLUGIN_ID[currency.network] + if (chainPluginId == null) { + throw new Error( + `Unknown Xgram network "${currency.network}" for ${currencyCode}` + ) + } + + const evmChainId = EVM_CHAIN_IDS[chainPluginId] + const contract = (currency.contract ?? '').trim() + const isNative = + isNativeTicker(chainPluginId, currencyCode) || isGasTokenContract(contract) + + if (contract === '' || isNative) { + if (isNative) { + return { chainPluginId, evmChainId, tokenId: null } + } + throw new Error( + `Missing Xgram contract for non-native ${currencyCode} on ${currency.network}` + ) + } + + const tokenType = tokenTypes[chainPluginId] + if (tokenType == null) { + throw new Error( + `Unknown tokenType for ${chainPluginId} (${currencyCode} on ${currency.network})` + ) + } + + return { + chainPluginId, + evmChainId, + tokenId: createTokenId(tokenType, currencyCode, contract) + } +} + +function parseAmount( + amount: ReturnType, + fallback: number +): number { + if (amount == null) return fallback + if (typeof amount === 'number') return amount + return safeParseFloat(amount) +} + +function parseXgramDate(date: string): { isoDate: string; timestamp: number } { + const match = date.match(/^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}:\d{2}:\d{2})$/) + if (match == null) { + throw new Error(`Unexpected Xgram date format: ${date}`) + } + const [, day, month, year, time] = match + const parsed = new Date(`${year}-${month}-${day}T${time}Z`) + if (Number.isNaN(parsed.getTime())) { + throw new Error(`Invalid Xgram date: ${date}`) + } + return { isoDate: parsed.toISOString(), timestamp: parsed.getTime() / 1000 } +} + +export const queryXgram = async ( + pluginParams: PluginParams +): Promise => { + const { log } = pluginParams + const { settings, apiKeys } = asStandardPluginParams(pluginParams) + const { apiKey } = apiKeys + const { latestIsoDate } = settings + + if (apiKey == null) { + return { settings: { latestIsoDate }, transactions: [] } + } + + const standardTxs: StandardTx[] = [] + let previousTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK + if (previousTimestamp < 0) previousTimestamp = 0 + const targetIsoDate = new Date(previousTimestamp).toISOString() + + const currencies = await fetchCurrencyCache(apiKey, log) + + // Because Xgram pages from newest to oldest, the watermark can only be + // advanced once the entire newer-than-target range has been fetched and + // processed without error. Track the candidate watermark separately and only + // return it when the run completes cleanly; bailing out early (e.g. a + // permanent fetch failure) must leave the persisted watermark untouched so + // the next run re-queries the same range instead of skipping the orders that + // were never reached. + let newLatestIsoDate = latestIsoDate + let completed = false + let page = 0 + let retry = 0 + let done = false + while (!done) { + const url = `https://xgram.io/api/v1/exchange-history?page=${page}&limit=${LIMIT}` + let txs + try { + const response = await retryFetch(url, { + method: 'GET', + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json' + } + }) + if (!response.ok) { + const text = await response.text() + throw new Error(`Xgram history error ${response.status}: ${text}`) + } + const result = await response.json() + txs = asXgramResult(result).exchanges + } catch (e) { + log.error(String(e)) + // Retry a few times with time delay to prevent throttling + retry++ + if (retry <= MAX_RETRIES) { + log.warn(`Snoozing ${5 * retry}s`) + await snooze(5000 * retry) + continue + } else { + // Permanent fetch failure: stop without advancing the watermark. + break + } + } + + if (txs.length === 0) { + // Reached the end of Xgram's history: the full range was fetched. + completed = true + break + } + let oldestIsoDate = '999999999999999999999999999999999999' + for (const rawTx of txs) { + const standardTx = processXgramTx(rawTx, currencies) + if (standardTx.isoDate < oldestIsoDate) { + oldestIsoDate = standardTx.isoDate + } + if (standardTx.isoDate < targetIsoDate) { + // Reached the lookback boundary: every order newer than the target has + // been processed, so the run is complete. + completed = true + done = true + break + } + standardTxs.push(standardTx) + if (standardTx.isoDate > newLatestIsoDate) { + newLatestIsoDate = standardTx.isoDate + } + } + log( + `Xgram page ${page} oldestIsoDate ${oldestIsoDate} targetIsoDate ${targetIsoDate}` + ) + page += 1 + retry = 0 + } + const out: PluginResult = { + settings: { latestIsoDate: completed ? newLatestIsoDate : latestIsoDate }, + transactions: standardTxs + } + return out +} + +export const xgram: PartnerPlugin = { + queryFunc: queryXgram, + pluginName: 'xgram', + pluginId: 'xgram' +} + +export function processXgramTx( + rawTx: unknown, + currencies: XgramCurrencies +): StandardTx { + const tx: XgramTxTx = asXgramTx(rawTx) + const { isoDate, timestamp } = parseXgramDate(tx.date) + const depositCurrency = tx['x-fromCcy'].toUpperCase() + const payoutCurrency = tx['x-toCcy'].toUpperCase() + const depositAsset = getAssetInfo(depositCurrency, currencies) + const payoutAsset = getAssetInfo(payoutCurrency, currencies) + const standardTx: StandardTx = { + status: statusMap[tx['x-status']], + orderId: tx.id, + countryCode: null, + depositTxid: tx['x-ccyDepositHash'], + depositAddress: tx['x-ccyDepositAddress'], + depositCurrency, + depositChainPluginId: depositAsset.chainPluginId, + depositEvmChainId: depositAsset.evmChainId, + depositTokenId: depositAsset.tokenId, + depositAmount: parseAmount( + tx['x-ccyAmountFrom'], + tx['x-ccyExpectedAmountFrom'] + ), + direction: null, + exchangeType: 'swap', + paymentType: null, + payoutTxid: tx.txId, + payoutAddress: tx['x-ccyDestinationAddress'], + payoutCurrency, + payoutChainPluginId: payoutAsset.chainPluginId, + payoutEvmChainId: payoutAsset.evmChainId, + payoutTokenId: payoutAsset.tokenId, + payoutAmount: parseAmount(tx['x-ccyAmountTo'], tx['x-ccyExpectedAmountTo']), + timestamp, + isoDate, + usdValue: -1, + rawTx + } + + return standardTx +} diff --git a/src/queryEngine.ts b/src/queryEngine.ts index 0d7cea08..ee34571c 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -35,6 +35,7 @@ import { maya, thorchain } from './partners/thorchain' import { transak } from './partners/transak' import { wyre } from './partners/wyre' import { xanpool } from './partners/xanpool' +import { xgram } from './partners/xgram' import { asApp, asApps, @@ -87,7 +88,8 @@ const plugins = [ thorchain, transak, wyre, - xanpool + xanpool, + xgram ] const QUERY_FREQ_MS = 60 * 1000 const MAX_CONCURRENT_QUERIES = 3 diff --git a/test/xgram.test.ts b/test/xgram.test.ts new file mode 100644 index 00000000..b86f57c9 --- /dev/null +++ b/test/xgram.test.ts @@ -0,0 +1,126 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { processXgramTx, XgramCurrencies } from '../src/partners/xgram' + +const currencies: XgramCurrencies = { + BTC: { + coinName: 'Bitcoin', + network: 'Bitcoin', + contract: '' + }, + ADA: { + coinName: 'Cardano', + network: 'ADA', + contract: '' + }, + USDT: { + coinName: 'Tether', + network: 'ERC20', + contract: '0xdac17f958d2ee523a2206206994597c13d831ec7' + }, + USDTTRC20: { + coinName: 'Tether', + network: 'TRC20', + contract: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + }, + ZEC: { + coinName: 'Zcash', + network: 'Zcash', + contract: '' + } +} + +describe('processXgramTx', () => { + it('maps source and destination asset IDs', () => { + const tx = processXgramTx( + { + id: 'dyv3a2tdbgipvh0', + 'x-status': 'x-completed', + 'x-fromCcy': 'BTC', + 'x-toCcy': 'USDT', + 'x-ccyDepositAddress': 'bc1q8tgkyamr4jvlfw2ccaqg5gd2tskqs9h6r7fra7', + 'x-ccyDepositHash': 'deposit-hash', + 'x-ccyDestinationAddress': '0xf12fb83D413c509506635A663D188B1Dc7fA0C47', + 'x-ccyExpectedAmountFrom': 0.01334746, + 'x-ccyExpectedAmountTo': 992.5, + 'x-ccyAmountFrom': '0.0133', + 'x-ccyAmountTo': '990.1', + date: '27.05.2026 20:57:28', + txId: 'payout-hash' + }, + currencies + ) + + expect(tx.status).equals('complete') + expect(tx.depositCurrency).equals('BTC') + expect(tx.depositAmount).equals(0.0133) + expect(tx.depositChainPluginId).equals('bitcoin') + expect(tx.depositEvmChainId).equals(undefined) + expect(tx.depositTokenId).equals(null) + expect(tx.payoutCurrency).equals('USDT') + expect(tx.payoutAmount).equals(990.1) + expect(tx.payoutChainPluginId).equals('ethereum') + expect(tx.payoutEvmChainId).equals(1) + expect(tx.payoutTokenId).equals('dac17f958d2ee523a2206206994597c13d831ec7') + expect(tx.isoDate).equals('2026-05-27T20:57:28.000Z') + }) + + it('uses expected amounts and chain-specific token IDs for pending rows', () => { + const tx = processXgramTx( + { + id: 'tmah3a2td9cp20q0', + 'x-status': 'x-new', + 'x-fromCcy': 'USDTTRC20', + 'x-toCcy': 'BTC', + 'x-ccyDepositAddress': '0xcc56c6a4B3Fa0Cc4b672f8bDfd08f420F901d7D3', + 'x-ccyDepositHash': null, + 'x-ccyDestinationAddress': 'bc1qcrean77uds2gwggjzyry4vw30j80j7lhhvvczl', + 'x-ccyExpectedAmountFrom': 1001.699001, + 'x-ccyExpectedAmountTo': 0.01308, + 'x-ccyAmountFrom': null, + 'x-ccyAmountTo': null, + date: '27.05.2026 20:56:54', + txId: null + }, + currencies + ) + + expect(tx.status).equals('pending') + expect(tx.depositCurrency).equals('USDTTRC20') + expect(tx.depositAmount).equals(1001.699001) + expect(tx.depositChainPluginId).equals('tron') + expect(tx.depositTokenId).equals('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t') + expect(tx.payoutCurrency).equals('BTC') + expect(tx.payoutAmount).equals(0.01308) + expect(tx.payoutChainPluginId).equals('bitcoin') + expect(tx.payoutTokenId).equals(null) + }) + + it('maps historical native currencies missing from the currency API', () => { + const tx = processXgramTx( + { + id: 'talr3a0e49fplpog', + 'x-status': 'x-timeout', + 'x-fromCcy': 'ZEC', + 'x-toCcy': 'ADA', + 'x-ccyDepositAddress': 't1example', + 'x-ccyDepositHash': null, + 'x-ccyDestinationAddress': 'addr1example', + 'x-ccyExpectedAmountFrom': 1.2, + 'x-ccyExpectedAmountTo': 123, + 'x-ccyAmountFrom': null, + 'x-ccyAmountTo': null, + date: '12.05.2026 20:07:51', + txId: null + }, + currencies + ) + + expect(tx.status).equals('expired') + expect(tx.depositChainPluginId).equals('zcash') + expect(tx.depositTokenId).equals(null) + expect(tx.payoutChainPluginId).equals('cardano') + expect(tx.payoutTokenId).equals(null) + }) +}) From 0ca9ce9670bf66fecb7b6ed8d6696e76e2545c8f Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Tue, 9 Jun 2026 11:59:15 -0700 Subject: [PATCH 04/24] Infer LetsExchange native network when API omits network fields Some older LetsExchange transactions return null network fields for unambiguous native assets (e.g. ETH, BTC, XRP), causing processing to throw "Missing network" and stalling the query. Add a currency-to-network fallback for 1:1 native tickers so these transactions process correctly. Co-authored-by: Cursor --- src/partners/letsexchange.ts | 71 +++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/src/partners/letsexchange.ts b/src/partners/letsexchange.ts index e05a8718..c57bc28f 100644 --- a/src/partners/letsexchange.ts +++ b/src/partners/letsexchange.ts @@ -201,6 +201,68 @@ const LETSEXCHANGE_NETWORK_TO_PLUGIN_ID: Record = { ZKSYNC: 'zksync' } +// When the API omits network fields, infer native chain from currency code. +// Only unambiguous 1:1 native-ticker cases (not USDT, USDC, BNB, etc.). +const LETSEXCHANGE_CURRENCY_TO_DEFAULT_NETWORK: Record = { + ADA: 'ADA', + ALGO: 'ALGO', + ARRR: 'ARRR', + ATOM: 'ATOM', + AVAX: 'AVAXC', + BCH: 'BCH', + BSV: 'BSV', + BTC: 'BTC', + BTG: 'BTG', + CELO: 'CELO', + COREUM: 'COREUM', + DASH: 'DASH', + DGB: 'DGB', + DOGE: 'DOGE', + DOT: 'DOT', + EOS: 'EOS', + ETC: 'ETC', + ETH: 'ETH', + ETHW: 'ETHW', + FIL: 'FIL', + FIO: 'FIO', + FIRO: 'FIRO', + FTM: 'FTM', + GRS: 'GRS', + HBAR: 'HBAR', + LTC: 'LTC', + MATIC: 'MATIC', + PIVX: 'PIVX', + POL: 'POL', + PLS: 'PLS', + QTUM: 'QTUM', + RUNE: 'RUNE', + RVN: 'RVN', + SOL: 'SOL', + SONIC: 'SONIC', + SUI: 'SUI', + TLOS: 'TLOS', + TON: 'TON', + TRX: 'TRX', + XEC: 'XEC', + XLM: 'XLM', + XMR: 'XMR', + XRP: 'XRP', + XTZ: 'XTZ', + ZANO: 'ZANO', + ZEC: 'ZEC' +} + +function resolveNetworkCode( + network: string | null, + currencyCode: string, + isoDate: string +): string | null { + if (network != null) return network + if (isoDate < NETWORK_FIELDS_AVAILABLE_DATE) return null + const currencyUpper = currencyCode.toUpperCase() + return LETSEXCHANGE_CURRENCY_TO_DEFAULT_NETWORK[currencyUpper] ?? null +} + // Native token placeholder addresses that should be treated as null (native coin) // All values should be lowercase for case-insensitive matching const NATIVE_TOKEN_ADDRESSES = new Set([ @@ -299,13 +361,10 @@ function getAssetInfo( contractAddress: string | null, isoDate: string ): AssetInfo | undefined { - if (initialNetwork == null) { - if (isoDate < NETWORK_FIELDS_AVAILABLE_DATE) { - return undefined - } - throw new Error(`Missing network for currency ${currencyCode}`) + const network = resolveNetworkCode(initialNetwork, currencyCode, isoDate) + if (network == null) { + return undefined } - const network = initialNetwork const networkUpper = network.toUpperCase() const chainPluginId = LETSEXCHANGE_NETWORK_TO_PLUGIN_ID[networkUpper] From 41287a317b72cf25ac53dfe31833d348606b0bd7 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Tue, 23 Jun 2026 14:59:14 -0700 Subject: [PATCH 05/24] Add missing partner asset and payment mappings These mappings were causing the query engine to halt and stop scanning forward for the affected partners (fail-closed design), blocking collection of newer transactions until the mapping was added. - SideShift: map SWARMS on Solana to its mint address (delisted from the SideShift coins API, so added to DELISTED_COINS). - Rango: map the SONIC blockchain to the `sonic` Edge pluginId. - Banxa: map the "Primer Paypal Pay" and "Primer Google Pay" payment types to paypal and googlepay respectively. Co-authored-by: Cursor --- src/partners/banxa.ts | 3 +++ src/partners/rango.ts | 1 + src/partners/sideshift.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/src/partners/banxa.ts b/src/partners/banxa.ts index e089b18c..ecc5b8ee 100644 --- a/src/partners/banxa.ts +++ b/src/partners/banxa.ts @@ -607,7 +607,10 @@ function getFiatPaymentType(tx: BanxaTx): FiatPaymentType { case 'WorldPay ApplePay': case 'Primer Apple Pay': return 'applepay' + case 'Primer Paypal Pay': + return 'paypal' case 'WorldPay GooglePay': + case 'Primer Google Pay': return 'googlepay' case 'iDEAL Transfer': return 'ideal' diff --git a/src/partners/rango.ts b/src/partners/rango.ts index 8a122089..ccfdbe6d 100644 --- a/src/partners/rango.ts +++ b/src/partners/rango.ts @@ -120,6 +120,7 @@ const RANGO_BLOCKCHAIN_TO_PLUGIN_ID: Record = { OSMOSIS: 'osmosis', POLYGON: 'polygon', SOLANA: 'solana', + SONIC: 'sonic', SUI: 'sui', TON: 'ton', TRON: 'tron', diff --git a/src/partners/sideshift.ts b/src/partners/sideshift.ts index 9d3ce4af..41291118 100644 --- a/src/partners/sideshift.ts +++ b/src/partners/sideshift.ts @@ -81,6 +81,7 @@ const DELISTED_COINS: Record = { 'MATIC-polygon': null, // Native gas token (rebranded to POL) 'MKR-ethereum': '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2', 'PYTH-solana': 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', + 'SWARMS-solana': '74SBV4zDXxTRgv1pEMoECskKBkZHc2yGPnc7GYVepump', 'USDC-tron': 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', 'XMR-monero': null, // Native gas token 'ZEC-zcash': null // Native gas token From 1ff3e86d513ecff213d5f2d430959c7278057881 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Mon, 29 Jun 2026 13:44:21 -0700 Subject: [PATCH 06/24] Load full ChangeNow currency list to resolve delisted assets The currency cache was built from the `currencies?active=true` endpoint. When ChangeNow deactivates an asset (e.g. DASH), it disappears from the active list while historical transactions still reference it. The lookup then misses and the plugin halts fail-closed, stalling all ChangeNow transaction collection. Fetch the full currency list (omit `active=true`) so previously-listed assets continue to resolve for historical transactions. Co-authored-by: Cursor --- src/partners/changenow.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/partners/changenow.ts b/src/partners/changenow.ts index f91c9ca6..2d6a110a 100644 --- a/src/partners/changenow.ts +++ b/src/partners/changenow.ts @@ -143,8 +143,12 @@ async function loadCurrencyCache( } try { - // The exchange/currencies endpoint doesn't require authentication - const url = 'https://api.changenow.io/v2/exchange/currencies?active=true' + // The exchange/currencies endpoint doesn't require authentication. + // Fetch the full list (omit `active=true`): historical transactions can + // reference currencies that ChangeNow has since deactivated/delisted (e.g. + // DASH). Filtering to active-only drops those entries, causing a cache miss + // and a fail-closed halt on otherwise-valid historical transactions. + const url = 'https://api.changenow.io/v2/exchange/currencies' const response = await retryFetch(url, { method: 'GET' }) From 3f9845d699b9b78e057f65a0bc309ec62381471c Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Mon, 13 Jul 2026 14:23:41 -0700 Subject: [PATCH 07/24] Add Banxa historical fallback for delisted ZEC Banxa removed ZEC from the v2 crypto catalog, so historical ZEC orders abort the partner query and stall ingestion past July 8. Co-authored-by: Cursor --- src/partners/banxa.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/partners/banxa.ts b/src/partners/banxa.ts index ecc5b8ee..62bad0ae 100644 --- a/src/partners/banxa.ts +++ b/src/partners/banxa.ts @@ -113,7 +113,9 @@ const BANXA_HISTORICAL_COINS: Record = { 'RLUSD-XRP': { contractAddress: 'rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De', pluginId: 'ripple' - } + }, + // ZEC delisted from Banxa v2 catalog + 'ZEC-ZEC': { contractAddress: null, pluginId: 'zcash' } } /** From 9d7d8c3435b58aacdd74550046ef228c9a41b156 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Tue, 28 Jul 2026 19:20:27 -0700 Subject: [PATCH 08/24] Disable getTxInfo endpoint Not private enough and is scrapable. Co-authored-by: Cursor --- src/indexApi.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/indexApi.ts b/src/indexApi.ts index 76fe4e9d..af6b06f3 100644 --- a/src/indexApi.ts +++ b/src/indexApi.ts @@ -8,7 +8,7 @@ import { analyticsRouter } from './routes/v1/analytics' import { checkTxsRouter } from './routes/v1/checkTxs' import { getAppIdRouter } from './routes/v1/getAppId' import { getPluginIdsRouter } from './routes/v1/getPluginIds' -import { getTxInfoRouter } from './routes/v1/getTxInfo' +// import { getTxInfoRouter } from './routes/v1/getTxInfo' import { HttpError } from './util/httpErrors' export const nanoDb = nano(config.couchDbFullpath) @@ -28,7 +28,8 @@ async function main(): Promise { app.use('/v1/checkTxs/', checkTxsRouter) app.use('/v1/getAppId/', getAppIdRouter) app.use('/v1/getPluginIds/', getPluginIdsRouter) - app.use('/v1/getTxInfo/', getTxInfoRouter) + // Disabled: not private enough and is scrapable. + // app.use('/v1/getTxInfo/', getTxInfoRouter) // Error router app.use(function(err, _req, res, _next) { From 1b95c0b5815f8123349464a8b13d58736a6bd37c Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Tue, 28 Jul 2026 19:15:50 -0700 Subject: [PATCH 09/24] Add Banxa Klarna Checkout payment type mapping Unblocks Banxa query progress stuck since Jul 24 on unrecognized KLARNA Checkout payment methods. Co-authored-by: Cursor --- src/partners/banxa.ts | 2 ++ src/types.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/partners/banxa.ts b/src/partners/banxa.ts index 62bad0ae..271b83f9 100644 --- a/src/partners/banxa.ts +++ b/src/partners/banxa.ts @@ -616,6 +616,8 @@ function getFiatPaymentType(tx: BanxaTx): FiatPaymentType { return 'googlepay' case 'iDEAL Transfer': return 'ideal' + case 'KLARNA Checkout': + return 'klarna' case 'ZeroHash ACH Sell': case 'Fortress/Plaid ACH': return 'ach' diff --git a/src/types.ts b/src/types.ts index 9f8d0582..7094a749 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,6 +93,7 @@ const asFiatPaymentType = asValue( 'interac', 'iobank', 'israelibank', + 'klarna', 'mexicobank', 'mobikwik', 'moonpay', From 7db6286f8512f1636b80e330ee4388126750eea2 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:47:44 -0700 Subject: [PATCH 10/24] Fix broken mocha test suite The suite imported three helpers from ../lib/util, a gitignored build artifact that only exists after a build, so a clean checkout could not run a single test. They live in src/demo/clientUtil, so point at the source module instead. --- test/util.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9bb9e40647d86c29aaec77acc454a87660f1ec23 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:47:45 -0700 Subject: [PATCH 11/24] Add CI job to run the test suite No job ran the tests, which is how the suite stayed broken. This one generates the gitignored clientConfig.json first, then runs mocha on every pull request. --- .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 6197e586b66c3228ae4f0f23d21ead6c5c8f0b38 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:47:48 -0700 Subject: [PATCH 12/24] Add a log-safe description of a partner payload A cleaner failure needs enough to find the record and see how its shape drifted, which is its identifier plus the field names it arrived with. Serializing the whole payload instead copies counterparty addresses and transaction ids into centralized logs, where anyone with log access can recover them; the record itself stays retrievable from Couch by that id. Partners disagree on what the id field is called, so the helper tries the common spellings in order and falls back to the field list alone. --- src/util.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/util.ts b/src/util.ts index 3129f1c8..f402879b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -182,3 +182,30 @@ export const safeParseFloat = (val: string): number => { if (val === '') return 0 return parseFloat(val) } + +/** + * A one-line, log-safe description of a partner payload for a cleaner failure. + * + * Triage needs enough to find the record and see how its shape drifted, which + * is its identifier plus the field names it arrived with. Serializing the whole + * payload instead would copy counterparty addresses and transaction ids into + * centralized logs, where anyone with log access could recover them; the full + * record stays retrievable from Couch by that id. + * + * `idFields` is tried in order, since partners disagree on what the id is + * called (orderId, id, requestId, ...). + */ +export const describeRawTx = ( + rawTx: unknown, + idFields: string[] = ['orderId', 'id', 'requestId', 'uid', 'transactionId'] +): string => { + if (typeof rawTx !== 'object' || rawTx === null) return `<${typeof rawTx}>` + const record = rawTx as { [key: string]: unknown } + for (const field of idFields) { + const value = record[field] + if (typeof value === 'string' && value !== '') { + return `${field}=${value} fields=[${Object.keys(record).join(',')}]` + } + } + return `id=unknown fields=[${Object.keys(record).join(',')}]` +} From 20287da6f5fef9d18fb5ee56b86e971f62c57a6e Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:48:18 -0700 Subject: [PATCH 13/24] Carry reported revenue and chained pair keys through the analytics cache Two additions to StandardTx and the buckets built from it. Revenue: some partner APIs report Edge's actual fee per order, and estimating that same number from a rate discards a fact already in hand. revenueUsd holds the figure and revenueSource records how it was obtained, so a consumer can tell a reported number from a derived one. It is stored at ingest as a fact about the order and never recomputed; the cache engine sums it into the buckets. Orders without a reported fee are left for the dashboard to estimate at read time, so correcting a rate fixes history immediately. Chained pair keys: pair totals were keyed by currency code alone, which merges assets that share a ticker across chains. The cache now dual-writes the chained key alongside the plain one, so existing readers keep working while a reader that understands chains can separate them. checkUpdateTx gains both revenue fields. It compares an explicit field list, and revenue arrives late by nature, so without them a re-poll that filled in a fee without touching another tracked field looked unchanged and the write was skipped. --- src/apiAnalytics.ts | 44 +++++++++- src/cacheEngine.ts | 9 +- src/dbutils.ts | 13 ++- src/demo/clientUtil.ts | 4 +- src/routes/v1/getTxInfo.ts | 5 +- src/types.ts | 70 +++++++++++++-- test/analytics.test.ts | 147 +++++++++++++++++++++++++------ test/testData.json | 174 ++++++++++++++++++++++++++++++++++++- 8 files changed, 418 insertions(+), 48 deletions(-) diff --git a/src/apiAnalytics.ts b/src/apiAnalytics.ts index 117b4195..516994c2 100644 --- a/src/apiAnalytics.ts +++ b/src/apiAnalytics.ts @@ -11,17 +11,22 @@ interface DbTx { orderId: string depositCurrency: string payoutCurrency: string + depositChainPluginId?: string + payoutChainPluginId?: string timestamp: number usdValue: number + revenueUsd?: number } interface Bucket { start: number usdValue: number numTxs: number + revenueUsd: number isoDate: string currencyCodes: { [currencyCode: string]: number } currencyPairs: { [currencyPair: string]: number } + chainedPairs: { [currencyPair: string]: number } } export const getAnalytics = ( @@ -49,8 +54,10 @@ export const getAnalytics = ( isoDate: monthStart.toISOString(), usdValue: 0, numTxs: 0, + revenueUsd: 0, currencyCodes: {}, - currencyPairs: {} + currencyPairs: {}, + chainedPairs: {} }) m++ monthStart = new Date(Date.UTC(y, m, 1, 0)) @@ -66,8 +73,10 @@ export const getAnalytics = ( isoDate: dayStart.toISOString(), usdValue: 0, numTxs: 0, + revenueUsd: 0, currencyCodes: {}, - currencyPairs: {} + currencyPairs: {}, + chainedPairs: {} }) d++ dayStart = new Date(Date.UTC(y, m, d, 0)) @@ -83,8 +92,10 @@ export const getAnalytics = ( isoDate: hourStart.toISOString(), usdValue: 0, numTxs: 0, + revenueUsd: 0, currencyCodes: {}, - currencyPairs: {} + currencyPairs: {}, + chainedPairs: {} }) h++ hourStart = new Date(Date.UTC(y, m, d, h)) @@ -160,6 +171,8 @@ const bucketAdder = (bucket: Bucket, tx: DbTx): void => { bucket.numTxs++ // usdValue bucket.usdValue += tx.usdValue != null ? tx.usdValue : 0 + // reported revenue (partners that do not report one contribute 0) + bucket.revenueUsd += tx.revenueUsd != null ? tx.revenueUsd : 0 // currencyCode if (bucket.currencyCodes[tx.depositCurrency] == null) { bucket.currencyCodes[tx.depositCurrency] = 0 @@ -177,4 +190,29 @@ const bucketAdder = (bucket: Bucket, tx: DbTx): void => { bucket.currencyPairs[currencyPair] = 0 } bucket.currencyPairs[currencyPair] += tx.usdValue != null ? tx.usdValue : 0 + const chainedPair = chainedPairKey(tx) + if (bucket.chainedPairs[chainedPair] == null) { + bucket.chainedPairs[chainedPair] = 0 + } + bucket.chainedPairs[chainedPair] += tx.usdValue != null ? tx.usdValue : 0 +} + +export function assetChainKey( + currency: string, + chainPluginId?: string +): string { + if (chainPluginId == null || chainPluginId === '') return currency + return `${currency}@${chainPluginId}` +} + +export function chainedPairKey(tx: { + depositCurrency: string + payoutCurrency: string + depositChainPluginId?: string + payoutChainPluginId?: string +}): string { + return `${assetChainKey( + tx.depositCurrency, + tx.depositChainPluginId + )}>${assetChainKey(tx.payoutCurrency, tx.payoutChainPluginId)}` } diff --git a/src/cacheEngine.ts b/src/cacheEngine.ts index ab260f77..adeb0d6c 100644 --- a/src/cacheEngine.ts +++ b/src/cacheEngine.ts @@ -100,8 +100,11 @@ export async function cacheEngine(): Promise { 'orderId', 'depositCurrency', 'payoutCurrency', + 'depositChainPluginId', + 'payoutChainPluginId', 'timestamp', - 'usdValue' + 'usdValue', + 'revenueUsd' ], use_index: 'timestamp-p', sort: ['timestamp'], @@ -145,8 +148,10 @@ export async function cacheEngine(): Promise { timestamp: bucket.start, usdValue: bucket.usdValue, numTxs: bucket.numTxs, + revenueUsd: bucket.revenueUsd, currencyCodes: bucket.currencyCodes, - currencyPairs: bucket.currencyPairs + currencyPairs: bucket.currencyPairs, + chainedPairs: bucket.chainedPairs } }) try { diff --git a/src/dbutils.ts b/src/dbutils.ts index e59f9a66..b50edf46 100644 --- a/src/dbutils.ts +++ b/src/dbutils.ts @@ -1,4 +1,4 @@ -import { asArray, asNumber, asObject, asString } from 'cleaners' +import { asArray, asNumber, asObject, asOptional, asString } from 'cleaners' import nano from 'nano' import { config } from './config' @@ -14,8 +14,11 @@ export const asDbReq = asObject({ orderId: asString, depositCurrency: asString, payoutCurrency: asString, + depositChainPluginId: asOptional(asString), + payoutChainPluginId: asOptional(asString), timestamp: asNumber, - usdValue: asNumber + usdValue: asNumber, + revenueUsd: asOptional(asNumber) }) ) }) @@ -92,7 +95,7 @@ export const cacheAnalytic = async ( usdValue: { $gte: 0 }, timestamp: { $gte: startForDayTimePeriod ?? start, $lt: end } }, - use_index: 'timestamp-index', + use_index: 'timestamp-p', sort: ['timestamp'], limit: 1000000 } @@ -106,9 +109,11 @@ export const cacheAnalytic = async ( start: cacheObj.timestamp, usdValue: cacheObj.usdValue, numTxs: cacheObj.numTxs, + revenueUsd: cacheObj.revenueUsd, isoDate: new Date(cacheObj.timestamp).toISOString(), currencyCodes: cacheObj.currencyCodes, - currencyPairs: cacheObj.currencyPairs + currencyPairs: cacheObj.currencyPairs, + chainedPairs: cacheObj.chainedPairs } }) console.time(`${partnerId} ${timePeriod} cache fetched`) diff --git a/src/demo/clientUtil.ts b/src/demo/clientUtil.ts index 5ddf1477..2aac1657 100644 --- a/src/demo/clientUtil.ts +++ b/src/demo/clientUtil.ts @@ -144,9 +144,11 @@ export const createQuarterBuckets = (analytics: AnalyticsResult): Bucket[] => { start: realTimestamp / 1000, usdValue: 0, numTxs: 0, + revenueUsd: undefined, isoDate: new Date(realTimestamp).toISOString(), currencyCodes: {}, - currencyPairs: {} + currencyPairs: {}, + chainedPairs: {} } }) let i = 0 diff --git a/src/routes/v1/getTxInfo.ts b/src/routes/v1/getTxInfo.ts index a1c8ba41..94658890 100644 --- a/src/routes/v1/getTxInfo.ts +++ b/src/routes/v1/getTxInfo.ts @@ -92,7 +92,10 @@ getTxInfoRouter.get('/', async function(req, res) { const rows = results.docs .map(doc => asMaybe(asDbTx)(doc)) - .filter((item): item is DbTx => item != null) + // Narrow on the cleaner's own return type: DbTx's revenue keys are + // optional-key relaxed, so it is no longer a subtype of that return type + // and cannot serve as the predicate target. + .filter((item): item is NonNullable => item != null) const txs: TxInfo[] = rows.map(row => ({ providerId: getProviderId(row), diff --git a/src/types.ts b/src/types.ts index 7094a749..31d21fae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,17 @@ export type FiatPaymentType = ReturnType /** The type of exchange that the partner is. A 'fiat' type means on/off ramp. */ const asExchangeType = asValue('fiat', 'swap') +/** + * How `revenueUsd` was obtained. The type parameter is pinned to the literal + * tuple on purpose: left to inference `asValue` widens to `string`, which + * silently costs every consumer the compile-time guarantee and leaves only the + * runtime cleaner to catch a bad value, after it may already be persisted. + */ +const asRevenueSource = asValue<['reported', 'estimated']>( + 'reported', + 'estimated' +) + export const asStandardTx = asObject({ orderId: asString, countryCode: asEither(asString, asNull, asUndefined), @@ -149,6 +160,21 @@ export const asStandardTx = asObject({ isoDate: asString, timestamp: asNumber, usdValue: asNumber, + /** + * Edge's actual revenue on this order in USD, when the partner's API reports + * it (e.g. Revolut's partner_fee, pre-converted to USD by Revolut). Stored at + * ingest as a fact about the order, never recomputed. Absent when the partner + * does not report one; the v2 dashboard then estimates revenue at read time + * as usdValue * the app doc's per-partner revShareRate, so a corrected rate + * fixes history immediately while reported figures stay immutable. + */ + revenueUsd: asOptional(asNumber), + /** + * How revenueUsd was obtained. 'reported' is the only value written at + * ingest; 'estimated' exists so read-time consumers can tag derived figures + * without inventing a second vocabulary. + */ + revenueSource: asOptional(asRevenueSource), rawTx: asUnknown }) @@ -180,7 +206,15 @@ export const asStandardPluginParams = asObject({ const asPartnerInfo = asObject({ pluginId: asOptional(asString), - apiKeys: asMap(asString) + apiKeys: asMap(asString), + /** + * Revenue-share rate for this app-partner relationship (fraction of volume), + * used by the v2 dashboard to estimate revenue when the partner's API does + * not report actual fees. Lives here, beside the credentials that define the + * relationship, because the rate is a property of the deal: per app AND per + * partner. Never committed to source; this repo is public. + */ + revShareRate: asOptional(asNumber) }) export const asApp = asObject({ @@ -196,8 +230,12 @@ const asCacheEntry = asObject({ timestamp: asNumber, usdValue: asNumber, numTxs: asNumber, + // Sum of reported revenueUsd across the bucket's txs. Optional: cache docs + // written before this field existed lack it, and rebuilding fills it in. + revenueUsd: asOptional(asNumber), currencyCodes: asObject(asNumber), - currencyPairs: asObject(asNumber) + currencyPairs: asObject(asNumber), + chainedPairs: asOptional(asObject(asNumber)) }) export const asCacheQuery = asObject({ @@ -208,9 +246,11 @@ export const asBucket = asObject({ start: asNumber, usdValue: asNumber, numTxs: asNumber, + revenueUsd: asOptional(asNumber), isoDate: asString, currencyCodes: asObject(asNumber), - currencyPairs: asObject(asNumber) + currencyPairs: asObject(asNumber), + chainedPairs: asOptional(asObject(asNumber)) }) export const asAnalyticsResult = asObject({ @@ -267,8 +307,28 @@ export type AnalyticsResult = ReturnType export type CurrencyCodeMappings = ReturnType export type DbCurrencyCodeMappings = ReturnType -export type DbTx = ReturnType -export type StandardTx = ReturnType +// Same optional-key relaxation as StandardTx (asDbTx spreads its shape). +export type DbTx = Omit< + ReturnType, + 'revenueUsd' | 'revenueSource' +> & { + revenueUsd?: number + revenueSource?: 'reported' | 'estimated' +} +/** + * `revenueUsd`/`revenueSource` are truly optional KEYS, not just + * possibly-undefined values: only partners whose APIs report an actual fee set + * them, and requiring every other plugin to spell out two explicit undefineds + * would churn the whole partner directory for no information. The cleaner + * still validates both fields when present. + */ +export type StandardTx = Omit< + ReturnType, + 'revenueUsd' | 'revenueSource' +> & { + revenueUsd?: number + revenueSource?: 'reported' | 'estimated' +} export type PluginParams = ReturnType & { log: ScopedLog } diff --git a/test/analytics.test.ts b/test/analytics.test.ts index ba525708..4c8defc0 100644 --- a/test/analytics.test.ts +++ b/test/analytics.test.ts @@ -14,16 +14,30 @@ import { } from './testData.json' describe('apiAnalytics function tests', function() { + const withoutChainedPairs = ( + result: ReturnType + ): ReturnType => { + const copy = JSON.parse(JSON.stringify(result)) + for (const period of ['hour', 'day', 'month'] as const) { + for (const bucket of copy.result[period]) { + delete bucket.chainedPairs + } + } + return copy + } + it('A Real Coinswitch Query for Month of July 2020', function() { expect( JSON.stringify( - getAnalytics( - inputOne, - 1594023608, - 1596055300, - 'edge', - 'coinswitch', - 'month' + withoutChainedPairs( + getAnalytics( + inputOne, + 1594023608, + 1596055300, + 'edge', + 'coinswitch', + 'month' + ) ) ) ).equals(JSON.stringify(outputOne)) @@ -31,13 +45,15 @@ describe('apiAnalytics function tests', function() { it('Create All 3 Buckets', function() { expect( JSON.stringify( - getAnalytics( - inputTwo, - 1300000000, - 1300070000, - 'app-dummy', - 'partner-dummy', - 'month|day|hour' + withoutChainedPairs( + getAnalytics( + inputTwo, + 1300000000, + 1300070000, + 'app-dummy', + 'partner-dummy', + 'month|day|hour' + ) ) ) ).equals(JSON.stringify(outputTwo)) @@ -45,13 +61,15 @@ describe('apiAnalytics function tests', function() { it('Leap Year Test', function() { expect( JSON.stringify( - getAnalytics( - inputThree, - 1708992000, - 1709424000, - 'app-dummy', - 'partner-dummy', - 'day|hour' + withoutChainedPairs( + getAnalytics( + inputThree, + 1708992000, + 1709424000, + 'app-dummy', + 'partner-dummy', + 'day|hour' + ) ) ) ).equals(JSON.stringify(outputThree)) @@ -59,15 +77,88 @@ describe('apiAnalytics function tests', function() { it('Year Rollover', function() { expect( JSON.stringify( - getAnalytics( - inputFour, - 1672444800, - 1706918400, - 'app-dummy', - 'partner-dummy', - 'month' + withoutChainedPairs( + getAnalytics( + inputFour, + 1672444800, + 1706918400, + 'app-dummy', + 'partner-dummy', + 'month' + ) ) ) ).equals(JSON.stringify(outputFour)) }) + it('dual-writes chainedPairs with pluginId when chain is present', function() { + const result = getAnalytics( + [ + { + orderId: '1', + depositCurrency: 'USDC', + payoutCurrency: 'ETH', + depositChainPluginId: 'ethereum', + payoutChainPluginId: 'ethereum', + timestamp: 1594351955, + usdValue: 100 + } + ], + 1594023608, + 1596055300, + 'edge', + 'coinswitch', + 'month' + ) + const month = result.result.month.find(bucket => bucket.numTxs > 0) + expect(month).to.not.equal(undefined) + if (month == null) return + expect(month.currencyPairs['USDC-ETH']).to.equal(100) + expect(month.chainedPairs?.['USDC@ethereum>ETH@ethereum']).to.equal(100) + }) + it('keys chainedPairs without @ when chain is absent', function() { + const result = getAnalytics( + [ + { + orderId: '1', + depositCurrency: 'DOGE', + payoutCurrency: 'ETH', + timestamp: 1594351955, + usdValue: 50 + } + ], + 1594023608, + 1596055300, + 'edge', + 'coinswitch', + 'month' + ) + const month = result.result.month.find(bucket => bucket.numTxs > 0) + expect(month).to.not.equal(undefined) + if (month == null) return + expect(month.currencyPairs['DOGE-ETH']).to.equal(50) + expect(month.chainedPairs?.['DOGE>ETH']).to.equal(50) + }) + it('keeps a fiat leg ticker-only in chainedPairs', function() { + const result = getAnalytics( + [ + { + orderId: '1', + depositCurrency: 'USD', + payoutCurrency: 'BTC', + payoutChainPluginId: 'bitcoin', + timestamp: 1594351955, + usdValue: 25 + } + ], + 1594023608, + 1596055300, + 'edge', + 'banxa', + 'month' + ) + const month = result.result.month.find(bucket => bucket.numTxs > 0) + expect(month).to.not.equal(undefined) + if (month == null) return + expect(month.chainedPairs?.['USD>BTC@bitcoin']).to.equal(25) + }) }) diff --git a/test/testData.json b/test/testData.json index 419ab91a..6fc634dc 100644 --- a/test/testData.json +++ b/test/testData.json @@ -1164,6 +1164,7 @@ "isoDate": "2020-07-01T00:00:00.000Z", "usdValue": 18949.855647399967, "numTxs": 165, + "revenueUsd": 0, "currencyCodes": { "DOGE": 4353.434238976961, "ETH": 1039.0955238959054, @@ -1270,7 +1271,7 @@ "numAllTxs": 165 }, "app": "edge", - "pluginId": "coinswitch", + "partnerId": "coinswitch", "start": 1594023608, "end": 1596055300 }, @@ -1319,6 +1320,7 @@ "isoDate": "2011-03-01T00:00:00.000Z", "usdValue": 9520, "numTxs": 5, + "revenueUsd": 0, "currencyCodes": { "DOGE": 4760, "ETH": 220, @@ -1339,6 +1341,7 @@ "isoDate": "2011-03-13T00:00:00.000Z", "usdValue": 9480, "numTxs": 4, + "revenueUsd": 0, "currencyCodes": { "DOGE": 4740, "ETH": 200, @@ -1356,6 +1359,7 @@ "isoDate": "2011-03-14T00:00:00.000Z", "usdValue": 40, "numTxs": 1, + "revenueUsd": 0, "currencyCodes": { "ETH": 20, "DOGE": 20 @@ -1371,6 +1375,7 @@ "isoDate": "2011-03-13T07:00:00.000Z", "usdValue": 2480, "numTxs": 3, + "revenueUsd": 0, "currencyCodes": { "DOGE": 1240, "ETH": 200, @@ -1388,6 +1393,7 @@ "isoDate": "2011-03-13T08:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1396,6 +1402,7 @@ "isoDate": "2011-03-13T09:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1404,6 +1411,7 @@ "isoDate": "2011-03-13T10:00:00.000Z", "usdValue": 7000, "numTxs": 1, + "revenueUsd": 0, "currencyCodes": { "BTC": 3500, "DOGE": 3500 @@ -1417,6 +1425,7 @@ "isoDate": "2011-03-13T11:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1425,6 +1434,7 @@ "isoDate": "2011-03-13T12:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1433,6 +1443,7 @@ "isoDate": "2011-03-13T13:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1441,6 +1452,7 @@ "isoDate": "2011-03-13T14:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1449,6 +1461,7 @@ "isoDate": "2011-03-13T15:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1457,6 +1470,7 @@ "isoDate": "2011-03-13T16:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1465,6 +1479,7 @@ "isoDate": "2011-03-13T17:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1473,6 +1488,7 @@ "isoDate": "2011-03-13T18:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1481,6 +1497,7 @@ "isoDate": "2011-03-13T19:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1489,6 +1506,7 @@ "isoDate": "2011-03-13T20:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1497,6 +1515,7 @@ "isoDate": "2011-03-13T21:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1505,6 +1524,7 @@ "isoDate": "2011-03-13T22:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1513,6 +1533,7 @@ "isoDate": "2011-03-13T23:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1521,6 +1542,7 @@ "isoDate": "2011-03-14T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1529,6 +1551,7 @@ "isoDate": "2011-03-14T01:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1537,6 +1560,7 @@ "isoDate": "2011-03-14T02:00:00.000Z", "usdValue": 40, "numTxs": 1, + "revenueUsd": 0, "currencyCodes": { "ETH": 20, "DOGE": 20 @@ -1549,7 +1573,7 @@ "numAllTxs": 5 }, "app": "app-dummy", - "pluginId": "partner-dummy", + "partnerId": "partner-dummy", "start": 1300000000, "end": 1300070000 }, @@ -1599,6 +1623,7 @@ "isoDate": "2024-02-27T00:00:00.000Z", "usdValue": 9900.43, "numTxs": 1, + "revenueUsd": 0, "currencyCodes": { "BTC": 4950.215, "ETH": 4950.215 @@ -1612,6 +1637,7 @@ "isoDate": "2024-02-28T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1620,6 +1646,7 @@ "isoDate": "2024-02-29T00:00:00.000Z", "usdValue": 20800, "numTxs": 2, + "revenueUsd": 0, "currencyCodes": { "BTC": 10400, "DOGE": 10400 @@ -1633,6 +1660,7 @@ "isoDate": "2024-03-01T00:00:00.000Z", "usdValue": 2, "numTxs": 1, + "revenueUsd": 0, "currencyCodes": { "BTC": 1, "DOGE": 1 @@ -1646,6 +1674,7 @@ "isoDate": "2024-03-02T00:00:00.000Z", "usdValue": 1000, "numTxs": 1, + "revenueUsd": 0, "currencyCodes": { "ETH": 500, "DOGE": 500 @@ -1659,6 +1688,7 @@ "isoDate": "2024-03-03T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} } @@ -1669,6 +1699,7 @@ "isoDate": "2024-02-27T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1677,6 +1708,7 @@ "isoDate": "2024-02-27T01:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1685,6 +1717,7 @@ "isoDate": "2024-02-27T02:00:00.000Z", "usdValue": 9900.43, "numTxs": 1, + "revenueUsd": 0, "currencyCodes": { "BTC": 4950.215, "ETH": 4950.215 @@ -1698,6 +1731,7 @@ "isoDate": "2024-02-27T03:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1706,6 +1740,7 @@ "isoDate": "2024-02-27T04:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1714,6 +1749,7 @@ "isoDate": "2024-02-27T05:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1722,6 +1758,7 @@ "isoDate": "2024-02-27T06:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1730,6 +1767,7 @@ "isoDate": "2024-02-27T07:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1738,6 +1776,7 @@ "isoDate": "2024-02-27T08:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1746,6 +1785,7 @@ "isoDate": "2024-02-27T09:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1754,6 +1794,7 @@ "isoDate": "2024-02-27T10:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1762,6 +1803,7 @@ "isoDate": "2024-02-27T11:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1770,6 +1812,7 @@ "isoDate": "2024-02-27T12:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1778,6 +1821,7 @@ "isoDate": "2024-02-27T13:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1786,6 +1830,7 @@ "isoDate": "2024-02-27T14:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1794,6 +1839,7 @@ "isoDate": "2024-02-27T15:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1802,6 +1848,7 @@ "isoDate": "2024-02-27T16:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1810,6 +1857,7 @@ "isoDate": "2024-02-27T17:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1818,6 +1866,7 @@ "isoDate": "2024-02-27T18:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1826,6 +1875,7 @@ "isoDate": "2024-02-27T19:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1834,6 +1884,7 @@ "isoDate": "2024-02-27T20:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1842,6 +1893,7 @@ "isoDate": "2024-02-27T21:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1850,6 +1902,7 @@ "isoDate": "2024-02-27T22:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1858,6 +1911,7 @@ "isoDate": "2024-02-27T23:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1866,6 +1920,7 @@ "isoDate": "2024-02-28T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1874,6 +1929,7 @@ "isoDate": "2024-02-28T01:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1882,6 +1938,7 @@ "isoDate": "2024-02-28T02:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1890,6 +1947,7 @@ "isoDate": "2024-02-28T03:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1898,6 +1956,7 @@ "isoDate": "2024-02-28T04:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1906,6 +1965,7 @@ "isoDate": "2024-02-28T05:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1914,6 +1974,7 @@ "isoDate": "2024-02-28T06:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1922,6 +1983,7 @@ "isoDate": "2024-02-28T07:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1930,6 +1992,7 @@ "isoDate": "2024-02-28T08:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1938,6 +2001,7 @@ "isoDate": "2024-02-28T09:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1946,6 +2010,7 @@ "isoDate": "2024-02-28T10:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1954,6 +2019,7 @@ "isoDate": "2024-02-28T11:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1962,6 +2028,7 @@ "isoDate": "2024-02-28T12:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1970,6 +2037,7 @@ "isoDate": "2024-02-28T13:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1978,6 +2046,7 @@ "isoDate": "2024-02-28T14:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1986,6 +2055,7 @@ "isoDate": "2024-02-28T15:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -1994,6 +2064,7 @@ "isoDate": "2024-02-28T16:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2002,6 +2073,7 @@ "isoDate": "2024-02-28T17:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2010,6 +2082,7 @@ "isoDate": "2024-02-28T18:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2018,6 +2091,7 @@ "isoDate": "2024-02-28T19:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2026,6 +2100,7 @@ "isoDate": "2024-02-28T20:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2034,6 +2109,7 @@ "isoDate": "2024-02-28T21:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2042,6 +2118,7 @@ "isoDate": "2024-02-28T22:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2050,6 +2127,7 @@ "isoDate": "2024-02-28T23:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2058,6 +2136,7 @@ "isoDate": "2024-02-29T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2066,6 +2145,7 @@ "isoDate": "2024-02-29T01:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2074,6 +2154,7 @@ "isoDate": "2024-02-29T02:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2082,6 +2163,7 @@ "isoDate": "2024-02-29T03:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2090,6 +2172,7 @@ "isoDate": "2024-02-29T04:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2098,6 +2181,7 @@ "isoDate": "2024-02-29T05:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2106,6 +2190,7 @@ "isoDate": "2024-02-29T06:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2114,6 +2199,7 @@ "isoDate": "2024-02-29T07:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2122,6 +2208,7 @@ "isoDate": "2024-02-29T08:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2130,6 +2217,7 @@ "isoDate": "2024-02-29T09:00:00.000Z", "usdValue": 20800, "numTxs": 2, + "revenueUsd": 0, "currencyCodes": { "BTC": 10400, "DOGE": 10400 @@ -2143,6 +2231,7 @@ "isoDate": "2024-02-29T10:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2151,6 +2240,7 @@ "isoDate": "2024-02-29T11:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2159,6 +2249,7 @@ "isoDate": "2024-02-29T12:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2167,6 +2258,7 @@ "isoDate": "2024-02-29T13:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2175,6 +2267,7 @@ "isoDate": "2024-02-29T14:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2183,6 +2276,7 @@ "isoDate": "2024-02-29T15:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2191,6 +2285,7 @@ "isoDate": "2024-02-29T16:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2199,6 +2294,7 @@ "isoDate": "2024-02-29T17:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2207,6 +2303,7 @@ "isoDate": "2024-02-29T18:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2215,6 +2312,7 @@ "isoDate": "2024-02-29T19:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2223,6 +2321,7 @@ "isoDate": "2024-02-29T20:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2231,6 +2330,7 @@ "isoDate": "2024-02-29T21:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2239,6 +2339,7 @@ "isoDate": "2024-02-29T22:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2247,6 +2348,7 @@ "isoDate": "2024-02-29T23:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2255,6 +2357,7 @@ "isoDate": "2024-03-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2263,6 +2366,7 @@ "isoDate": "2024-03-01T01:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2271,6 +2375,7 @@ "isoDate": "2024-03-01T02:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2279,6 +2384,7 @@ "isoDate": "2024-03-01T03:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2287,6 +2393,7 @@ "isoDate": "2024-03-01T04:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2295,6 +2402,7 @@ "isoDate": "2024-03-01T05:00:00.000Z", "usdValue": 2, "numTxs": 1, + "revenueUsd": 0, "currencyCodes": { "BTC": 1, "DOGE": 1 @@ -2308,6 +2416,7 @@ "isoDate": "2024-03-01T06:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2316,6 +2425,7 @@ "isoDate": "2024-03-01T07:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2324,6 +2434,7 @@ "isoDate": "2024-03-01T08:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2332,6 +2443,7 @@ "isoDate": "2024-03-01T09:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2340,6 +2452,7 @@ "isoDate": "2024-03-01T10:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2348,6 +2461,7 @@ "isoDate": "2024-03-01T11:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2356,6 +2470,7 @@ "isoDate": "2024-03-01T12:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2364,6 +2479,7 @@ "isoDate": "2024-03-01T13:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2372,6 +2488,7 @@ "isoDate": "2024-03-01T14:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2380,6 +2497,7 @@ "isoDate": "2024-03-01T15:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2388,6 +2506,7 @@ "isoDate": "2024-03-01T16:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2396,6 +2515,7 @@ "isoDate": "2024-03-01T17:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2404,6 +2524,7 @@ "isoDate": "2024-03-01T18:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2412,6 +2533,7 @@ "isoDate": "2024-03-01T19:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2420,6 +2542,7 @@ "isoDate": "2024-03-01T20:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2428,6 +2551,7 @@ "isoDate": "2024-03-01T21:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2436,6 +2560,7 @@ "isoDate": "2024-03-01T22:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2444,6 +2569,7 @@ "isoDate": "2024-03-01T23:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2452,6 +2578,7 @@ "isoDate": "2024-03-02T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2460,6 +2587,7 @@ "isoDate": "2024-03-02T01:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2468,6 +2596,7 @@ "isoDate": "2024-03-02T02:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2476,6 +2605,7 @@ "isoDate": "2024-03-02T03:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2484,6 +2614,7 @@ "isoDate": "2024-03-02T04:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2492,6 +2623,7 @@ "isoDate": "2024-03-02T05:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2500,6 +2632,7 @@ "isoDate": "2024-03-02T06:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2508,6 +2641,7 @@ "isoDate": "2024-03-02T07:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2516,6 +2650,7 @@ "isoDate": "2024-03-02T08:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2524,6 +2659,7 @@ "isoDate": "2024-03-02T09:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2532,6 +2668,7 @@ "isoDate": "2024-03-02T10:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2540,6 +2677,7 @@ "isoDate": "2024-03-02T11:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2548,6 +2686,7 @@ "isoDate": "2024-03-02T12:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2556,6 +2695,7 @@ "isoDate": "2024-03-02T13:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2564,6 +2704,7 @@ "isoDate": "2024-03-02T14:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2572,6 +2713,7 @@ "isoDate": "2024-03-02T15:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2580,6 +2722,7 @@ "isoDate": "2024-03-02T16:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2588,6 +2731,7 @@ "isoDate": "2024-03-02T17:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2596,6 +2740,7 @@ "isoDate": "2024-03-02T18:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2604,6 +2749,7 @@ "isoDate": "2024-03-02T19:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2612,6 +2758,7 @@ "isoDate": "2024-03-02T20:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2620,6 +2767,7 @@ "isoDate": "2024-03-02T21:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2628,6 +2776,7 @@ "isoDate": "2024-03-02T22:00:00.000Z", "usdValue": 1000, "numTxs": 1, + "revenueUsd": 0, "currencyCodes": { "ETH": 500, "DOGE": 500 @@ -2641,6 +2790,7 @@ "isoDate": "2024-03-02T23:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2649,6 +2799,7 @@ "isoDate": "2024-03-03T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} } @@ -2656,7 +2807,7 @@ "numAllTxs": 5 }, "app": "app-dummy", - "pluginId": "partner-dummy", + "partnerId": "partner-dummy", "start": 1708992000, "end": 1709424000 }, @@ -2684,6 +2835,7 @@ "isoDate": "2022-12-01T00:00:00.000Z", "usdValue": 920, "numTxs": 1, + "revenueUsd": 0, "currencyCodes": { "ETH": 460, "BTC": 460 @@ -2697,6 +2849,7 @@ "isoDate": "2023-01-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2705,6 +2858,7 @@ "isoDate": "2023-02-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2713,6 +2867,7 @@ "isoDate": "2023-03-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2721,6 +2876,7 @@ "isoDate": "2023-04-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2729,6 +2885,7 @@ "isoDate": "2023-05-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2737,6 +2894,7 @@ "isoDate": "2023-06-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2745,6 +2903,7 @@ "isoDate": "2023-07-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2753,6 +2912,7 @@ "isoDate": "2023-08-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2761,6 +2921,7 @@ "isoDate": "2023-09-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2769,6 +2930,7 @@ "isoDate": "2023-10-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2777,6 +2939,7 @@ "isoDate": "2023-11-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2785,6 +2948,7 @@ "isoDate": "2023-12-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} }, @@ -2793,6 +2957,7 @@ "isoDate": "2024-01-01T00:00:00.000Z", "usdValue": 3, "numTxs": 1, + "revenueUsd": 0, "currencyCodes": { "BTC": 1.5, "DOGE": 1.5 @@ -2806,6 +2971,7 @@ "isoDate": "2024-02-01T00:00:00.000Z", "usdValue": 0, "numTxs": 0, + "revenueUsd": 0, "currencyCodes": {}, "currencyPairs": {} } @@ -2815,7 +2981,7 @@ "numAllTxs": 2 }, "app": "app-dummy", - "pluginId": "partner-dummy", + "partnerId": "partner-dummy", "start": 1672444800, "end": 1706918400 } From 209a56a277b529dfa22dc2e7c0a31fa046218816 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:48:22 -0700 Subject: [PATCH 14/24] Add Swapter partner plugin Queries Swapter's tool-history endpoint and maps each order to a StandardTx. Pages are buffered so a mid-page failure retries idempotently, the upper time bound is frozen before the walk so paging cannot drift, and progress only advances once the full walk completes. --- src/partners/swapter.ts | 286 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 src/partners/swapter.ts diff --git a/src/partners/swapter.ts b/src/partners/swapter.ts new file mode 100644 index 00000000..e186918d --- /dev/null +++ b/src/partners/swapter.ts @@ -0,0 +1,286 @@ +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 + +// Hard ceiling on pages per run. Every loop below already terminates on the +// partner's own signal, but that makes termination the partner's decision: a +// stuck cursor or a page that never shortens would spin the worker and grow the +// in-memory batch without bound. Hitting the cap ends the run WITHOUT advancing +// progress, so the unread remainder is simply re-queried next cycle, exactly +// like the retry-exhaustion path. +const MAX_PAGES = 200 +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() + + // An empty string is what an unprovisioned partner entry looks like in Couch, + // so treat it as unconfigured exactly as nym, revolut and nexchange do rather + // than calling Swapter every cycle with no credential. + if (apiKey == null || apiKey === '') { + 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 + + let pageCount = 0 + for (; pageCount < MAX_PAGES; pageCount++) { + 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 + } + } + } + + if (pageCount >= MAX_PAGES) { + log.warn( + `Swapter hit the ${MAX_PAGES}-page cap; progress is not advanced, so the remainder is re-queried next run` + ) + } + + 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 + } +} From c6f5abc31d2c38e8c4bbdba8072e500677c49587 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:48:25 -0700 Subject: [PATCH 15/24] 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, falling back to a built-in decimals table when that fetch fails. The report timestamp keys off createdDate, because completedDate is null even on settled orders. Chain pluginIds are recorded per order so pair keys can distinguish assets that share a ticker across chains. --- src/partners/nym.ts | 496 ++++++++++++++++++++++++++++++++++++++++++++ test/nym.test.ts | 220 ++++++++++++++++++++ 2 files changed, 716 insertions(+) create mode 100644 src/partners/nym.ts create mode 100644 test/nym.test.ts diff --git a/src/partners/nym.ts b/src/partners/nym.ts new file mode 100644 index 00000000..c2f24900 --- /dev/null +++ b/src/partners/nym.ts @@ -0,0 +1,496 @@ +import { + asArray, + asMaybe, + asNumber, + asObject, + asOptional, + asString, + asUnknown, + asValue +} from 'cleaners' + +import { + PartnerPlugin, + PluginParams, + PluginResult, + StandardTx, + Status +} from '../types' +import { + describeRawTx, + retryFetch, + safeParseFloat, + smartIsoDateFromTimestamp, + snooze +} from '../util' +import { + ChainNameToPluginIdMapping, + createTokenId, + EdgeTokenId, + tokenTypes +} from '../util/asEdgeTokenId' +import { EVM_CHAIN_IDS, REVERSE_EVM_CHAIN_IDS } from '../util/chainIds' + +// Reports API `sourceNetwork` / `destinationNetwork` -> Edge currency pluginId. +// Quote-side `chainNetwork` uses `nyx` for native NYM; the reports endpoint +// sends `NYM`. Both map to Edge pluginId `nym` (see edge-exchange-plugins +// src/mappings/nym.ts). `BTC` is the ticker used as a network name on reports. +export const NYM_NETWORK_TO_PLUGIN_ID: ChainNameToPluginIdMapping = { + bitcoin: 'bitcoin', + btc: 'bitcoin', + ethereum: 'ethereum', + nym: 'nym', + nyx: 'nym' +} + +// 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 + +// Hard ceiling on pages per run. Every loop below already terminates on the +// partner's own signal, but that makes termination the partner's decision: a +// stuck cursor or a page that never shortens would spin the worker and grow the +// in-memory batch without bound. Hitting the cap ends the run WITHOUT advancing +// progress, so the unread remainder is simply re-queried next cycle, exactly +// like the retry-exhaustion path. +const MAX_PAGES = 200 + +// 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, + sourceNetwork: asMaybe(asString), + sourceEvmChainId: asMaybe(asNumber), + payinAddress: asMaybe(asString), + payinTxid: asMaybe(asString), + + // Destination (payout) side. + destinationCurrencyCode: asString, + destinationTokenId: asMaybe(asString), + destinationAmount: asString, + destinationNetwork: asMaybe(asString), + destinationEvmChainId: asMaybe(asNumber), + 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 + + let page = 0 + for (; page < MAX_PAGES; page++) { + 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 + } + } + } + + if (page >= MAX_PAGES) { + log.warn( + `NYM hit the ${MAX_PAGES}-page cap; progress is not advanced, so the remainder is re-queried next run` + ) + } + + 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)}: ${describeRawTx(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 + ) + + const depositAsset = resolveNymChain( + tx.sourceNetwork, + tx.sourceEvmChainId, + tx.sourceTokenId, + tx.sourceCurrencyCode + ) + const payoutAsset = resolveNymChain( + tx.destinationNetwork, + tx.destinationEvmChainId, + tx.destinationTokenId, + tx.destinationCurrencyCode + ) + + const standardTx: StandardTx = { + status: statusMap[tx.status], + orderId: tx.orderId, + countryCode: null, + depositTxid: tx.payinTxid, + depositAddress: tx.payinAddress, + depositCurrency: tx.sourceCurrencyCode.toUpperCase(), + depositChainPluginId: depositAsset.chainPluginId, + depositEvmChainId: depositAsset.evmChainId, + depositTokenId: depositAsset.tokenId, + depositAmount, + direction: null, + exchangeType: 'swap', + paymentType: null, + payoutTxid: tx.payoutTxid, + payoutAddress: tx.payoutAddress, + payoutCurrency: tx.destinationCurrencyCode.toUpperCase(), + payoutChainPluginId: payoutAsset.chainPluginId, + payoutEvmChainId: payoutAsset.evmChainId, + payoutTokenId: payoutAsset.tokenId, + payoutAmount, + timestamp, + isoDate, + usdValue: -1, + rawTx + } + return standardTx +} + +interface NymChainInfo { + chainPluginId: string | undefined + evmChainId: number | undefined + tokenId: EdgeTokenId | undefined +} + +/** + * Map a NYM reports-API network name (and optional EVM chain id / contract) + * to Edge pluginId, evmChainId, and tokenId. Missing network and chain id + * leave the fields unset so older partial records still parse. An unknown + * network name throws. + */ +export function resolveNymChain( + network: string | undefined, + evmChainId: number | undefined, + contractAddress: string | undefined, + currencyCode: string +): NymChainInfo { + let chainPluginId: string | undefined + if (evmChainId != null && REVERSE_EVM_CHAIN_IDS[evmChainId] != null) { + chainPluginId = REVERSE_EVM_CHAIN_IDS[evmChainId] + } else if (network != null && network !== '') { + chainPluginId = NYM_NETWORK_TO_PLUGIN_ID[network.toLowerCase()] + if (chainPluginId == null) { + throw new Error( + `Unknown NYM network "${network}" for ${currencyCode}. Add mapping to NYM_NETWORK_TO_PLUGIN_ID.` + ) + } + } + + if (chainPluginId == null) { + return { + chainPluginId: undefined, + evmChainId: undefined, + tokenId: undefined + } + } + + const resolvedEvmChainId = EVM_CHAIN_IDS[chainPluginId] ?? evmChainId + if (contractAddress == null || contractAddress === '') { + return { + chainPluginId, + evmChainId: resolvedEvmChainId, + tokenId: null + } + } + + const tokenType = tokenTypes[chainPluginId] + if (tokenType == null) { + throw new Error( + `Unknown tokenType for chainPluginId ${chainPluginId} (currency: ${currencyCode}, network: ${network ?? + 'null'}). Add tokenType to tokenTypes.` + ) + } + return { + chainPluginId, + evmChainId: resolvedEvmChainId, + tokenId: createTokenId( + tokenType, + currencyCode.toUpperCase(), + contractAddress + ) + } +} diff --git a/test/nym.test.ts b/test/nym.test.ts new file mode 100644 index 00000000..5e83b463 --- /dev/null +++ b/test/nym.test.ts @@ -0,0 +1,220 @@ +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) + expect(standardTx.depositChainPluginId).to.equal('ethereum') + expect(standardTx.depositEvmChainId).to.equal(1) + expect(standardTx.depositTokenId).to.equal(null) + expect(standardTx.payoutChainPluginId).to.equal('nym') + expect(standardTx.payoutEvmChainId).to.equal(undefined) + expect(standardTx.payoutTokenId).to.equal(null) + }) + + 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) + sourceNetwork: 'ethereum', + sourceEvmChainId: 1, + payinAddress: '0x1111111111111111111111111111111111111111', + payinTxid: null, + destinationCurrencyCode: 'NYM', + destinationTokenId: null, + destinationAmount: '1901660695', // 1901.660695 NYM (6 decimals) + destinationNetwork: 'NYM', + 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) + expect(standardTx.depositChainPluginId).to.equal('ethereum') + expect(standardTx.depositTokenId).to.equal( + 'dac17f958d2ee523a2206206994597c13d831ec7' + ) + expect(standardTx.payoutChainPluginId).to.equal('nym') + expect(standardTx.payoutTokenId).to.equal(null) + // 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) + }) + + it('maps reports-API BTC and nyx aliases to Edge pluginIds', function() { + const standardTx = processNymTx( + { + orderId: 'order_aliases', + status: 'completed', + createdDate: '2026-07-22T00:00:00.000Z', + sourceCurrencyCode: 'BTC', + sourceAmount: '10000', + sourceNetwork: 'BTC', + destinationCurrencyCode: 'NYM', + destinationAmount: '5000000', + destinationNetwork: 'nyx' + }, + pluginParams + ) + expect(standardTx.depositChainPluginId).to.equal('bitcoin') + expect(standardTx.payoutChainPluginId).to.equal('nym') + }) + + it('throws for an unknown NYM network name', function() { + expect(() => + processNymTx( + { + orderId: 'order_unknownnet', + status: 'completed', + createdDate: '2026-07-22T00:00:00.000Z', + sourceCurrencyCode: 'ETH', + sourceAmount: '1000000000000000000', + sourceNetwork: 'sepolia', + destinationCurrencyCode: 'NYM', + destinationAmount: '5000000', + destinationNetwork: 'NYM' + }, + pluginParams + ) + ).to.throw(/Unknown NYM network "sepolia"/) + }) +}) From b7ceb1641b847f3c74a1fd3721b7cd6c0c4017d6 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:48:29 -0700 Subject: [PATCH 16/24] Add Revolut fiat payment provider Queries Revolut Ramp's orders endpoint with the X-API-KEY header. Three shapes of that API each break a naive port: start and end are date-only, the response is a bare array paged by skip and limit rather than a cursor envelope, and order ids are not unique because Revolut returns one row per payment attempt. Since orderId keys the StandardTx document, attempts are collapsed to one winner per id, settled beating unsettled, before anything is emitted. Revolut reports its own partner fee in USD, so a settled order carries it as reported revenue. Native assets resolve to their Edge chain with a null tokenId. Tokens resolve their chain but leave tokenId undefined on purpose: Revolut reports no contract address, and minting one from a guess would mis-price the asset. --- src/partners/revolut.ts | 420 ++++++++++++++++++++++++++++++++++++++++ test/revolut.test.ts | 309 +++++++++++++++++++++++++++++ 2 files changed, 729 insertions(+) create mode 100644 src/partners/revolut.ts create mode 100644 test/revolut.test.ts diff --git a/src/partners/revolut.ts b/src/partners/revolut.ts new file mode 100644 index 00000000..1c7e94cf --- /dev/null +++ b/src/partners/revolut.ts @@ -0,0 +1,420 @@ +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 { + describeRawTx, + 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 + +// Hard ceiling on pages per run. Every loop below already terminates on the +// partner's own signal, but that makes termination the partner's decision: a +// stuck cursor or a page that never shortens would spin the worker and grow the +// in-memory batch without bound. Hitting the cap ends the run WITHOUT advancing +// progress, so the unread remainder is simply re-queried next cycle, exactly +// like the retry-exhaustion path. +const MAX_PAGES = 200 + +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), + // Edge's actual cut, pre-converted by Revolut to the partner's settlement + // currency (USD across the full live history). asMaybe throughout: FAILED + // rows commonly omit the whole block. + fees_partner_currency: asMaybe( + asObject({ + partner_fee: asMaybe(asRevolutAmount) + }) + ) +}) +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 + + let page = 0 + for (; page < MAX_PAGES; page++) { + 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 + } + } + } + + if (page >= MAX_PAGES) { + log.warn( + `Revolut hit the ${MAX_PAGES}-page cap; progress is not advanced, so the remainder is re-queried next run` + ) + } + + 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)}: ${describeRawTx(rawTx)}`) + throw e + } + + const { isoDate, timestamp } = smartIsoDateFromTimestamp(tx.created_at) + const payout = resolveRevolutAsset(tx.crypto.currencyId) + + // Actual revenue, only when Revolut reports it in USD and the order settled: + // an unsettled attempt's fee is not revenue, and a non-USD settlement + // currency would need a conversion this plugin cannot do honestly. + const partnerFee = tx.fees_partner_currency?.partner_fee + const revenueUsd = + tx.status === 'COMPLETED' && + partnerFee != null && + partnerFee.currency === 'USD' + ? partnerFee.amount + : undefined + + // 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, + revenueUsd, + revenueSource: revenueUsd != null ? 'reported' : undefined, + 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/test/revolut.test.ts b/test/revolut.test.ts new file mode 100644 index 00000000..ab1955cb --- /dev/null +++ b/test/revolut.test.ts @@ -0,0 +1,309 @@ +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('stores the partner-reported USD fee as revenue on a settled order', function() { + const standardTx = processRevolutTx( + makeOrder({ + fees_partner_currency: { + partner_fee: { amount: 1.51, currency: 'USD' } + } + }), + pluginParams + ) + expect(standardTx.revenueUsd).to.equal(1.51) + expect(standardTx.revenueSource).to.equal('reported') + }) + + it('records no revenue on an unsettled attempt even when a fee is present', function() { + // A failed attempt's fee is not revenue. + const standardTx = processRevolutTx( + makeOrder({ + status: 'FAILED', + fees_partner_currency: { + partner_fee: { amount: 1.51, currency: 'USD' } + } + }), + pluginParams + ) + expect(standardTx.revenueUsd).to.equal(undefined) + expect(standardTx.revenueSource).to.equal(undefined) + }) + + it('records no revenue when the settlement currency is not USD', function() { + // The plugin cannot convert honestly, so it abstains rather than guesses. + const standardTx = processRevolutTx( + makeOrder({ + fees_partner_currency: { + partner_fee: { amount: 1.3, currency: 'EUR' } + } + }), + pluginParams + ) + expect(standardTx.revenueUsd).to.equal(undefined) + }) + + it('records no revenue when the fee block is absent', function() { + const standardTx = processRevolutTx(makeOrder(), pluginParams) + expect(standardTx.revenueUsd).to.equal(undefined) + expect(standardTx.revenueSource).to.equal(undefined) + }) + + it('throws on a structurally invalid order', function() { + expect(() => + processRevolutTx({ id: 'no-amounts' }, pluginParams) + ).to.throw() + }) +}) From 74eb8a04fc3bef20840f833e13eb939f3055b3db Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:48:33 -0700 Subject: [PATCH 17/24] Harden nexchange cleaners and native-asset detection Two defects that only surface against live data. The nullable response fields used asOptional, which supplies its fallback for a missing key but still throws when the key is present with an unexpected type. One odd row therefore aborts the whole page, exhausts the retries, and stalls the window, so the same row is re-hit every cycle and the plugin never progresses. A zero contract address denotes the chain's native gas asset, but only a missing or empty address was treated as native, so createTokenId would mint a tokenId for the gas asset and mis-route its rates and volume. --- src/partners/nexchange.ts | 50 +++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/src/partners/nexchange.ts b/src/partners/nexchange.ts index bf27fcb0..3a452fc5 100644 --- a/src/partners/nexchange.ts +++ b/src/partners/nexchange.ts @@ -2,6 +2,7 @@ import { asArray, asBoolean, asEither, + asMaybe, asNull, asObject, asOptional, @@ -22,7 +23,7 @@ import { createTokenId, tokenTypes } from '../util/asEdgeTokenId' import { EVM_CHAIN_IDS } from '../util/chainIds' // n.exchange endpoints are fixed for all deployments; they intentionally are -// not exposed via apiKeys. Auth uses the modern `x-api-key` header — the +// not exposed via apiKeys. Auth uses the modern `x-api-key` header, the // legacy `Authorization: ApiKey ` form is not used. const BASE_URL = 'https://api.n.exchange/en/api/v1' const CURRENCY_URL = 'https://api.n.exchange/en/api/v2/currency/' @@ -30,8 +31,8 @@ const CURRENCY_URL = 'https://api.n.exchange/en/api/v2/currency/' const asNexchangeTransfer = asObject({ currency: asString, amount: asString, - address: asOptional(asEither(asString, asNull), null), - txid: asOptional(asEither(asString, asNull), null) + address: asMaybe(asEither(asString, asNull), null), + txid: asMaybe(asEither(asString, asNull), null) }) const asNexchangeOrder = asObject({ @@ -40,12 +41,12 @@ const asNexchangeOrder = asObject({ createdAt: asString, deposit: asNexchangeTransfer, payout: asNexchangeTransfer, - countryCode: asOptional(asEither(asString, asNull), null) + countryCode: asMaybe(asEither(asString, asNull), null) }) const asNexchangeOrdersResponse = asObject({ orders: asArray(asUnknown), - nextCursor: asOptional(asEither(asString, asNull), null), + nextCursor: asMaybe(asEither(asString, asNull), null), hasMore: asBoolean }) @@ -55,9 +56,9 @@ const asNexchangeOrdersResponse = asObject({ const asNexchangeCurrencyMeta = asObject({ code: asString, is_fiat: asOptional(asBoolean, false), - network: asOptional(asEither(asString, asNull), null), - contract_address: asOptional(asEither(asString, asNull), null), - common_symbol: asOptional(asEither(asString, asNull), null) + network: asMaybe(asEither(asString, asNull), null), + contract_address: asMaybe(asEither(asString, asNull), null), + common_symbol: asMaybe(asEither(asString, asNull), null) }) const asNexchangeCurrencyList = asArray(asNexchangeCurrencyMeta) @@ -67,6 +68,14 @@ export type NexchangeCurrencyInfoMap = Record const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days const LIMIT = 200 + +// Hard ceiling on pages per run. Every loop below already terminates on the +// partner's own signal, but that makes termination the partner's decision: a +// stuck cursor or a page that never shortens would spin the worker and grow the +// in-memory batch without bound. Hitting the cap ends the run WITHOUT advancing +// progress, so the unread remainder is simply re-queried next cycle, exactly +// like the retry-exhaustion path. +const MAX_PAGES = 200 const MAX_ERROR_TEXT_LENGTH = 500 const statusMap: { [key: string]: Status } = { @@ -98,7 +107,7 @@ const statusMap: { [key: string]: Status } = { // // n.exchange uses TRON as the canonical network name in the v2 currency // catalog, but historical Edge audit-orders payloads have also been observed -// to reference TRX — both are mapped so the plugin works regardless of which +// to reference TRX. Both are mapped so the plugin works regardless of which // the API returns. export const NEXCHANGE_NETWORK_TO_PLUGIN_ID: Record = { ada: 'cardano', @@ -225,7 +234,7 @@ function asUnmapped(currencyCode: string): ResolvedNexchangeAsset { * currency-code mappings. * * Throws when an asset has a contract address (i.e. it is a token) but cannot - * be converted into an Edge tokenId — either because Edge does not model + * be converted into an Edge tokenId, either because Edge does not model * tokens on that chain, or because the address fails createTokenId. This is * deliberate: a token must never be silently downgraded to a native * (tokenId: null) mapping, which would price it with the chain's gas-token @@ -266,8 +275,16 @@ export function resolveNexchangeAsset( const evmChainId = EVM_CHAIN_IDS[chainPluginId] const contractAddress = meta.contract_address - // No contract_address means a native chain asset. - if (contractAddress == null || contractAddress === '') { + // A missing/empty contract_address, or a zero address (0x000…0), denotes the + // chain's native/gas asset, not a token. The zero-address case matters because + // createTokenId would otherwise mint a non-null tokenId for the gas asset and + // mis-route its rates and volume (Banxa and Moonpay special-case this the same + // way). + if ( + contractAddress == null || + contractAddress === '' || + /^0x0+$/i.test(contractAddress) + ) { return { currencyCode: normalizedCode, chainPluginId, @@ -309,6 +326,7 @@ export async function queryNexchange( const txByOrderId: Map = new Map() let cursor: string | undefined let offset = 0 + let page = 0 try { // The currency catalog supplies the network/contract metadata that the @@ -317,7 +335,7 @@ export async function queryNexchange( // nothing) rather than persisting a batch of unenriched transactions. const currencyMap = await fetchNexchangeCurrencyMap() - while (true) { + for (; page < MAX_PAGES; page++) { const params: string[] = [ `dateFrom=${encodeURIComponent(queryDateFrom)}`, `limit=${LIMIT.toString()}`, @@ -370,6 +388,12 @@ export async function queryNexchange( // retried rather than lost. } + if (page >= MAX_PAGES) { + log.warn( + `nexchange hit the ${MAX_PAGES}-page cap; pagination is ascending so progress advances to the last processed order and the remainder resumes next run` + ) + } + return { settings: { latestIsoDate }, transactions: Array.from(txByOrderId.values()) From e9d8dc902773eb828ec8cd265474a31f7a3ab289 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:48:37 -0700 Subject: [PATCH 18/24] Register the new reporting plugins Wires Swapter, NYM and Revolut into the query engine and the demo partner list, alongside the nexchange entry already present. --- src/demo/partners.ts | 12 ++++++++++++ src/queryEngine.ts | 14 +++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/demo/partners.ts b/src/demo/partners.ts index 97048697..6e233310 100644 --- a/src/demo/partners.ts +++ b/src/demo/partners.ts @@ -101,6 +101,10 @@ export default { type: 'swap', color: '#1D31B6' }, + nymswap: { + type: 'swap', + color: '#FB6E4E' + }, paybis: { type: 'fiat', color: '#FFB400' @@ -113,6 +117,10 @@ export default { type: 'swap', color: '#5891EE' }, + revolut: { + type: 'fiat', + color: '#191C33' + }, safello: { type: 'fiat', color: deprecated @@ -125,6 +133,10 @@ export default { type: 'swap', color: '#E35852' }, + swapter: { + type: 'swap', + color: '#00C9A7' + }, swapuz: { type: 'swap', color: '#56BD7C' diff --git a/src/queryEngine.ts b/src/queryEngine.ts index ee34571c..c407eabd 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -23,12 +23,15 @@ import { libertyx } from './partners/libertyx' import { lifi } from './partners/lifi' import { moonpay } from './partners/moonpay' import { nexchange } from './partners/nexchange' +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' @@ -77,12 +80,15 @@ const plugins = [ maya, moonpay, nexchange, + nymswap, paybis, paytrie, rango, + revolut, safello, sideshift, simplex, + swapter, swapuz, switchain, thorchain, @@ -198,7 +204,13 @@ const checkUpdateTx = (oldTx: StandardTx, newTx: StandardTx): string[] => { 'payoutTxid', 'payoutChainPluginId', 'payoutEvmChainId', - 'payoutTokenId' + 'payoutTokenId', + // Partner-reported revenue arrives late: a partner can settle the fee after + // the order row already exists. Without these the engine sees no tracked + // change, skips the write, and the revenue never reaches Couch or the + // analytics cache. + 'revenueUsd', + 'revenueSource' ] as const const changedFields: string[] = [] for (const field of fields) { From e6996c2e0b5482f72203453f95c87015991be8c1 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:49:06 -0700 Subject: [PATCH 19/24] Quarantine unprocessable partner transactions instead of stalling ChangeNow, Rango and Xgram each let a per-transaction failure escape the processing loop. That looks like the safe choice, but it stalls the partner outright: the run halts, progress saves just short of the bad row, and every later poll re-fetches the same range and dies there, so nothing newer is ever recorded. Xgram was worse again, because its loop sat outside the try/catch guarding the fetch, so the throw discarded every order already processed in that run. Emitting the row anyway is not the alternative either, since that prices a token with the chain's gas-token rate. So the row is quarantined: dropped, never emitted with wrong data, and reported with its id and a per-run count. Ingestion continues past it. Shared failures stay shared, so ChangeNow loads its currency cache once outside the guard, where an outage still aborts the run rather than being mistaken for a bad row. LetsExchange had the mirror defect. It treated an order predating the API's network fields and a recent order it simply could not resolve as the same thing, so the second was saved with chain and token data silently undefined. The two are now distinguished and the unresolvable one is reported, with guidance that splits single-chain tickers from multi-chain ones. Every loop also runs under a page cap, so a stuck cursor or a page that never shortens can no longer leave the run length as the partner's decision. --- src/partners/changenow.ts | 55 +++++++++++++++- src/partners/letsexchange.ts | 51 ++++++++++++--- src/partners/rango.ts | 36 +++++++++-- src/partners/xgram.ts | 122 ++++++++++++++++++++++++++++++++--- 4 files changed, 237 insertions(+), 27 deletions(-) diff --git a/src/partners/changenow.ts b/src/partners/changenow.ts index 2d6a110a..bc44aa87 100644 --- a/src/partners/changenow.ts +++ b/src/partners/changenow.ts @@ -17,7 +17,7 @@ import { StandardTx, Status } from '../types' -import { retryFetch, snooze } from '../util' +import { describeRawTx, retryFetch, snooze } from '../util' import { ChainNameToPluginIdMapping, createTokenId, @@ -252,6 +252,10 @@ const MAX_RETRIES = 5 const LIMIT = 200 const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days +// Hard ceiling on pages per run, matching the sibling plugins: without it the +// offset walk is bounded only by the partner's own "no more rows" signal. +const MAX_PAGES = 200 + const statusMap: { [key in ChangeNowStatus]: Status } = { finished: 'complete', waiting: 'pending', @@ -278,7 +282,21 @@ export const queryChangeNow = async ( let offset = 0 let retry = 0 - while (true) { + // Orders dropped because they could not be processed. Surfaced after the + // walk so a recurring mapping gap is visible as a count, not just as + // scattered error lines. + let skipped = 0 + let page = 0 + + // Load the shared currency cache ONCE, here, and let a failure propagate. + // processChangeNowTx also calls this, but inside the per-order guard below a + // currencies-endpoint outage would be indistinguishable from an unmappable + // order: every row would be "skipped" while the offset kept advancing, so a + // transient outage would quietly walk the whole window and record nothing. + // Failing the run here keeps that an abort, which is what it is. + await loadCurrencyCache(log) + + while (page < MAX_PAGES) { const url = `https://api.changenow.io/v2/exchanges?sortDirection=ASC&limit=${LIMIT}&dateFrom=${previousLatestIsoDate}&offset=${offset}` try { @@ -301,7 +319,26 @@ export const queryChangeNow = async ( break } for (const rawTx of txs) { - const standardTx = await processChangeNowTx(rawTx, pluginParams) + // Quarantine, rather than either of the two failure modes that look + // like opposites but are both data loss. Letting the error propagate + // stalls the whole partner: the outer catch retries the same + // deterministic failure, breaks, and every later poll re-fetches the + // same range and dies on the same row, so nothing newer is ever + // recorded. Emitting the row anyway would price a token with the + // chain's gas-token rate. So the row is dropped and reported LOUDLY, + // and ingestion continues past it. + let standardTx: StandardTx + try { + standardTx = await processChangeNowTx(rawTx, pluginParams) + } catch (e) { + skipped++ + log.error( + `ChangeNow: skipping unprocessable order, ingestion continues: ${String( + e + )}: ${describeRawTx(rawTx)}` + ) + continue + } standardTxs.push(standardTx) if (standardTx.isoDate > latestIsoDate) { latestIsoDate = standardTx.isoDate @@ -309,6 +346,7 @@ export const queryChangeNow = async ( } log(`offset ${offset} latestIsoDate ${latestIsoDate}`) offset += txs.length + page++ retry = 0 } catch (e) { log.error(String(e)) @@ -323,6 +361,17 @@ export const queryChangeNow = async ( } } } + if (page >= MAX_PAGES) { + log.warn( + `ChangeNow hit the ${MAX_PAGES}-page cap; the remainder resumes next run from the saved watermark` + ) + } + if (skipped > 0) { + log.error( + `ChangeNow: ${skipped} order(s) skipped as unprocessable this run; each is logged above and needs a mapping fix plus a backfill` + ) + } + const out: PluginResult = { settings: { latestIsoDate }, transactions: standardTxs diff --git a/src/partners/letsexchange.ts b/src/partners/letsexchange.ts index c57bc28f..81155954 100644 --- a/src/partners/letsexchange.ts +++ b/src/partners/letsexchange.ts @@ -252,15 +252,28 @@ const LETSEXCHANGE_CURRENCY_TO_DEFAULT_NETWORK: Record = { ZEC: 'ZEC' } +/** + * Resolves the network for an order. + * + * Returns `null` for the two cases that mean different things, so the caller + * can tell them apart: an order predating the API's network fields is an + * expected gap, while a recent order we cannot resolve is a mapping hole that + * must be reported rather than absorbed. `unresolved` marks the second. + */ function resolveNetworkCode( network: string | null, currencyCode: string, isoDate: string -): string | null { - if (network != null) return network - if (isoDate < NETWORK_FIELDS_AVAILABLE_DATE) return null +): { network: string | null; unresolved: boolean } { + if (network != null) return { network, unresolved: false } + // Predates the API exposing network fields at all: nothing to report. + if (isoDate < NETWORK_FIELDS_AVAILABLE_DATE) { + return { network: null, unresolved: false } + } const currencyUpper = currencyCode.toUpperCase() - return LETSEXCHANGE_CURRENCY_TO_DEFAULT_NETWORK[currencyUpper] ?? null + const fallback = LETSEXCHANGE_CURRENCY_TO_DEFAULT_NETWORK[currencyUpper] + if (fallback != null) return { network: fallback, unresolved: false } + return { network: null, unresolved: true } } // Native token placeholder addresses that should be treated as null (native coin) @@ -359,10 +372,30 @@ function getAssetInfo( initialNetwork: string | null, currencyCode: string, contractAddress: string | null, - isoDate: string + isoDate: string, + log: PluginParams['log'] ): AssetInfo | undefined { - const network = resolveNetworkCode(initialNetwork, currencyCode, isoDate) + const { network, unresolved } = resolveNetworkCode( + initialNetwork, + currencyCode, + isoDate + ) if (network == null) { + // A recent order whose network we cannot resolve loses chainPluginId, + // evmChainId and tokenId permanently, and the asset then gets priced by + // currency code alone. Silently is the one way that must not happen (see + // the same policy stated in changenow.ts and nexchange.ts), so name the + // currency that needs adding to LETSEXCHANGE_CURRENCY_TO_DEFAULT_NETWORK. + if (unresolved) { + // The fallback map is native-ticker only, on purpose, so pointing every + // case at it would be wrong advice: inventing a default network for a + // multi-chain ticker like USDT or USDC would mis-attribute the asset + // rather than fix anything. Which remedy applies depends on the ticker, + // so say so instead of guessing. + log.error( + `LetsExchange: no network for ${currencyCode} on a ${isoDate} order; chain and token data dropped. If ${currencyCode} exists on exactly one chain, add it to LETSEXCHANGE_CURRENCY_TO_DEFAULT_NETWORK; if it is multi-chain, the API omitted the network field and the gap has to be raised with LetsExchange.` + ) + } return undefined } @@ -567,14 +600,16 @@ export async function processLetsExchangeTx( tx.coin_from_network ?? tx.network_from_code, tx.coin_from, tx.coin_from_contract_address, - isoDate + isoDate, + log ) // Get payout asset info using contract address from API response const payoutAsset = getAssetInfo( tx.coin_to_network ?? tx.network_to_code, tx.coin_to, tx.coin_to_contract_address, - isoDate + isoDate, + log ) const status = statusMap[tx.status] diff --git a/src/partners/rango.ts b/src/partners/rango.ts index ccfdbe6d..4c0ded99 100644 --- a/src/partners/rango.ts +++ b/src/partners/rango.ts @@ -18,7 +18,7 @@ import { StandardTx, Status } from '../types' -import { retryFetch } from '../util' +import { describeRawTx, retryFetch } from '../util' import { createTokenId, tokenTypes } from '../util/asEdgeTokenId' import { EVM_CHAIN_IDS } from '../util/chainIds' @@ -141,6 +141,9 @@ export async function queryRango( } const standardTxs: StandardTx[] = [] + // Transactions dropped because they could not be processed, surfaced as a + // count after the walk so a recurring mapping gap is visible. + let skipped = 0 let startMs = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK if (startMs < 0) startMs = 0 @@ -181,12 +184,25 @@ export async function queryRango( let processedCount = 0 for (const rawTx of txs) { - // Do not catch per-tx errors: a failure here (e.g. a token that cannot - // be resolved to a tokenId) must halt the run rather than silently - // dropping the transaction. The outer catch saves progress up to the - // last fully processed tx, and the oldest-to-newest ordering means the - // failing tx is retried on the next run. - const standardTx = processRangoTx(rawTx, pluginParams) + // Quarantine the unprocessable tx: drop it, report it LOUDLY, and keep + // going. Halting instead does not "retry the failing tx next run", it + // stalls the partner outright, because the next run re-fetches the same + // range and dies on the same row, so nothing newer is ever recorded. + // Emitting it anyway is the other failure mode: a token priced with the + // chain's gas-token rate. Neither is acceptable, so the row is skipped + // and the error names it for a mapping fix and backfill. + let standardTx: StandardTx + try { + standardTx = processRangoTx(rawTx, pluginParams) + } catch (e) { + skipped++ + log.error( + `Rango: skipping unprocessable tx, ingestion continues: ${String( + e + )}: ${describeRawTx(rawTx)}` + ) + continue + } standardTxs.push(standardTx) processedCount++ @@ -213,6 +229,12 @@ export async function queryRango( // This ensures we don't lose transactions on transient failures } + if (skipped > 0) { + log.error( + `Rango: ${skipped} tx(s) skipped as unprocessable this run; each is logged above and needs a mapping fix plus a backfill` + ) + } + const out: PluginResult = { settings: { latestIsoDate }, transactions: standardTxs diff --git a/src/partners/xgram.ts b/src/partners/xgram.ts index e09a8f49..334c6515 100644 --- a/src/partners/xgram.ts +++ b/src/partners/xgram.ts @@ -12,14 +12,13 @@ import { } from 'cleaners' import { - asStandardPluginParams, PartnerPlugin, PluginParams, PluginResult, StandardTx, Status } from '../types' -import { retryFetch, safeParseFloat, snooze } from '../util' +import { describeRawTx, retryFetch, safeParseFloat, snooze } from '../util' import { createTokenId, EdgeTokenId, tokenTypes } from '../util/asEdgeTokenId' import { EVM_CHAIN_IDS } from '../util/chainIds' @@ -77,6 +76,13 @@ interface EdgeAssetInfo { const MAX_RETRIES = 5 const LIMIT = 50 + +// Hard ceiling on pages per run, matching the sibling plugins. Every exit below +// is driven by a partner-supplied signal, which leaves how long the worker runs +// as the partner's decision; a page that never empties would spin indefinitely. +// Xgram only advances its watermark on a clean completion, so a capped run +// re-queries the same range next cycle rather than skipping orders. +const MAX_PAGES = 200 const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours @@ -345,6 +351,23 @@ function parseAmount( return safeParseFloat(amount) } +/** + * Best-effort isoDate for an order that failed to process, used only to keep the + * newest-to-oldest walk's stop condition working across quarantined rows. + * Returns null when even the raw date is unusable, in which case that row simply + * cannot participate in the boundary test. + */ +function readXgramIsoDate(rawTx: unknown): string | null { + if (typeof rawTx !== 'object' || rawTx === null) return null + const date = (rawTx as { [key: string]: unknown }).date + if (typeof date !== 'string') return null + try { + return parseXgramDate(date).isoDate + } catch { + return null + } +} + function parseXgramDate(date: string): { isoDate: string; timestamp: number } { const match = date.match(/^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}:\d{2}:\d{2})$/) if (match == null) { @@ -358,13 +381,36 @@ function parseXgramDate(date: string): { isoDate: string; timestamp: number } { return { isoDate: parsed.toISOString(), timestamp: parsed.getTime() / 1000 } } +/** + * Xgram walks newest to oldest, so a run that stops early cannot express its + * progress as a watermark: the watermark tracks the NEWEST order, while an + * early stop leaves the OLDEST end unfinished. These two extra settings carry + * that state instead. + * + * `resumePage` is the page the next run should start from, and + * `pendingLatestIsoDate` is the newest order seen so far in a walk that has not + * finished yet. Without them, a run that hit the page cap would restart at page + * zero every cycle, refetch the same newest pages forever, and never reach + * older history, so any backfill larger than one run's cap could never complete. + */ +const asXgramPluginParams = asObject({ + settings: asObject({ + latestIsoDate: asOptional(asString, '1970-01-01T00:00:00.000Z'), + resumePage: asOptional(asNumber, 0), + pendingLatestIsoDate: asOptional(asString) + }), + apiKeys: asObject({ + apiKey: asMaybe(asString) + }) +}) + export const queryXgram = async ( pluginParams: PluginParams ): Promise => { const { log } = pluginParams - const { settings, apiKeys } = asStandardPluginParams(pluginParams) + const { settings, apiKeys } = asXgramPluginParams(pluginParams) const { apiKey } = apiKeys - const { latestIsoDate } = settings + const { latestIsoDate, resumePage, pendingLatestIsoDate } = settings if (apiKey == null) { return { settings: { latestIsoDate }, transactions: [] } @@ -384,12 +430,18 @@ export const queryXgram = async ( // permanent fetch failure) must leave the persisted watermark untouched so // the next run re-queries the same range instead of skipping the orders that // were never reached. - let newLatestIsoDate = latestIsoDate + // Carry the in-progress candidate across a resumed walk: the newest orders + // are only seen on the first run of that walk, so re-deriving it here would + // throw away the real watermark. + let newLatestIsoDate = pendingLatestIsoDate ?? latestIsoDate let completed = false - let page = 0 + // Orders dropped as unprocessable, surfaced as a count after the walk. + let skipped = 0 + let page = resumePage let retry = 0 let done = false - while (!done) { + const pageBudget = resumePage + MAX_PAGES + while (!done && page < pageBudget) { const url = `https://xgram.io/api/v1/exchange-history?page=${page}&limit=${LIMIT}` let txs try { @@ -427,7 +479,40 @@ export const queryXgram = async ( } let oldestIsoDate = '999999999999999999999999999999999999' for (const rawTx of txs) { - const standardTx = processXgramTx(rawTx, currencies) + // Quarantine rather than throwing out of queryXgram entirely. This loop + // sits outside the try/catch that guards the fetch, so an unresolvable + // currency used to reject the whole promise: runPlugin caught it at top + // level and never persisted anything, discarding every order already + // processed on earlier pages of the same run. Sibling plugins all keep + // their processing step recoverable; this one now does too. + let standardTx: StandardTx + try { + standardTx = processXgramTx(rawTx, currencies) + } catch (e) { + skipped++ + log.error( + `Xgram: skipping unprocessable order, ingestion continues: ${String( + e + )}: ${describeRawTx(rawTx)}` + ) + // The walk runs newest to oldest and stops at the lookback boundary, so + // the boundary test cannot depend on an order having processed + // successfully: a page of quarantined rows would otherwise never look + // old enough to stop, and the walk would keep paging back through + // history until the page cap. Re-read the date straight off the raw + // payload, which is a plain string and survives whatever made the rest + // of the record unprocessable. + const skippedIsoDate = readXgramIsoDate(rawTx) + if (skippedIsoDate != null) { + if (skippedIsoDate < oldestIsoDate) oldestIsoDate = skippedIsoDate + if (skippedIsoDate < targetIsoDate) { + completed = true + done = true + break + } + } + continue + } if (standardTx.isoDate < oldestIsoDate) { oldestIsoDate = standardTx.isoDate } @@ -449,8 +534,27 @@ export const queryXgram = async ( page += 1 retry = 0 } + if (!completed && page >= pageBudget) { + log.warn( + `Xgram hit its ${MAX_PAGES}-page budget at page ${page}; the walk resumes there next run rather than restarting` + ) + } + if (skipped > 0) { + log.error( + `Xgram: ${skipped} order(s) skipped as unprocessable this run; each is logged above and needs a mapping fix plus a backfill` + ) + } + const out: PluginResult = { - settings: { latestIsoDate: completed ? newLatestIsoDate : latestIsoDate }, + settings: completed + ? { latestIsoDate: newLatestIsoDate, resumePage: 0 } + : { + // The walk is unfinished, so the watermark stays put and the next run + // picks the backwards walk up where this one stopped. + latestIsoDate, + resumePage: page, + pendingLatestIsoDate: newLatestIsoDate + }, transactions: standardTxs } return out From 420b0d762b9ece30beb3e99213dde2a1a3784dad Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:49:10 -0700 Subject: [PATCH 20/24] Store chain pluginIds on Paybis and Kado orders Both plugins recorded currency codes without the chain they settled on, so pair keys could not tell apart assets that share a ticker. Each order now carries its resolved chain pluginId, which is what the chained pair keys in the analytics cache consume. --- src/partners/kado.ts | 115 +++++++++++++++++++++++++------ src/partners/paybis.ts | 101 +++++++++++++++++++++------ test/kado.test.ts | 67 ++++++++++++++++++ test/paybis.test.ts | 152 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 394 insertions(+), 41 deletions(-) create mode 100644 test/kado.test.ts create mode 100644 test/paybis.test.ts diff --git a/src/partners/kado.ts b/src/partners/kado.ts index 0cd37d5f..9646485d 100644 --- a/src/partners/kado.ts +++ b/src/partners/kado.ts @@ -18,8 +18,14 @@ import { PluginResult, StandardTx } from '../types' -import { retryFetch, smartIsoDateFromTimestamp, snooze } from '../util' -import { queryDummy } from './dummy' +import { + describeRawTx, + retryFetch, + smartIsoDateFromTimestamp, + snooze +} from '../util' +import { ChainNameToPluginIdMapping, EdgeTokenId } from '../util/asEdgeTokenId' +import { EVM_CHAIN_IDS } from '../util/chainIds' // Define cleaner for individual transactions in onRamps and offRamps const asTxType = asValue('buy', 'sell') @@ -63,6 +69,16 @@ const asResponse = asObject({ const MAX_RETRIES = 5 +// Kado `network` values from live orders. Lookup is lowercased so `Solana` +// and `solana` share a row. +export const KADO_NETWORK_TO_PLUGIN_ID: ChainNameToPluginIdMapping = { + bitcoin: 'bitcoin', + ethereum: 'ethereum', + injective: 'injective', + litecoin: 'litecoin', + solana: 'solana' +} + export async function queryKado( pluginParams: PluginParams ): Promise { @@ -78,6 +94,8 @@ export async function queryKado( } const standardTxs: StandardTx[] = [] + // Orders dropped as unmappable, surfaced as a count after the walk. + let skipped = 0 let retry = 0 const url = `https://api.kado.money/v2/organizations/${apiKey}/orders` @@ -90,12 +108,25 @@ export async function queryKado( const jsonObj = await response.json() const transferResults = asResponse(jsonObj) const { onRamps, offRamps } = transferResults.data - for (const rawTx of onRamps) { - const standardTx: StandardTx = processKadoTx(rawTx) - standardTxs.push(standardTx) - } - for (const rawTx of offRamps) { - const standardTx: StandardTx = processKadoTx(rawTx) + // Quarantine an unmappable order rather than letting it escape into the + // fetch catch below. That catch snoozes without re-requesting and still + // returns the truncated batch, so runPlugin would record a successful + // update while every order after the bad row went missing, and each later + // cycle would sleep on the same mapping error. Dropping the row loudly + // keeps the rest of the batch flowing and names what needs mapping. + for (const rawTx of [...onRamps, ...offRamps]) { + let standardTx: StandardTx + try { + standardTx = processKadoTx(rawTx) + } catch (e) { + skipped++ + log.error( + `Kado: skipping unprocessable order, ingestion continues: ${String( + e + )}: ${describeRawTx(rawTx)}` + ) + continue + } standardTxs.push(standardTx) } log(`latestIsoDate:${latestIsoDate}`) @@ -113,6 +144,12 @@ export async function queryKado( } } + if (skipped > 0) { + log.error( + `Kado: ${skipped} order(s) skipped as unmappable this run; each is logged above and needs a KADO_NETWORK_TO_PLUGIN_ID entry plus a backfill` + ) + } + const out = { settings: {}, transactions: standardTxs @@ -121,16 +158,52 @@ export async function queryKado( } export const kado: PartnerPlugin = { - queryFunc: queryDummy, + queryFunc: queryKado, pluginName: 'Kado', pluginId: 'kado' } +interface KadoChainInfo { + chainPluginId: string | undefined + evmChainId: number | undefined + tokenId: EdgeTokenId | undefined +} + +const emptyKadoChain = (): KadoChainInfo => ({ + chainPluginId: undefined, + evmChainId: undefined, + tokenId: undefined +}) + +/** + * Map Kado's `network` field to an Edge pluginId. Kado does not send a + * contract on the order, so tokenId stays undefined. An unknown network + * throws so a new chain is not stored as ticker-only. + */ +export function resolveKadoChain(network: string): KadoChainInfo { + if (network === '') { + return emptyKadoChain() + } + const chainPluginId = KADO_NETWORK_TO_PLUGIN_ID[network.toLowerCase()] + if (chainPluginId == null) { + throw new Error( + `Unknown Kado network "${network}". Add mapping to KADO_NETWORK_TO_PLUGIN_ID.` + ) + } + return { + chainPluginId, + evmChainId: EVM_CHAIN_IDS[chainPluginId], + tokenId: undefined + } +} + export function processKadoTx(rawTx: unknown): StandardTx { const tx = asKadoTx(rawTx) const { isoDate, timestamp } = smartIsoDateFromTimestamp( tx.createdAt.toISOString() ) + const cryptoChain = resolveKadoChain(tx.network) + const fiatChain = emptyKadoChain() if ('paidAmountUsd' in tx) { return { status: 'complete', @@ -139,9 +212,9 @@ export function processKadoTx(rawTx: unknown): StandardTx { depositTxid: undefined, depositAddress: undefined, depositCurrency: 'USD', - depositChainPluginId: undefined, - depositEvmChainId: undefined, - depositTokenId: undefined, + depositChainPluginId: fiatChain.chainPluginId, + depositEvmChainId: fiatChain.evmChainId, + depositTokenId: fiatChain.tokenId, depositAmount: tx.paidAmountUsd, direction: tx.type, exchangeType: 'fiat', @@ -149,9 +222,9 @@ export function processKadoTx(rawTx: unknown): StandardTx { payoutTxid: undefined, payoutAddress: tx.walletAddress, payoutCurrency: tx.cryptoCurrency, - payoutChainPluginId: undefined, - payoutEvmChainId: undefined, - payoutTokenId: undefined, + payoutChainPluginId: cryptoChain.chainPluginId, + payoutEvmChainId: cryptoChain.evmChainId, + payoutTokenId: cryptoChain.tokenId, payoutAmount: tx.receiveUnitCount, timestamp, isoDate, @@ -166,9 +239,9 @@ export function processKadoTx(rawTx: unknown): StandardTx { depositTxid: undefined, depositAddress: undefined, depositCurrency: tx.cryptoCurrency, - depositChainPluginId: undefined, - depositEvmChainId: undefined, - depositTokenId: undefined, + depositChainPluginId: cryptoChain.chainPluginId, + depositEvmChainId: cryptoChain.evmChainId, + depositTokenId: cryptoChain.tokenId, depositAmount: tx.depositUnitCount, direction: tx.type, exchangeType: 'fiat', @@ -176,9 +249,9 @@ export function processKadoTx(rawTx: unknown): StandardTx { payoutTxid: undefined, payoutAddress: undefined, payoutCurrency: 'USD', - payoutChainPluginId: undefined, - payoutEvmChainId: undefined, - payoutTokenId: undefined, + payoutChainPluginId: fiatChain.chainPluginId, + payoutEvmChainId: fiatChain.evmChainId, + payoutTokenId: fiatChain.tokenId, payoutAmount: tx.receiveUsd, timestamp, isoDate, diff --git a/src/partners/paybis.ts b/src/partners/paybis.ts index 355be8e4..a2926347 100644 --- a/src/partners/paybis.ts +++ b/src/partners/paybis.ts @@ -23,6 +23,8 @@ import { Status } from '../types' import { retryFetch, smartIsoDateFromTimestamp, snooze } from '../util' +import { ChainNameToPluginIdMapping, EdgeTokenId } from '../util/asEdgeTokenId' +import { EVM_CHAIN_IDS } from '../util/chainIds' const PLUGIN_START_DATE = '2023-09-01T00:00:00.000Z' const asStatuses = asMaybe( @@ -56,22 +58,33 @@ const asUser = asObject({ // currency: asOptional(asString) // }) -// More complex structures -// const asBlockchain = asObject({ -// name: asString, -// network: asString -// }) -// const asCurrencyDetail = asObject({ -// id: asString, -// name: asString, -// currency: asObject({ -// code: asCurrencyCode -// }), -// blockchain: asOptional(asBlockchain) -// }) +// Paybis `to.asset.blockchain.name` / `from.asset.blockchain.name` values +// observed on live orders. `network` is mainnet/testnet, not the chain. +export const PAYBIS_BLOCKCHAIN_TO_PLUGIN_ID: ChainNameToPluginIdMapping = { + bitcoin: 'bitcoin', + 'bitcoin-cash': 'bitcoincash', + bitcoincash: 'bitcoincash', + dogecoin: 'dogecoin', + ethereum: 'ethereum', + litecoin: 'litecoin', + polygon: 'polygon', + ripple: 'ripple', + solana: 'solana', + tron: 'tron' +} + +const asPaybisBlockchain = asObject({ + name: asString, + network: asOptional(asString) +}) +const asPaybisAsset = asObject({ + id: asOptional(asString), + name: asOptional(asString), + blockchain: asOptional(asPaybisBlockchain) +}) const asFromToStructure = asObject({ name: asString, - // asset: asOptional(asCurrencyDetail), + asset: asMaybe(asPaybisAsset), address: asOptional(asString) // destinationTag: asOptional(asString) }) @@ -261,6 +274,46 @@ export const paybis: PartnerPlugin = { pluginId: 'paybis' } +interface PaybisChainInfo { + chainPluginId: string | undefined + evmChainId: number | undefined + tokenId: EdgeTokenId | undefined +} + +const emptyChain = (): PaybisChainInfo => ({ + chainPluginId: undefined, + evmChainId: undefined, + tokenId: undefined +}) + +/** + * Resolve Edge chain fields from a Paybis asset. Paybis does not send a + * contract address on the order, so tokenId stays undefined even when the + * chain is known. Missing asset/blockchain (the fiat leg) leaves chain + * fields unset. An unknown blockchain.name throws so a new chain is not + * silently stored as ticker-only. + */ +export function resolvePaybisChain( + asset: ReturnType | undefined +): PaybisChainInfo { + const blockchainName = asset?.blockchain?.name + if (blockchainName == null || blockchainName === '') { + return emptyChain() + } + const chainPluginId = + PAYBIS_BLOCKCHAIN_TO_PLUGIN_ID[blockchainName.toLowerCase()] + if (chainPluginId == null) { + throw new Error( + `Unknown Paybis blockchain "${blockchainName}". Add mapping to PAYBIS_BLOCKCHAIN_TO_PLUGIN_ID.` + ) + } + return { + chainPluginId, + evmChainId: EVM_CHAIN_IDS[chainPluginId], + tokenId: undefined + } +} + export function processPaybisTx(rawTx: unknown): StandardTx { const tx = asPaybisTx(rawTx) const { amounts, createdAt, gateway, hash, id } = tx @@ -274,6 +327,14 @@ export function processPaybisTx(rawTx: unknown): StandardTx { const payoutTxid = gateway === 'fiat_to_crypto' ? hash : undefined const direction = gateway === 'fiat_to_crypto' ? 'buy' : 'sell' + const cryptoChain = + direction === 'buy' + ? resolvePaybisChain(tx.to.asset) + : resolvePaybisChain(tx.from.asset) + const fiatChain = emptyChain() + const depositChain = direction === 'buy' ? fiatChain : cryptoChain + const payoutChain = direction === 'buy' ? cryptoChain : fiatChain + const standardTx: StandardTx = { status: statusMap[tx.status], orderId: id, @@ -281,9 +342,9 @@ export function processPaybisTx(rawTx: unknown): StandardTx { depositTxid, depositAddress: undefined, depositCurrency: spentOriginal.currency, - depositChainPluginId: undefined, - depositEvmChainId: undefined, - depositTokenId: undefined, + depositChainPluginId: depositChain.chainPluginId, + depositEvmChainId: depositChain.evmChainId, + depositTokenId: depositChain.tokenId, depositAmount, direction, exchangeType: 'fiat', @@ -291,9 +352,9 @@ export function processPaybisTx(rawTx: unknown): StandardTx { payoutTxid, payoutAddress: tx.to.address, payoutCurrency: receivedOriginal.currency, - payoutChainPluginId: undefined, - payoutEvmChainId: undefined, - payoutTokenId: undefined, + payoutChainPluginId: payoutChain.chainPluginId, + payoutEvmChainId: payoutChain.evmChainId, + payoutTokenId: payoutChain.tokenId, payoutAmount, timestamp, isoDate, diff --git a/test/kado.test.ts b/test/kado.test.ts new file mode 100644 index 00000000..86ead921 --- /dev/null +++ b/test/kado.test.ts @@ -0,0 +1,67 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { processKadoTx, resolveKadoChain } from '../src/partners/kado' + +describe('processKadoTx', function() { + it('stores the payout chain on a buy', function() { + const standardTx = processKadoTx({ + _id: 'kado-buy-btc', + walletAddress: 'bc1qexamplewalletaddress00000000000000000', + createdAt: '2024-01-26T00:55:30.547Z', + type: 'buy', + walletType: 'manual_input', + cryptoCurrency: 'BTC', + network: 'bitcoin', + receiveUnitCount: 0.001, + paidAmountUsd: 50, + paymentMethod: 'wire_transfer' + }) + expect(standardTx.direction).to.equal('buy') + expect(standardTx.depositCurrency).to.equal('USD') + expect(standardTx.depositChainPluginId).to.equal(undefined) + expect(standardTx.payoutCurrency).to.equal('BTC') + expect(standardTx.payoutChainPluginId).to.equal('bitcoin') + }) + + it('treats Solana and solana as the same chain', function() { + const standardTx = processKadoTx({ + _id: 'kado-buy-sol', + walletAddress: 'SoLExamp1eAddress000000000000000000000000', + createdAt: '2024-01-26T00:55:30.547Z', + type: 'buy', + walletType: 'manual_input', + cryptoCurrency: 'SOL', + network: 'Solana', + receiveUnitCount: 1, + paidAmountUsd: 20, + paymentMethod: 'wire_transfer' + }) + expect(standardTx.payoutChainPluginId).to.equal('solana') + }) + + it('maps ethereum to pluginId plus evmChainId', function() { + expect(resolveKadoChain('ethereum')).to.deep.equal({ + chainPluginId: 'ethereum', + evmChainId: 1, + tokenId: undefined + }) + }) + + it('throws for an unknown network', function() { + expect(() => + processKadoTx({ + _id: 'kado-unknown', + walletAddress: 'addr', + createdAt: '2024-01-26T00:55:30.547Z', + type: 'buy', + walletType: 'manual_input', + cryptoCurrency: 'XYZ', + network: 'not-a-chain', + receiveUnitCount: 1, + paidAmountUsd: 1, + paymentMethod: 'wire_transfer' + }) + ).to.throw(/Unknown Kado network "not-a-chain"/) + }) +}) diff --git a/test/paybis.test.ts b/test/paybis.test.ts new file mode 100644 index 00000000..8be59084 --- /dev/null +++ b/test/paybis.test.ts @@ -0,0 +1,152 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { processPaybisTx } from '../src/partners/paybis' + +describe('processPaybisTx', function() { + it('stores the payout chain on a fiat-to-crypto buy', function() { + const rawTx = { + id: 'pb-buy-btc', + gateway: 'fiat_to_crypto', + status: 'completed', + from: { name: 'Credit/Debit Card' }, + to: { + name: 'Bitcoin', + address: 'bc1qexamplewalletaddress00000000000000000', + asset: { + id: 'BTC', + name: 'Bitcoin', + blockchain: { name: 'bitcoin', network: 'mainnet' } + } + }, + createdAt: '2026-06-01T00:07:05.000Z', + amounts: { + spentOriginal: { amount: '100', currency: 'USD' }, + spentFiat: { amount: '100', currency: 'USD' }, + receivedOriginal: { amount: '0.001', currency: 'BTC' }, + receivedFiat: { amount: '100', currency: 'USD' } + }, + user: { country: { name: 'United States', code: 'US' } } + } + + const standardTx = processPaybisTx(rawTx) + expect(standardTx.direction).to.equal('buy') + expect(standardTx.depositCurrency).to.equal('USD') + expect(standardTx.depositChainPluginId).to.equal(undefined) + expect(standardTx.payoutCurrency).to.equal('BTC') + expect(standardTx.payoutChainPluginId).to.equal('bitcoin') + expect(standardTx.payoutEvmChainId).to.equal(undefined) + }) + + it('distinguishes USDT ERC20 from USDT TRC20 via blockchain.name', function() { + const trc20 = processPaybisTx({ + id: 'pb-usdt-trc20', + gateway: 'fiat_to_crypto', + status: 'completed', + from: { name: 'Credit/Debit Card' }, + to: { + name: 'Tether (TRC20)', + address: 'TExampleTronAddress000000000000000000', + asset: { + id: 'USDT-TRC20', + name: 'Tether (TRC20)', + blockchain: { name: 'tron', network: 'mainnet' } + } + }, + createdAt: '2026-06-01T00:00:00.000Z', + amounts: { + spentOriginal: { amount: '20', currency: 'USD' }, + spentFiat: { amount: '20', currency: 'USD' }, + receivedOriginal: { amount: '20', currency: 'USDT' }, + receivedFiat: { amount: '20', currency: 'USD' } + }, + user: { country: null } + }) + expect(trc20.payoutCurrency).to.equal('USDT') + expect(trc20.payoutChainPluginId).to.equal('tron') + + const erc20 = processPaybisTx({ + id: 'pb-usdt-erc20', + gateway: 'fiat_to_crypto', + status: 'completed', + from: { name: 'Credit/Debit Card' }, + to: { + name: 'Tether (ERC20)', + address: '0x1111111111111111111111111111111111111111', + asset: { + id: 'USDT', + name: 'Tether (ERC20)', + blockchain: { name: 'ethereum', network: 'mainnet' } + } + }, + createdAt: '2026-06-01T00:00:00.000Z', + amounts: { + spentOriginal: { amount: '20', currency: 'USD' }, + spentFiat: { amount: '20', currency: 'USD' }, + receivedOriginal: { amount: '20', currency: 'USDT' }, + receivedFiat: { amount: '20', currency: 'USD' } + }, + user: { country: null } + }) + expect(erc20.payoutChainPluginId).to.equal('ethereum') + expect(erc20.payoutEvmChainId).to.equal(1) + }) + + it('stores the deposit chain on a crypto-to-fiat sell', function() { + const standardTx = processPaybisTx({ + id: 'pb-sell-eth', + gateway: 'crypto_to_fiat', + status: 'completed', + from: { + name: 'Ethereum', + asset: { + id: 'ETH', + name: 'Ethereum', + blockchain: { name: 'ethereum', network: 'mainnet' } + } + }, + to: { name: 'Credit/Debit Card' }, + hash: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + createdAt: '2026-06-01T00:00:00.000Z', + amounts: { + spentOriginal: { amount: '0.01', currency: 'ETH' }, + spentFiat: { amount: '30', currency: 'USD' }, + receivedOriginal: { amount: '30', currency: 'USD' }, + receivedFiat: { amount: '30', currency: 'USD' } + }, + user: { country: null } + }) + expect(standardTx.direction).to.equal('sell') + expect(standardTx.depositCurrency).to.equal('ETH') + expect(standardTx.depositChainPluginId).to.equal('ethereum') + expect(standardTx.depositEvmChainId).to.equal(1) + expect(standardTx.payoutChainPluginId).to.equal(undefined) + }) + + it('throws for an unknown blockchain.name', function() { + expect(() => + processPaybisTx({ + id: 'pb-unknown-chain', + gateway: 'fiat_to_crypto', + status: 'completed', + from: { name: 'Credit/Debit Card' }, + to: { + name: 'Mystery', + asset: { + id: 'XYZ', + blockchain: { name: 'not-a-chain', network: 'mainnet' } + } + }, + createdAt: '2026-06-01T00:00:00.000Z', + amounts: { + spentOriginal: { amount: '1', currency: 'USD' }, + spentFiat: { amount: '1', currency: 'USD' }, + receivedOriginal: { amount: '1', currency: 'XYZ' }, + receivedFiat: { amount: '1', currency: 'USD' } + }, + user: { country: null } + }) + ).to.throw(/Unknown Paybis blockchain "not-a-chain"/) + }) +}) From 801acab0a0e99067e1ec0638ddbf49992db17054 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:49:14 -0700 Subject: [PATCH 21/24] Add isolated v2 reports dashboard A redesigned dashboard served at /v2/, fully separate from the v1 demo: its own entry point, its own parcel bundle, its own URL. No v1 route, component or build output changes. Where behaviour overlaps, code is duplicated into v2 rather than refactoring anything v1 depends on. It reads the real /v1 reporting API, plus an isolated /v2/config route for per-provider rev-share rates and fiat/swap classification. Rates live on the app doc in reports_apps beside each partner's credentials, never in source, because they are commercial terms and this repo is public. Revenue is a first-class metric rather than gross volume alone. Where a partner reports Edge's actual fee the dashboard uses that figure directly and marks it; everywhere else it estimates volume times the rate at read time, so a corrected rate fixes history at once while reported figures stay immutable. Dates are handled in UTC throughout, matching the buckets the API returns, and a seeded apiKey is stripped from the address bar once read so it does not persist in history or a copied link. A chain filter and a syncProdCache helper round it out. --- package.json | 3 +- src/bin/syncProdCache.ts | 179 ++++ src/demoV2/README.md | 88 ++ src/demoV2/index.html | 1673 ++++++++++++++++++++++++++++++++++++ src/indexApi.ts | 7 + src/routes/v2/getConfig.ts | 91 ++ 6 files changed, 2040 insertions(+), 1 deletion(-) create mode 100644 src/bin/syncProdCache.ts create mode 100644 src/demoV2/README.md create mode 100644 src/demoV2/index.html create mode 100644 src/routes/v2/getConfig.ts diff --git a/package.json b/package.json index 66f75176..2fe2c399 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "build.lib": "sucrase -q -t typescript,imports,jsx -d ./lib ./src", "build.types": "tsc", - "build.dist": "parcel build", + "build.dist": "parcel build && parcel build src/demoV2/index.html --dist-dir dist/v2 --public-url ./", "clean": "rimraf lib dist .parcel-cache", "configure": "configure", "fix": "npm run lint -- --fix", @@ -27,6 +27,7 @@ "stats": "node -r sucrase/register src/bin/partitionStats.ts", "test": "mocha -r sucrase/register 'test/**/*.test.ts'", "demo": "parcel serve src/demo/index.html", + "demo.v2": "parcel serve src/demoV2/index.html --dist-dir dist/v2 --public-url ./ -p 1235", "lint": "eslint --ext .js,.jsx,.ts,.tsx ." }, "lint-staged": { diff --git a/src/bin/syncProdCache.ts b/src/bin/syncProdCache.ts new file mode 100644 index 00000000..a3548c33 --- /dev/null +++ b/src/bin/syncProdCache.ts @@ -0,0 +1,179 @@ +/** + * Pull production dashboard cache DBs into the local CouchDB from + * config.json. Does not copy reports_transactions (~11 GB) or + * reports_hour (~40 GB). Dashboard /v2/ reads reports_apps + reports_day + * (and month for longer presets). + * + * Requires SSH to reports-wusa1.edge.app and a local Couch that already + * has partitioned DBs (`npm run setup`). Never point query/cache engines + * at the tunnel; this script only pulls. + * + * node -r sucrase/register src/bin/syncProdCache.ts + * node -r sucrase/register src/bin/syncProdCache.ts --apps-only + */ +import { ChildProcess, execFileSync, spawn } from 'child_process' +import { asBoolean, asJSON, asObject, asString } from 'cleaners' +import fetch from 'node-fetch' + +import { config } from '../config' + +const asRemoteConfig = asObject({ couchDbFullpath: asString }) +const asReplicateOk = asObject({ ok: asBoolean }) + +const PROD_HOST = 'reports-wusa1.edge.app' +const TUNNEL_PORT = 15984 +const PROD_CONFIG_PATH = '/tmp/reports-sanity/config.json' +const CACHE_DBS = ['reports_apps', 'reports_day', 'reports_month'] +const APPS_ONLY_DBS = ['reports_apps'] +const REPLICATE_TIMEOUT_MS = 4 * 60 * 60 * 1000 + +async function main(): Promise { + const appsOnly = process.argv.includes('--apps-only') + const dbNames = appsOnly ? APPS_ONLY_DBS : CACHE_DBS + const localUrl = trimSlash(config.couchDbFullpath ?? '') + if (localUrl === '') { + throw new Error('config.json couchDbFullpath is empty') + } + + await assertLocalCouch(localUrl) + for (const name of dbNames) { + await assertLocalDb(localUrl, name) + } + + console.log(`Opening SSH tunnel ${PROD_HOST} -> 127.0.0.1:${TUNNEL_PORT}`) + const prodUrl = readRemoteCouchUrl(PROD_HOST) + const tunneledUrl = withHostPort(prodUrl, '127.0.0.1', TUNNEL_PORT) + const ssh = startTunnel(PROD_HOST, TUNNEL_PORT) + try { + await waitForCouch(tunneledUrl, 20000) + for (const name of dbNames) { + console.log(`Replicating ${name} (pull from prod)`) + await replicate(tunneledUrl, localUrl, name) + console.log(`Done ${name}`) + } + } finally { + ssh.kill('SIGTERM') + } +} + +function readRemoteCouchUrl(host: string): string { + const raw = execFileSync('ssh', [host, 'cat', PROD_CONFIG_PATH], { + encoding: 'utf8' + }) + const parsed = asJSON(asRemoteConfig)(raw) + if (!parsed.couchDbFullpath.startsWith('http')) { + throw new Error( + `Remote ${PROD_CONFIG_PATH} did not yield a couch URL. Copy prod config.json to that path on the box.` + ) + } + return parsed.couchDbFullpath +} + +function startTunnel(host: string, localPort: number): ChildProcess { + const ssh = spawn('ssh', ['-N', '-L', `${localPort}:127.0.0.1:5984`, host], { + stdio: 'ignore' + }) + ssh.on('error', (error: unknown) => { + console.error('ssh tunnel failed', error) + }) + return ssh +} + +async function assertLocalCouch(localUrl: string): Promise { + try { + const response = await fetch(localUrl, { timeout: 3000 }) + if (!response.ok && response.status !== 401) { + throw new Error(`Local Couch HTTP ${response.status}`) + } + } catch (error) { + throw new Error( + `Local Couch is not reachable at ${redactUrl( + localUrl + )}. Start CouchDB, then npm run setup. (${ + error instanceof Error ? error.message : String(error) + })` + ) + } +} + +async function assertLocalDb(localUrl: string, name: string): Promise { + const response = await fetch(`${localUrl}/${name}`, { timeout: 10000 }) + if (response.status === 404) { + throw new Error(`Local DB ${name} is missing. Run: socket npm run setup`) + } + if (!response.ok) { + throw new Error(`Local DB ${name} HTTP ${response.status}`) + } +} + +async function waitForCouch(url: string, maxMs: number): Promise { + const deadline = Date.now() + maxMs + let lastError = 'timeout' + while (Date.now() < deadline) { + try { + const response = await fetch(url, { timeout: 2000 }) + if (response.ok || response.status === 401) return + lastError = `HTTP ${response.status}` + } catch (error) { + lastError = error instanceof Error ? error.message : String(error) + } + await sleep(400) + } + throw new Error(`Tunnel Couch did not answer: ${lastError}`) +} + +async function replicate( + sourceBase: string, + targetBase: string, + dbName: string +): Promise { + const response = await fetch(`${targetBase}/_replicate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: `${sourceBase}/${dbName}`, + target: `${targetBase}/${dbName}`, + create_target: false + }), + timeout: REPLICATE_TIMEOUT_MS + }) + const text = await response.text() + if (!response.ok) { + throw new Error(`_replicate ${dbName} HTTP ${response.status}: ${text}`) + } + const body = asJSON(asReplicateOk)(text) + if (!body.ok) { + throw new Error(`_replicate ${dbName} did not return ok: ${text}`) + } +} + +function withHostPort(urlStr: string, hostname: string, port: number): string { + const parsed = new URL(urlStr) + parsed.hostname = hostname + parsed.port = String(port) + return trimSlash(parsed.toString()) +} + +function redactUrl(urlStr: string): string { + try { + const parsed = new URL(urlStr) + if (parsed.password !== '') parsed.password = '***' + if (parsed.username !== '') parsed.username = '***' + return parsed.toString() + } catch { + return '[unparseable url]' + } +} + +function trimSlash(urlStr: string): string { + return urlStr.replace(/\/$/, '') +} + +async function sleep(ms: number): Promise { + await new Promise(resolve => setTimeout(resolve, ms)) +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +}) diff --git a/src/demoV2/README.md b/src/demoV2/README.md new file mode 100644 index 00000000..ba6d296e --- /dev/null +++ b/src/demoV2/README.md @@ -0,0 +1,88 @@ +# Edge Reports v2 dashboard + +An isolated redesign of the reports dashboard, served at `/v2/`. It is fully +separate from the v1 demo (`src/demo`): its own entry point, its own parcel +bundle (`dist/v2/`), and its own URL. v1 routes, components and build output are +untouched. Where behavior overlaps, code is duplicated into v2 rather than +refactoring anything v1 depends on. + +## What it is + +The full rendering and interaction engine is ported from the design prototype: +one summary card, a Providers section and a Currency pairs section, each with a +trend chart (stacked bars or lines), a share card (donut + ranked bars, hover +linked), and a detail table (the pair table paginated). Global filters (range, +type, providers, pairs) scope every card. Top-8-plus-Other color rules keep the +chart, share card and table in agreement. + +The only thing that changed from the prototype is the data source: the baked-in +sample generator is replaced by the real `/v1` reporting API. + +## Data flow + +- `GET /v1/getAppId?apiKey=`: validates the key. Returns plain text on a bad + key (400); v2 checks the response before parsing and redirects to the + key-entry screen instead of hanging (the v1 spinner-forever bug). +- `GET /v2/config?apiKey=`: per-provider rev-share rates and fiat/swap + classification. Isolated v2 route; no v1 route touched. +- `GET /v1/getPluginIds?apiKey=`: the app's registered providers. +- `POST /v1/analytics`: one call for all providers, `timePeriod: "day"`, last + 24 months. v2 rebuckets to month client-side for the longer presets. + +Auth reuses v1's simple apiKey model: the key lives in the `apiKey` cookie (a +`?apiKey=` query param also seeds it). No new auth system. + +### Est. revenue + +Where the partner's API reports Edge's actual fee per order (e.g. Revolut's +`partner_fee`, pre-converted to USD), the plugin stores it on the transaction as +`revenueUsd` with `revenueSource: 'reported'`, the cache engine sums it into the +analytics buckets, and the dashboard uses it directly, marked with a check in +the provider table. That figure is a fact about the order and never recomputed. + +For partners that report no fee, revenue is estimated at read time as +`volume * revShareRate`. The rate lives on the app doc in `reports_apps`, as an +optional `revShareRate` beside that partner's `apiKeys`: + +```json +"partnerIds": { + "moonpay": { "apiKeys": { "apiKey": "..." }, "revShareRate": 0.008 } +} +``` + +Per app and per partner, because the rate is a property of the deal. Estimating +at read time rather than at ingest means correcting a rate fixes history +immediately, while reported figures stay immutable. The rates are deliberately +not in source or `config.json`: they are commercial terms and this repo is +public. A partner with neither reported fees nor a rate contributes 0. + +## Local testing + +Run against a local CouchDB (never point at production). + +```bash +# 1) Build v1 + v2 bundles (v2 lands in dist/v2/). +npm run build.dist + +# 2) Serve the API + built dashboards. +npm run start.api # http://localhost:8008 + +# 3) Open the dashboard and enter an API key registered in reports_apps. +open http://localhost:8008/v2/ +``` + +The dashboard needs an app registered in the `reports_apps` database: a document +whose `_id` is the API key, with an `appId` and a `partnerIds` map. That is what +`getAppId`, `getPluginIds` and `validateApiKey` read. Populate transaction data +with the normal engines (`npm run start` to query partners, `npm run start.cache` +to build the hour/day/month cache buckets that `/v1/analytics` serves). + +For live-reload development of the v2 UI only: `npm run demo.v2` (Parcel dev +server on :1235). Point it at a running API via same-origin or a proxy; the +`build.dist` + `start.api` path above is the end-to-end test. + +Partner credentials belong in the `reports_apps` document, not in `config.json` +and not in any tracked file. Writing real keys into production is a human ops +step. A null apiKey makes a partner plugin skip silently and return no rows, so +"no data" and "never wired" look the same; check the key is populated before +concluding a plugin is broken. diff --git a/src/demoV2/index.html b/src/demoV2/index.html new file mode 100644 index 00000000..0e96fbe5 --- /dev/null +++ b/src/demoV2/index.html @@ -0,0 +1,1673 @@ + + +Edge Reports v2 + + + +
+
+ +

Edge Reports

+

Enter your API key to view the dashboard.

+ + + +
+
+
+ + +
Loading reports…
+ +
+
+
+ +
+

Edge Reports

+
v2 dashboard
+
+
+ + +
+ + +
+
+ +
+
+
+ + + +
+
+ +
+ +
+ + +
+
+
+
+
+ +
+ +
+ + +
+
+
+
+
+ +
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+ + +
+

Providers

+
+
+ +
+
+

Volume by provider

+
+
+ + + +
+
+ + +
+ +
+
+ +
+
+
+
+ +
+
+

Provider share

+ of volume in range · same top 8 + other as the chart above +
+ +
+ +
+ +
+
+

Provider detail

+ +
+ +
+
+
+
+
+ + +
+

Currency pairs

+
+
+ +
+
+

Volume by pair

+ across selected providers +
+
+ + +
+ +
+
+ +
+
+
+
+ +
+
+

Pair share

+ of volume in range · same top 8 + other as the chart above +
+ +
+ +
+ +
+
+

Pair detail

+ +
+ +
+
+
+
+
+
+
+
+ + diff --git a/src/indexApi.ts b/src/indexApi.ts index af6b06f3..43f5cfc5 100644 --- a/src/indexApi.ts +++ b/src/indexApi.ts @@ -8,7 +8,9 @@ import { analyticsRouter } from './routes/v1/analytics' import { checkTxsRouter } from './routes/v1/checkTxs' import { getAppIdRouter } from './routes/v1/getAppId' import { getPluginIdsRouter } from './routes/v1/getPluginIds' +// Disabled on deploy: not private enough and is scrapable. // import { getTxInfoRouter } from './routes/v1/getTxInfo' +import { getConfigRouter } from './routes/v2/getConfig' import { HttpError } from './util/httpErrors' export const nanoDb = nano(config.couchDbFullpath) @@ -31,6 +33,11 @@ async function main(): Promise { // Disabled: not private enough and is scrapable. // app.use('/v1/getTxInfo/', getTxInfoRouter) + // v2 dashboard config (isolated; v1 routes untouched). The static handler + // above serves the built /v2/ bundle from dist; this API path falls through + // it because no matching file exists in dist. + app.use('/v2/config/', getConfigRouter) + // Error router app.use(function(err, _req, res, _next) { console.error(err.stack) diff --git a/src/routes/v2/getConfig.ts b/src/routes/v2/getConfig.ts new file mode 100644 index 00000000..42473183 --- /dev/null +++ b/src/routes/v2/getConfig.ts @@ -0,0 +1,91 @@ +import { asMap, asMaybe, asNumber, asObject, asOptional } from 'cleaners' +import Router from 'express-promise-router' + +import partners from '../../demo/partners' +import { reportsApps } from '../../indexApi' + +/** + * Rev-share rates live on the app doc in `reports_apps`, as an optional + * `revShareRate` beside each partner's `apiKeys`. The rate is a property of the + * app-partner deal, so it is per app AND per partner, and it sits with the + * credentials that define the relationship: onboarding a partner is one doc + * edit, with no separate rates map to forget. The rates are commercial terms + * and this repo is public, so they are never committed or placed in + * config.json. A partner without one contributes 0 estimated revenue. + * + * Tolerant cleaner: only the fields this route consumes, and a malformed + * partner entry degrades to no rate rather than failing the whole request. + */ +const asAppRevShareRates = asObject({ + partnerIds: asMap(asMaybe(asObject({ revShareRate: asOptional(asNumber) }))) +}) + +const getRevShareRates = async ( + apiKey: string +): Promise<{ [partnerId: string]: number }> => { + // The app doc's _id IS the apiKey (same lookup validateApiKey uses), so a + // missing doc doubles as auth failure and is thrown to the caller. + const doc = await reportsApps.get(apiKey) + const { partnerIds } = asAppRevShareRates(doc) + const rates: { [partnerId: string]: number } = {} + for (const partnerId of Object.keys(partnerIds)) { + const rate = partnerIds[partnerId]?.revShareRate + if (rate != null) rates[partnerId] = rate + } + return rates +} + +/** + * Fiat/swap classification per pluginId, so the dashboard can offer its + * fiat-buy/sell vs swap filter. + * + * This cannot be derived from the data. `StandardTx.exchangeType` exists but is + * optional and was added long after most history was ingested, so it is absent + * from nearly every stored transaction, and `PartnerPlugin` carries no type at + * all. A static table is the only option. + * + * It is projected from the v1 registry rather than retyped, because the retyped + * copy drifted: it keyed Ionia gift cards, Fox Exchange and NYM by filename or + * lowercase instead of by their real pluginIds, and omitted banxa2, banxa3 and + * gebo entirely. Unknown pluginIds default to 'swap' on the client, so all four + * fiat providers among those rendered as swaps. + * + * Unknown pluginIds still default to 'swap' on the client. + */ +const extraProviderTypes: { [pluginId: string]: 'fiat' | 'swap' } = { + // Registered in edge-exchange-plugins but absent from the v1 registry, having + // never reported a transaction here. + bridgeless: 'swap' +} + +const providerTypes: { [pluginId: string]: 'fiat' | 'swap' } = { + ...extraProviderTypes +} +for (const pluginId of Object.keys(partners)) { + providerTypes[pluginId] = partners[pluginId].type +} + +export const getConfigRouter = Router() + +getConfigRouter.get('/', async function(req, res) { + const apiKey = req.query.apiKey + if (typeof apiKey !== 'string' || apiKey === '') { + res.status(400).send(`Missing Request fields.`) + return + } + + // Same auth as v1: the app doc keyed by apiKey. An unrecognized key 401s so + // the v2 client redirects to its key-entry screen instead of hanging. + let revShareRates: { [partnerId: string]: number } + try { + revShareRates = await getRevShareRates(apiKey) + } catch { + res.status(401).send(`Invalid API Key`) + return + } + + res.json({ + revShareRates, + providerTypes + }) +}) From 8fba1f1511f1a28663e5742ca8aa7921c8b3b001 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 16:49:14 -0700 Subject: [PATCH 22/24] Document the blocked partner reporting APIs nexchange, Simplex and Bridgeless have no working reporting plugin, and none of them is blocked on work this repo can do alone. Record what each one needs, with the live request and exact response behind every claim, so the investigation is not repeated. nexchange is credential-scoped: its key authenticates but returns 403 on the reporting resource. Simplex needs a server-side key only their support issues, and the shipped plugin also targets a retired host with the wrong auth header. Bridgeless needs no key at all, but its public API carries no timestamp, no referral filter and no resumable cursor, which is what makes a plugin infeasible rather than merely expensive. --- CHANGELOG.md | 16 ++++ docs/blocked-partner-reporting.md | 144 ++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 docs/blocked-partner-reporting.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 766cf5df..6a9a672f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## 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 +- added: Isolated v2 reports dashboard at /v2/ (real /v1 API data, apiKey auth with redirect on a bad key) +- added: Partner-reported revenue (revenueUsd/revenueSource) on StandardTx, summed through the analytics cache; Revolut reports it, and the v2 dashboard uses reported figures where present with volume x revShareRate as the estimate elsewhere +- added: Document the verified reporting-API status and unblock path for nexchange, Simplex and Bridgeless +- added: v2 dashboard chain filter, using chained pair keys (CODE@pluginId) in the analytics cache - 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 @@ -9,8 +17,16 @@ - changed: Add EVM chainId, pluginId, and tokenId fields to StandardTx - changed: Update Lifi to provide chainId, pluginId, and tokenId - changed: Use rates V3 for transactions with pluginId/tokenId +- changed: Store chain pluginIds on new Paybis, Nym, and Kado orders +- changed: v2 Select all applies only to the currently filtered providers and pairs +- changed: Show ChangeNOW with that spelling in the v2 provider list +- fixed: Classify banxa2, banxa3, gebo and Ionia gift cards as fiat in the v2 dashboard filter, by projecting the provider types from the v1 partner registry instead of a second hand-written copy +- fixed: Quarantine an unprocessable partner transaction instead of halting ingestion behind it in ChangeNow, Rango and Xgram, so one bad row no longer stops every newer transaction from being recorded +- fixed: Report LetsExchange orders whose network cannot be resolved instead of silently dropping their chain and token data +- fixed: Tolerate unexpected types on nexchange's nullable response fields, and treat a zero contract address as the chain's native asset instead of minting a tokenId for the gas asset - 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/docs/blocked-partner-reporting.md b/docs/blocked-partner-reporting.md new file mode 100644 index 00000000..d82f84fc --- /dev/null +++ b/docs/blocked-partner-reporting.md @@ -0,0 +1,144 @@ +# Blocked partner reporting APIs + +Three partners have no working reporting plugin, and none of them is blocked on +work this repo can do alone. This records what each one actually needs, with the +evidence, so the next person does not repeat the investigation. + +Everything below was established by a live request against the partner's own +API, not from documentation. Requests were read-only and used the credentials +already in `edge-react-gui/env.json`. Every claim here is reproducible with the +commands shown. + +Last verified 2026-07-31. + +## Summary + +| Partner | Plugin state | Blocked on | Who unblocks it | +| --- | --- | --- | --- | +| nexchange | Ships here, but unverifiable | API key lacks the reporting scope | nexchange support | +| Simplex | `src/partners/simplex.ts`, wrong host and auth | A server-side reporting key that does not exist yet | Simplex partner support | +| Bridgeless | No plugin | The partner API cannot express an incremental report | Bridgeless engineering | + +## nexchange + +The plugin ships in this branch, descended from +[#217](https://github.com/EdgeApp/edge-reports-server/pull/217). Two ports of it +existed for a while; this branch carries one copy, so there is nothing left to +deduplicate. + +Two fixes found while porting are applied here, and are worth carrying into any +other surviving copy: + +- Use `asMaybe` rather than `asOptional(asEither(asString, asNull), null)` on the + nullable response fields. `asOptional` still throws on an unexpected type, so + one odd row aborts the whole page, exhausts the retries and stalls the window. +- Treat a zero contract address (`0x000…0`) as the chain's native asset. Without + it `createTokenId` mints a non-null tokenId for the gas asset and mis-routes + its rates and volume. Banxa and Moonpay special-case this the same way. + +The remaining blocker is credentials, and it is independent of whose code lands. +The key in `env.json` (`NEXCHANGE_INIT.apiKey`) authenticates but is not +authorised for the reporting resource: + +``` +GET https://api.n.exchange/en/api/v1/audit-orders +-> HTTP 403 {"detail":"API key is not authorised for this resource"} +``` + +A 403 rather than a 401 is the point: the key is recognised, the scope is +missing. There is no self-serve key console. + +**Ask:** email support@n.exchange or the account manager for a reporting-scoped +key, and ask them to confirm the exact header and path for audit-orders. The +public v1/v2 OpenAPI specs document `Authorization: ApiKey ` and do not +mention `x-api-key` at all, while the ported plugin sends `x-api-key`, so the +header is worth confirming in the same message. + +## Simplex + +The shipped plugin is wrong in three independent ways, and only the first two can +be fixed without a key. + +**Host and path.** `turnkey.api.simplex.com/transactions` is not the reporting +API any more. The current one is `/reporting/v1/payments`, and it is not on the +turnkey host: + +``` +GET https://turnkey.api.simplex.com/reporting/v1/payments +-> HTTP 403 {"message":"Missing Authentication Token"} # API Gateway for "no such route" + +GET https://sandbox.test-simplexcc.com/reporting/v1/payments +-> HTTP 401 {"message":"Authorization header is missing"} # route exists, wants auth +``` + +**Auth scheme.** The plugin sends `X-API-KEY`. The reporting API ignores that +header entirely and wants `Authorization: ApiKey `. The error text +distinguishes the two cases exactly: + +``` +-H 'x-api-key: ' -> 401 "Authorization header is missing" +-H 'Authorization: Bearer ' -> 401 "Authorization Apikey has invalid format" +-H 'Authorization: ' -> 401 "Authorization Apikey has invalid format" +-H 'Authorization: ApiKey ' -> 401 "Invalid API key" +``` + +Only the last one gets past format validation to key validation, which is what +identifies it as the correct scheme. + +**Credential.** This is the blocker. `env.json` holds only the publishable +checkout credentials (`PLUGIN_API_KEYS.simplex.publicKey`, a `pk_live_` value, +plus the `jwtTokenProvider` flow). Reporting needs a separate server-side +partner key that Simplex issues on request and locks to a source IP. There is no +dashboard that mints one. + +The plugin is not rewritten here because the response body has never been seen. +Writing cleaners for an unobserved payload would be inventing the schema, and a +half-migrated plugin is worse than the current one. + +**Ask:** request a server-side reporting ApiKey from Simplex partner support, +supply the reports-server egress IP for their allowlist, and ask for one sample +`/reporting/v1/payments` response. The sample alone unblocks the rewrite; the key +unblocks verification. The IP lock also answers the key-exposure concern that got +Simplex reporting disabled in the first place. + +## Bridgeless + +Bridgeless is a public Cosmos-SDK L1, so there is no key to obtain. The +unauthenticated LCD at `https://rpc-api.node0.mainnet.bridgeless.com` does carry +Edge's attribution: Edge is referral 2 (`BRIDGELESS_INIT.referralId` in +`env.json`), and the module confirms it: + +``` +GET /cosmos/bridge/referrals/2 +-> {"referral":{"id":2,"withdrawal_address":"bridge1rcu…","commission_rate":"0.66"}} + +GET /cosmos/bridge/referrals/rewards/2 +-> accrued commission per token, currently 4 tokens with a non-zero to_claim +``` + +So aggregate commission is readable today. Per-order reporting is not, for three +reasons that compound: + +- **No timestamp.** A bridge transaction record carries `deposit_block` and + `deposit_chain_id` but no time of any kind. `StandardTx` requires `isoDate` and + `timestamp`, so every record would need a block lookup on its own origin chain + to be datable. The Cosmos tx index cannot supply it either: bridge state does + not move through user transactions, and sampled blocks contain none. +- **No referral filter.** `/cosmos/bridge/transactions` returns the whole set + (100,210 records) with no server-side filter, so attribution means scanning + everything and filtering `referral_id == 2` client-side. +- **No incremental cursor.** The store is hash-ordered, not append-ordered: + offsets 0, 50,000 and 100,000 return interleaved chains and unrelated block + heights. There is no watermark to resume from, so every run is a full rescan. + +Together those make a plugin infeasible rather than merely expensive. A 20,000 +record sample (20% of the store) contained exactly **one** record with +`referral_id == 2`, so the current shape would rescan 100,000+ untyped records +per cycle to recover a handful of Edge orders, and still could not date them. + +**Ask:** ask Bridgeless engineering for any one of these, in preference order: +a `referral_id` filter plus a timestamp on `/cosmos/bridge/transactions`; an +append-ordered or height-keyed cursor so the scan can resume; or an off-chain +partner reporting endpoint. Also worth asking why on-chain `referral_id == 2` is +so rare while `/referrals/rewards/2` shows real accrued commission, since that +gap suggests attribution is recorded somewhere this endpoint does not expose. From fb8780ec0eaa637e8beac6bc24578d3e63bfd73a Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Tue, 18 Aug 2026 16:03:22 +0000 Subject: [PATCH 23/24] WIP Map MoonPay Cash App and Banxa card payouts Recognize MoonPay Cash App payments and payouts, and normalize Banxa Checkout card payouts so both query cursors can advance. --- src/partners/banxa.ts | 2 ++ src/partners/moonpay.ts | 8 +++++--- src/types.ts | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/partners/banxa.ts b/src/partners/banxa.ts index 271b83f9..4713ae6b 100644 --- a/src/partners/banxa.ts +++ b/src/partners/banxa.ts @@ -587,6 +587,8 @@ function getFiatPaymentType(tx: BanxaTx): FiatPaymentType { case 'Sofort Transfer': return 'sofort' case 'Checkout Credit Card': + case 'Checkout Payouts': + case 'CHECKOUTPO': case 'Primer Credit Card': case 'WorldPay Credit Card': return 'credit' diff --git a/src/partners/moonpay.ts b/src/partners/moonpay.ts index 0bee4b3c..d1053d5c 100644 --- a/src/partners/moonpay.ts +++ b/src/partners/moonpay.ts @@ -454,6 +454,7 @@ export function processMoonpayTx(rawTx: unknown): StandardTx { const paymentMethodMap: Record = { ach_bank_transfer: 'ach', apple_pay: 'applepay', + cash_app: 'cashapp', credit_debit_card: 'credit', gbp_bank_transfer: 'fasterpayments', gbp_open_banking_payment: 'fasterpayments', @@ -470,7 +471,8 @@ const paymentMethodMap: Record = { function getFiatPaymentType(tx: MoonpayTxBase): FiatPaymentType | null { let paymentMethod: FiatPaymentType | null = null - switch (tx.paymentMethod) { + const rawPaymentMethod = tx.paymentMethod ?? tx.payoutMethod + switch (rawPaymentMethod) { case undefined: // Legacy buy transactions can omit paymentMethod entirely. Fall back to // cardType which Moonpay set on older card payments. @@ -488,11 +490,11 @@ function getFiatPaymentType(tx: MoonpayTxBase): FiatPaymentType | null { } break default: - paymentMethod = paymentMethodMap[tx.paymentMethod] + paymentMethod = paymentMethodMap[rawPaymentMethod] break } if (paymentMethod == null) { - throw new Error(`Unknown payment method: ${tx.paymentMethod} for ${tx.id}`) + throw new Error(`Unknown payment method: ${rawPaymentMethod} for ${tx.id}`) } return paymentMethod } diff --git a/src/types.ts b/src/types.ts index 31d21fae..ab3a7e2d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,6 +80,7 @@ const asFiatPaymentType = asValue( 'bpay', 'blueshyft', 'cash', + 'cashapp', 'colombiabank', 'credit', 'directtobank', From 227ff5abf193fdde39b00b178a3fa962218191b7 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Tue, 18 Aug 2026 16:12:51 +0000 Subject: [PATCH 24/24] WIP Map Banxa PIX payouts Normalize Banxa DLocal PIX payout labels so the query cursor can continue through newer sell orders. --- src/partners/banxa.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/partners/banxa.ts b/src/partners/banxa.ts index 4713ae6b..5fbe1ea3 100644 --- a/src/partners/banxa.ts +++ b/src/partners/banxa.ts @@ -627,6 +627,8 @@ function getFiatPaymentType(tx: BanxaTx): FiatPaymentType { return 'turkishbank' case 'ClearJunction Sell Sepa': return 'sepa' + case 'DLocal Brazil PIX Payout': + case 'DLOCALPIXPO': case 'Dlocal Brazil PIX': return 'pix' case 'DLocal South Africa IO':