From 1be912ed29cf71291f8a90f0a7504a96dd3536ca Mon Sep 17 00:00:00 2001 From: vlad Date: Mon, 22 Jun 2026 13:31:13 +0300 Subject: [PATCH 1/2] Swapter plugin integration --- src/partners/swapter.ts | 236 ++++++++++++++++++++++++++++++++++++++++ src/queryEngine.ts | 2 + 2 files changed, 238 insertions(+) create mode 100644 src/partners/swapter.ts diff --git a/src/partners/swapter.ts b/src/partners/swapter.ts new file mode 100644 index 00000000..4260eba9 --- /dev/null +++ b/src/partners/swapter.ts @@ -0,0 +1,236 @@ +import { + asArray, + asMaybe, + asNumber, + asObject, + asString, + asUnknown, + asValue +} from 'cleaners' + +import { + asStandardPluginParams, + PartnerPlugin, + PluginParams, + PluginResult, + StandardTx, + Status +} from '../types' +import { datelog, retryFetch, snooze } from '../util' + +const asSwapterStatus = asMaybe( + asValue( + 'Waiting', + 'Confirmation', + 'Exchanging', + 'Sending', + 'Success', + 'Frozen', + 'Refunded', + 'Overdue', + 'Suspended' + ), + 'other' +) + +const asSwapterTx = asObject({ + info: asObject({ + uid: asString, + status: asSwapterStatus, + type: asString, + link: asString, + equivalent: asNumber + }), + deposit: asObject({ + coin: asString, + network: asString, + amount: asNumber, + actual: asMaybe(asNumber), + address: asString, + memo: asMaybe(asString) + }), + withdraw: asObject({ + coin: asString, + network: 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: asObject({ + name: asString, + profit: asObject({ + amount: asNumber, + percent: asNumber + }) + }) +}) + +const asSwapterResult = asObject({ + page: asNumber, + total: asNumber, + data: asArray(asUnknown) +}) + +type SwapterTx = ReturnType +type SwapterStatus = ReturnType + +const MAX_RETRIES = 5 +const LIMIT = 200 +const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days + +const statusMap: { [key in SwapterStatus]: Status } = { + Waiting: 'pending', + Confirmation: 'pending', + Exchanging: 'pending', + Sending: 'pending', + Success: 'complete', + Overdue: 'expired', + Refunded: 'refunded', + Frozen: 'other', + Suspended: 'other', + other: 'other' +} + +export const querySwapter = async ( + pluginParams: PluginParams +): Promise => { + const { settings, apiKeys } = asStandardPluginParams(pluginParams) + const { apiKey } = apiKeys + let latestIsoDate = + typeof settings.latestIsoDate === 'string' + ? settings.latestIsoDate + : new Date(0).toISOString() + + if (apiKey == null) { + return { settings: { latestIsoDate }, transactions: [] } + } + + const standardTxs: StandardTx[] = [] + + let previousTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK + if (previousTimestamp < 0) previousTimestamp = 0 + + let page = 1 + let retry = 0 + + while (true) { + try { + const response = await retryFetch( + 'https://api.swapter.io/personal/exchange/tool-history', + { + method: 'POST', + headers: { + 'X-Api-Key': apiKey, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + page, + items: LIMIT, + timeFrom: previousTimestamp, + timeTo: Date.now() + }) + } + ) + + if (!response.ok) { + const text = await response.text() + datelog(`Swapter error on page:${page}`) + throw new Error(text) + } + + const result = asSwapterResult(await response.json()) + const txs = result.data + + if (txs.length === 0) break + + for (const rawTx of txs) { + const standardTx = processSwapterTx(rawTx) + + if (standardTx.isoDate <= latestIsoDate) continue + + standardTxs.push(standardTx) + + if (standardTx.isoDate > latestIsoDate) { + latestIsoDate = standardTx.isoDate + } + } + + datelog(`Swapter page ${page} latestIsoDate ${latestIsoDate}`) + + const loaded = page * LIMIT + if (loaded >= result.total) break + + page++ + retry = 0 + } catch (e) { + datelog(e) + + retry++ + if (retry <= MAX_RETRIES) { + datelog(`Snoozing ${5 * retry}s`) + await snooze(5000 * retry) + } else { + break + } + } + } + + return { + settings: { latestIsoDate }, + transactions: standardTxs + } +} + +export const swapter: PartnerPlugin = { + queryFunc: querySwapter, + pluginName: 'Swapter', + pluginId: 'swapter' +} + +export function processSwapterTx(rawTx: unknown): StandardTx { + const tx: SwapterTx = asSwapterTx(rawTx) + + const date = new Date(tx.time.create) + const timestamp = tx.time.create / 1000 + + 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: date.toISOString(), + + usdValue: tx.info.equivalent > 0 ? tx.info.equivalent : -1, + + rawTx + } +} diff --git a/src/queryEngine.ts b/src/queryEngine.ts index 8551c54f..43574a20 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -28,6 +28,7 @@ import { rango } from './partners/rango' import { safello } from './partners/safello' import { sideshift } from './partners/sideshift' import { simplex } from './partners/simplex' +import { swapter } from './partners/swapter' import { swapuz } from './partners/swapuz' import { switchain } from './partners/switchain' import { maya, thorchain } from './partners/thorchain' @@ -61,6 +62,7 @@ const plugins = [ bitrefill, changelly, changenow, + swapter, changehero, exolix, foxExchange, From a061fcd74e8fc4cb6a9686ae50715802ce2fba14 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 24 Jun 2026 12:11:56 -0700 Subject: [PATCH 2/2] Harden Swapter timestamp parsing and add changelog entry Use smartIsoDateFromTimestamp for the Swapter time.create field so the transform produces correct dates whether the partner sends Unix seconds or milliseconds, matching the other reports-server partner plugins. Add the missing CHANGELOG entry for the Swapter integration. Claude-Session: https://claude.ai/code/session_01STk8j8NcL2LgiuXRxirVn6 --- CHANGELOG.md | 1 + src/partners/swapter.ts | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9feac9b8..84503dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- added: Add Swapter reporting - changed: Add signature header support to Exolix - changed: Add index for orderId - changed: Add EVM chainId, pluginId, and tokenId fields to StandardTx diff --git a/src/partners/swapter.ts b/src/partners/swapter.ts index 4260eba9..b4093af1 100644 --- a/src/partners/swapter.ts +++ b/src/partners/swapter.ts @@ -16,7 +16,7 @@ import { StandardTx, Status } from '../types' -import { datelog, retryFetch, snooze } from '../util' +import { datelog, retryFetch, smartIsoDateFromTimestamp, snooze } from '../util' const asSwapterStatus = asMaybe( asValue( @@ -198,8 +198,7 @@ export const swapter: PartnerPlugin = { export function processSwapterTx(rawTx: unknown): StandardTx { const tx: SwapterTx = asSwapterTx(rawTx) - const date = new Date(tx.time.create) - const timestamp = tx.time.create / 1000 + const { timestamp, isoDate } = smartIsoDateFromTimestamp(tx.time.create) return { status: statusMap[tx.info.status], @@ -227,7 +226,7 @@ export function processSwapterTx(rawTx: unknown): StandardTx { payoutAmount: tx.withdraw.amount, timestamp, - isoDate: date.toISOString(), + isoDate, usdValue: tx.info.equivalent > 0 ? tx.info.equivalent : -1,