diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..f1eb6e48 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: Test +on: [pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout the latest code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Setup Node + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: 18 + - name: Install dependencies + run: npm install --ignore-scripts + - name: Generate client config + run: node -r sucrase/register src/bin/configure.ts + - name: Run tests + run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 766cf5df..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. 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/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/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/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/demo/partners.ts b/src/demo/partners.ts index 14e7dc4b..6e233310 100644 --- a/src/demo/partners.ts +++ b/src/demo/partners.ts @@ -97,6 +97,14 @@ export default { type: 'fiat', color: '#7214F5' }, + nexchange: { + type: 'swap', + color: '#1D31B6' + }, + nymswap: { + type: 'swap', + color: '#FB6E4E' + }, paybis: { type: 'fiat', color: '#FFB400' @@ -109,6 +117,10 @@ export default { type: 'swap', color: '#5891EE' }, + revolut: { + type: 'fiat', + color: '#191C33' + }, safello: { type: 'fiat', color: deprecated @@ -121,6 +133,10 @@ export default { type: 'swap', color: '#E35852' }, + swapter: { + type: 'swap', + color: '#00C9A7' + }, swapuz: { type: 'swap', color: '#56BD7C' @@ -152,5 +168,9 @@ export default { xanpool: { type: 'fiat', color: '#46228B' + }, + xgram: { + type: 'swap', + color: '#0FA7B1' } } as const 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 76fe4e9d..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' -import { getTxInfoRouter } from './routes/v1/getTxInfo' +// 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) @@ -28,7 +30,13 @@ 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) + + // 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) { diff --git a/src/partners/banxa.ts b/src/partners/banxa.ts index e089b18c..5fbe1ea3 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' } } /** @@ -585,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' @@ -607,10 +611,15 @@ 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' + case 'KLARNA Checkout': + return 'klarna' case 'ZeroHash ACH Sell': case 'Fortress/Plaid ACH': return 'ach' @@ -618,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': diff --git a/src/partners/changenow.ts b/src/partners/changenow.ts index e4eb6814..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, @@ -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' }) @@ -248,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', @@ -274,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 { @@ -297,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 @@ -305,6 +346,7 @@ export const queryChangeNow = async ( } log(`offset ${offset} latestIsoDate ${latestIsoDate}`) offset += txs.length + page++ retry = 0 } catch (e) { log.error(String(e)) @@ -319,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 @@ -376,24 +429,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/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/letsexchange.ts b/src/partners/letsexchange.ts index e05a8718..81155954 100644 --- a/src/partners/letsexchange.ts +++ b/src/partners/letsexchange.ts @@ -201,6 +201,81 @@ 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' +} + +/** + * 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 +): { 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() + 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) // All values should be lowercase for case-insensitive matching const NATIVE_TOKEN_ADDRESSES = new Set([ @@ -297,15 +372,32 @@ function getAssetInfo( initialNetwork: string | null, currencyCode: string, contractAddress: string | null, - isoDate: string + isoDate: string, + log: PluginParams['log'] ): AssetInfo | undefined { - if (initialNetwork == null) { - if (isoDate < NETWORK_FIELDS_AVAILABLE_DATE) { - return undefined + 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.` + ) } - throw new Error(`Missing network for currency ${currencyCode}`) + return undefined } - const network = initialNetwork const networkUpper = network.toUpperCase() const chainPluginId = LETSEXCHANGE_NETWORK_TO_PLUGIN_ID[networkUpper] @@ -508,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/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/partners/nexchange.ts b/src/partners/nexchange.ts new file mode 100644 index 00000000..3a452fc5 --- /dev/null +++ b/src/partners/nexchange.ts @@ -0,0 +1,447 @@ +import { + asArray, + asBoolean, + asEither, + asMaybe, + 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: asMaybe(asEither(asString, asNull), null), + txid: asMaybe(asEither(asString, asNull), null) +}) + +const asNexchangeOrder = asObject({ + orderId: asString, + status: asString, + createdAt: asString, + deposit: asNexchangeTransfer, + payout: asNexchangeTransfer, + countryCode: asMaybe(asEither(asString, asNull), null) +}) + +const asNexchangeOrdersResponse = asObject({ + orders: asArray(asUnknown), + nextCursor: asMaybe(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: asMaybe(asEither(asString, asNull), null), + contract_address: asMaybe(asEither(asString, asNull), null), + common_symbol: asMaybe(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 + +// 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 } = { + 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 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, + 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 + + // 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, + tokenId: null, + evmChainId + } + } + + // 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) { + throw new Error( + `Unknown tokenType for chainPluginId "${chainPluginId}" (currency: ${normalizedCode}, contract: ${contractAddress}). Add tokenType to tokenTypes.` + ) + } + + 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 + let page = 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() + + for (; page < MAX_PAGES; page++) { + 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. + } + + 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()) + } +} + +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/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/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/src/partners/rango.ts b/src/partners/rango.ts index 62445ee8..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' @@ -115,10 +115,13 @@ const RANGO_BLOCKCHAIN_TO_PLUGIN_ID: Record = { FANTOM: 'fantom', LTC: 'litecoin', MATIC: 'polygon', + MONAD: 'monad', OPTIMISM: 'optimism', OSMOSIS: 'osmosis', POLYGON: 'polygon', SOLANA: 'solana', + SONIC: 'sonic', + SUI: 'sui', TON: 'ton', TRON: 'tron', XRPL: 'ripple', @@ -138,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 @@ -178,17 +184,30 @@ export async function queryRango( let processedCount = 0 for (const rawTx of txs) { + // 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 { - const standardTx = processRangoTx(rawTx, pluginParams) - standardTxs.push(standardTx) - processedCount++ - - if (standardTx.isoDate > latestIsoDate) { - latestIsoDate = standardTx.isoDate - } + standardTx = processRangoTx(rawTx, pluginParams) } catch (e) { - // Log but continue processing other transactions - log.warn(`Failed to process tx: ${String(e)}`) + skipped++ + log.error( + `Rango: skipping unprocessable tx, ingestion continues: ${String( + e + )}: ${describeRawTx(rawTx)}` + ) + continue + } + standardTxs.push(standardTx) + processedCount++ + + if (standardTx.isoDate > latestIsoDate) { + latestIsoDate = standardTx.isoDate } } @@ -210,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/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/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 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 + } +} diff --git a/src/partners/xgram.ts b/src/partners/xgram.ts new file mode 100644 index 00000000..334c6515 --- /dev/null +++ b/src/partners/xgram.ts @@ -0,0 +1,610 @@ +import { + asArray, + asEither, + asMap, + asMaybe, + asNumber, + asObject, + asOptional, + asString, + asUnknown, + asValue +} from 'cleaners' + +import { + PartnerPlugin, + PluginParams, + PluginResult, + StandardTx, + Status +} from '../types' +import { describeRawTx, 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 + +// 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 + +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) +} + +/** + * 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) { + 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 } +} + +/** + * 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 } = asXgramPluginParams(pluginParams) + const { apiKey } = apiKeys + const { latestIsoDate, resumePage, pendingLatestIsoDate } = 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. + // 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 + // Orders dropped as unprocessable, surfaced as a count after the walk. + let skipped = 0 + let page = resumePage + let retry = 0 + let done = false + 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 { + 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) { + // 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 + } + 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 + } + 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: 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 +} + +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 5c9ce353..c407eabd 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -22,18 +22,23 @@ 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 { 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' import { transak } from './partners/transak' import { wyre } from './partners/wyre' import { xanpool } from './partners/xanpool' +import { xgram } from './partners/xgram' import { asApp, asApps, @@ -74,18 +79,23 @@ const plugins = [ lifi, maya, moonpay, + nexchange, + nymswap, paybis, paytrie, rango, + revolut, safello, sideshift, simplex, + swapter, swapuz, switchain, thorchain, transak, wyre, - xanpool + xanpool, + xgram ] const QUERY_FREQ_MS = 60 * 1000 const MAX_CONCURRENT_QUERIES = 3 @@ -194,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) { 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/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 + }) +}) diff --git a/src/types.ts b/src/types.ts index 9f8d0582..ab3a7e2d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,6 +80,7 @@ const asFiatPaymentType = asValue( 'bpay', 'blueshyft', 'cash', + 'cashapp', 'colombiabank', 'credit', 'directtobank', @@ -93,6 +94,7 @@ const asFiatPaymentType = asValue( 'interac', 'iobank', 'israelibank', + 'klarna', 'mexicobank', 'mobikwik', 'moonpay', @@ -124,6 +126,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), @@ -148,6 +161,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 }) @@ -179,7 +207,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({ @@ -195,8 +231,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({ @@ -207,9 +247,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({ @@ -266,8 +308,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/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(',')}]` +} 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/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/nexchange.test.ts b/test/nexchange.test.ts new file mode 100644 index 00000000..b287b0c2 --- /dev/null +++ b/test/nexchange.test.ts @@ -0,0 +1,294 @@ +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' + }, + // 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' + } +} + +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/ + ) + }) + + 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', () => { + 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') + }) + }) +}) 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"/) + }) +}) 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"/) + }) +}) 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() + }) +}) 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 } 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 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) + }) +})