diff --git a/CHANGELOG.md b/CHANGELOG.md index a5920d9a..766cf5df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - 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 - changed: Add index for orderId - changed: Add EVM chainId, pluginId, and tokenId fields to StandardTx diff --git a/src/partners/sideshift.ts b/src/partners/sideshift.ts index d05f66eb..9d3ce4af 100644 --- a/src/partners/sideshift.ts +++ b/src/partners/sideshift.ts @@ -1,5 +1,6 @@ import { asArray, + asMap, asMaybe, asNumber, asObject, @@ -14,6 +15,7 @@ import { PartnerPlugin, PluginParams, PluginResult, + ScopedLog, StandardTx, Status } from '../types' @@ -35,6 +37,7 @@ const SIDESHIFT_NETWORK_TO_PLUGIN_ID: ChainNameToPluginIdMapping = { bitcoin: 'bitcoin', bitcoincash: 'bitcoincash', bsc: 'binancesmartchain', + bsv: 'bitcoinsv', cardano: 'cardano', cosmos: 'cosmoshub', dash: 'dash', @@ -71,6 +74,7 @@ const ASSET_NAME_OVERRIDES: Record = { // Delisted coins that are no longer in the SideShift API // Map: `${coin}-${network}` -> contract address (null for native gas tokens) const DELISTED_COINS: Record = { + 'BSV-bsv': null, // Native gas token 'BUSD-bsc': '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', 'FTM-fantom': null, // Native gas token 'MATIC-ethereum': '0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0', @@ -176,20 +180,61 @@ const asSideshiftTx = asObject({ settledAt: asOptional(asString) }) +// apiKeys reaching a partner plugin are a flat string->string map +// (see asPartnerInfo in types.ts), so additional accounts are carried as +// explicit string fields rather than a nested array. The primary +// sideshiftAffiliateId/Secret pair is the pre-existing account whose completed +// orders are already recorded, so it keeps its watermark and resumes +// incrementally. The optional sideshiftAffiliateId2/Secret2 pair is a +// newly-added affiliate account; it has no recorded history, so it backfills +// from epoch (see querySideshift). Backward compatible: when the *2 fields are +// absent only the primary pair is queried. Putting the established account in +// the primary slot is what keeps a rotation cheap: the account with years of +// history never re-scans from epoch. +const asSideshiftApiKeys = asObject({ + sideshiftAffiliateId: asString, + sideshiftAffiliateSecret: asString, + sideshiftAffiliateId2: asOptional(asString), + sideshiftAffiliateSecret2: asOptional(asString) +}) + +const DEFAULT_LATEST_ISO_DATE = '1970-01-01T00:00:00.000Z' + +// `accounts` is a per-affiliateId cursor map. Legacy progress docs only carry +// the top-level `latestIsoDate`; the primary account inherits it across the +// upgrade (so the existing single account keeps its watermark), while any +// newly-added account starts from the epoch default to backfill its full +// history. See querySideshift for the per-account fallback rationale. +const asSideshiftSettings = asObject({ + latestIsoDate: asOptional(asString, DEFAULT_LATEST_ISO_DATE), + accounts: asOptional(asMap(asString), () => ({})) +}) + const asSideshiftPluginParams = asObject({ - apiKeys: asObject({ - sideshiftAffiliateId: asString, - sideshiftAffiliateSecret: asString - }), - settings: asObject({ - latestIsoDate: asOptional(asString, '1970-01-01T00:00:00.000Z') - }) + apiKeys: asSideshiftApiKeys, + settings: asSideshiftSettings }) +interface SideshiftAccount { + affiliateId: string + affiliateSecret: string +} + +type SideshiftApiKeys = ReturnType type SideshiftTx = ReturnType type SideshiftStatus = ReturnType const asSideshiftResult = asArray(asUnknown) +// Fetches one raw page of completed orders for an account, and processes a raw +// order into a StandardTx. Both are injectable so the multi-account merge and +// cursor logic can be exercised in tests without the live Sideshift API. +type FetchSideshiftOrders = ( + account: SideshiftAccount, + startTime: number, + now: number +) => Promise +type ProcessSideshiftOrder = (rawTx: unknown) => Promise + const MAX_RETRIES = 5 const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days const QUERY_TIME_BLOCK_MS = QUERY_LOOKBACK @@ -219,13 +264,82 @@ function affiliateSignature( .digest('hex') } -export async function querySideshift( - pluginParams: PluginParams -): Promise { - const { log } = pluginParams - const { settings, apiKeys } = asSideshiftPluginParams(pluginParams) - const { sideshiftAffiliateId, sideshiftAffiliateSecret } = apiKeys - let { latestIsoDate } = settings +// Builds the list of affiliate accounts to query from the configured apiKeys. +// Returns the primary (pre-existing) account plus the optional newly-added +// account, deduped by affiliateId so a single-account config yields exactly one +// account. Order matters: the primary stays at index 0 because querySideshift +// gives index 0 the legacy watermark and backfills the rest from epoch. +export function getSideshiftAccounts( + apiKeys: SideshiftApiKeys +): SideshiftAccount[] { + const { + sideshiftAffiliateId, + sideshiftAffiliateSecret, + sideshiftAffiliateId2, + sideshiftAffiliateSecret2 + } = apiKeys + + const accounts: SideshiftAccount[] = [ + { + affiliateId: sideshiftAffiliateId, + affiliateSecret: sideshiftAffiliateSecret + } + ] + + // A half-configured pair means the operator intended a second account but + // it would be silently skipped, so fail loudly instead. + if ((sideshiftAffiliateId2 != null) !== (sideshiftAffiliateSecret2 != null)) { + throw new Error( + 'Sideshift config error: sideshiftAffiliateId2 and sideshiftAffiliateSecret2 must both be set' + ) + } + + if ( + sideshiftAffiliateId2 != null && + sideshiftAffiliateSecret2 != null && + sideshiftAffiliateId2 !== sideshiftAffiliateId + ) { + accounts.push({ + affiliateId: sideshiftAffiliateId2, + affiliateSecret: sideshiftAffiliateSecret2 + }) + } + + return accounts +} + +const defaultFetchSideshiftOrders: FetchSideshiftOrders = async ( + account, + startTime, + now +): Promise => { + const signature = affiliateSignature( + account.affiliateId, + account.affiliateSecret, + now + ) + const url = `https://sideshift.ai/api/affiliate/completedOrders?affiliateId=${account.affiliateId}&since=${startTime}¤tTime=${now}&signature=${signature}` + const response = await retryFetch(url) + if (!response.ok) { + const text = await response.text() + throw new Error(text) + } + const jsonObj = await response.json() + return asSideshiftResult(jsonObj) +} + +// Queries the completed orders for a single affiliate account, advancing its +// own cursor. Mirrors the original per-account query/signature/retry/time-block +// behavior exactly; only the fetch + per-order processing are abstracted so the +// merge can be tested without the live API. +async function querySideshiftAccount( + account: SideshiftAccount, + initialLatestIsoDate: string, + fetchOrders: FetchSideshiftOrders, + processOrder: ProcessSideshiftOrder, + log: ScopedLog +): Promise<{ transactions: StandardTx[]; latestIsoDate: string }> { + let latestIsoDate = initialLatestIsoDate let lastCheckedTimestamp = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK if (lastCheckedTimestamp < 0) lastCheckedTimestamp = 0 @@ -238,33 +352,20 @@ export async function querySideshift( const endTime = startTime + QUERY_TIME_BLOCK_MS const now = Date.now() - const signature = affiliateSignature( - sideshiftAffiliateId, - sideshiftAffiliateSecret, - now - ) - - const url = `https://sideshift.ai/api/affiliate/completedOrders?affiliateId=${sideshiftAffiliateId}&since=${startTime}¤tTime=${now}&signature=${signature}` try { - const response = await retryFetch(url) - if (!response.ok) { - const text = await response.text() - throw new Error(text) - } - const jsonObj = await response.json() - const orders = asSideshiftResult(jsonObj) + const orders = await fetchOrders(account, startTime, now) if (orders.length === 0) { break } for (const rawTx of orders) { - const standardTx = await processSideshiftTx(rawTx, pluginParams) + const standardTx = await processOrder(rawTx) standardTxs.push(standardTx) if (standardTx.isoDate > latestIsoDate) { latestIsoDate = standardTx.isoDate } } startTime = new Date(latestIsoDate).getTime() - log(`latestIsoDate ${latestIsoDate}`) + log(`${account.affiliateId} latestIsoDate ${latestIsoDate}`) if (endTime > now) { break } @@ -283,8 +384,60 @@ export async function querySideshift( } } + return { transactions: standardTxs, latestIsoDate } +} + +export async function querySideshift( + pluginParams: PluginParams, + fetchOrders: FetchSideshiftOrders = defaultFetchSideshiftOrders, + processOrder: ProcessSideshiftOrder = async rawTx => + await processSideshiftTx(rawTx, pluginParams) +): Promise { + const { log } = pluginParams + const { settings, apiKeys } = asSideshiftPluginParams(pluginParams) + const { + latestIsoDate: legacyLatestIsoDate, + accounts: accountCursors + } = settings + + const accounts = getSideshiftAccounts(apiKeys) + + const standardTxs: StandardTx[] = [] + const newAccountCursors: { [affiliateId: string]: string } = {} + let overallLatestIsoDate = legacyLatestIsoDate + + for (let index = 0; index < accounts.length; index++) { + const account = accounts[index] + // Resume from the account's own cursor when known. On the first run that an + // account appears (no map entry), the PRIMARY account (index 0) inherits the + // legacy single cursor so the pre-existing account keeps its watermark, while + // a newly-added account starts from the epoch default to backfill its full + // history. Inheriting the primary's advanced watermark would make a new + // account skip every order older than that point permanently. + const fallbackCursor = + index === 0 ? legacyLatestIsoDate : DEFAULT_LATEST_ISO_DATE + const startCursor = accountCursors[account.affiliateId] ?? fallbackCursor + + const { transactions, latestIsoDate } = await querySideshiftAccount( + account, + startCursor, + fetchOrders, + processOrder, + log + ) + + standardTxs.push(...transactions) + newAccountCursors[account.affiliateId] = latestIsoDate + if (latestIsoDate > overallLatestIsoDate) { + overallLatestIsoDate = latestIsoDate + } + } + const out = { - settings: { latestIsoDate }, + settings: { + latestIsoDate: overallLatestIsoDate, + accounts: newAccountCursors + }, transactions: standardTxs } return out diff --git a/test/sideshift.test.ts b/test/sideshift.test.ts new file mode 100644 index 00000000..30476963 --- /dev/null +++ b/test/sideshift.test.ts @@ -0,0 +1,318 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { getSideshiftAccounts, querySideshift } from '../src/partners/sideshift' +import { ScopedLog, StandardTx } from '../src/types' + +// Mirrors QUERY_LOOKBACK in src/partners/sideshift.ts (5 days). The plugin +// starts each account a lookback window before its cursor. +const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 + +interface FakeAccount { + affiliateId: string + affiliateSecret: string +} +interface RawOrder { + orderId: string + isoDate: string +} +interface Captured { + affiliateId: string + startTime: number +} + +// Silent logger so test output stays clean; the plugin never branches on it. +const noopLog: ScopedLog = Object.assign(() => undefined, { + warn: () => undefined, + error: () => undefined +}) + +const makeTx = (orderId: string, isoDate: string): StandardTx => ({ + orderId, + countryCode: null, + depositTxid: undefined, + depositAddress: 'deposit-address', + depositCurrency: 'BTC', + depositChainPluginId: 'bitcoin', + depositEvmChainId: undefined, + depositTokenId: null, + depositAmount: 1, + direction: undefined, + exchangeType: 'swap', + paymentType: null, + payoutTxid: undefined, + payoutAddress: 'payout-address', + payoutCurrency: 'ETH', + payoutChainPluginId: 'ethereum', + payoutEvmChainId: 1, + payoutTokenId: null, + payoutAmount: 2, + status: 'complete', + isoDate, + timestamp: new Date(isoDate).getTime() / 1000, + usdValue: -1, + rawTx: { id: orderId } +}) + +const processOrder = async (rawTx: unknown): Promise => { + const order = rawTx as RawOrder + return makeTx(order.orderId, order.isoDate) +} + +// Returns the configured orders for an affiliateId on the FIRST call, then an +// empty array so the per-account query loop terminates deterministically +// without touching the live Sideshift API. +const makeFakeFetcher = (ordersByAffiliateId: { + [affiliateId: string]: RawOrder[] +}): { + fetchOrders: ( + account: FakeAccount, + startTime: number, + now: number + ) => Promise + calls: Captured[] +} => { + const calls: Captured[] = [] + const served = new Set() + const fetchOrders = async ( + account: FakeAccount, + startTime: number + ): Promise => { + calls.push({ affiliateId: account.affiliateId, startTime }) + if (served.has(account.affiliateId)) return [] + served.add(account.affiliateId) + return ordersByAffiliateId[account.affiliateId] ?? [] + } + return { fetchOrders, calls } +} + +const firstStartTimeFor = (calls: Captured[], affiliateId: string): number => { + const call = calls.find(c => c.affiliateId === affiliateId) + if (call == null) throw new Error(`no fetch for ${affiliateId}`) + return call.startTime +} + +describe('getSideshiftAccounts', function() { + it('returns one account when no added account is configured', function() { + const accounts = getSideshiftAccounts({ + sideshiftAffiliateId: 'PRIMARY', + sideshiftAffiliateSecret: 'sprimary', + sideshiftAffiliateId2: undefined, + sideshiftAffiliateSecret2: undefined + }) + expect(accounts).to.deep.equal([ + { affiliateId: 'PRIMARY', affiliateSecret: 'sprimary' } + ]) + }) + + it('returns both accounts when an added account is configured', function() { + const accounts = getSideshiftAccounts({ + sideshiftAffiliateId: 'PRIMARY', + sideshiftAffiliateSecret: 'sprimary', + sideshiftAffiliateId2: 'ADDED', + sideshiftAffiliateSecret2: 'sadded' + }) + expect(accounts).to.deep.equal([ + { affiliateId: 'PRIMARY', affiliateSecret: 'sprimary' }, + { affiliateId: 'ADDED', affiliateSecret: 'sadded' } + ]) + }) + + it('dedupes when the added affiliateId equals the primary', function() { + const accounts = getSideshiftAccounts({ + sideshiftAffiliateId: 'SAME', + sideshiftAffiliateSecret: 'sprimary', + sideshiftAffiliateId2: 'SAME', + sideshiftAffiliateSecret2: 'sadded' + }) + expect(accounts).to.deep.equal([ + { affiliateId: 'SAME', affiliateSecret: 'sprimary' } + ]) + }) + + it('throws when only one of the added-account fields is configured', function() { + // A half-configured pair means the operator intended a second account. + // Silently querying only the primary would hide the misconfiguration. + expect(() => + getSideshiftAccounts({ + sideshiftAffiliateId: 'PRIMARY', + sideshiftAffiliateSecret: 'sprimary', + sideshiftAffiliateId2: 'ADDED', + sideshiftAffiliateSecret2: undefined + }) + ).to.throw('Sideshift config error') + expect(() => + getSideshiftAccounts({ + sideshiftAffiliateId: 'PRIMARY', + sideshiftAffiliateSecret: 'sprimary', + sideshiftAffiliateId2: undefined, + sideshiftAffiliateSecret2: 'sadded' + }) + ).to.throw('Sideshift config error') + }) +}) + +describe('querySideshift dual-account merge', function() { + it('single account: queries one account and tracks its cursor', async function() { + const { fetchOrders, calls } = makeFakeFetcher({ + PRIMARY: [{ orderId: 'o1', isoDate: '2025-06-05T00:00:00.000Z' }] + }) + const result = await querySideshift( + { + apiKeys: { + sideshiftAffiliateId: 'PRIMARY', + sideshiftAffiliateSecret: 'sprimary' + }, + settings: {}, + log: noopLog + }, + fetchOrders, + processOrder + ) + + expect(result.transactions.map(tx => tx.orderId)).to.deep.equal(['o1']) + expect(result.settings.latestIsoDate).to.equal('2025-06-05T00:00:00.000Z') + expect(result.settings.accounts).to.deep.equal({ + PRIMARY: '2025-06-05T00:00:00.000Z' + }) + // Only the primary account was queried. + expect(calls.every(c => c.affiliateId === 'PRIMARY')).to.equal(true) + }) + + it('dual account: merges both streams and keeps the max overall cursor', async function() { + const { fetchOrders, calls } = makeFakeFetcher({ + PRIMARY: [{ orderId: 'n1', isoDate: '2025-06-10T00:00:00.000Z' }], + ADDED: [{ orderId: 'o1', isoDate: '2025-06-02T00:00:00.000Z' }] + }) + const result = await querySideshift( + { + apiKeys: { + sideshiftAffiliateId: 'PRIMARY', + sideshiftAffiliateSecret: 'sprimary', + sideshiftAffiliateId2: 'ADDED', + sideshiftAffiliateSecret2: 'sadded' + }, + settings: {}, + log: noopLog + }, + fetchOrders, + processOrder + ) + + expect( + result.transactions + .map(tx => tx.orderId) + .sort((a, b) => a.localeCompare(b)) + ).to.deep.equal(['n1', 'o1']) + // Overall cursor is the newest order across both accounts. + expect(result.settings.latestIsoDate).to.equal('2025-06-10T00:00:00.000Z') + // Per-account cursors track each account independently. + expect(result.settings.accounts).to.deep.equal({ + PRIMARY: '2025-06-10T00:00:00.000Z', + ADDED: '2025-06-02T00:00:00.000Z' + }) + expect(calls.some(c => c.affiliateId === 'PRIMARY')).to.equal(true) + expect(calls.some(c => c.affiliateId === 'ADDED')).to.equal(true) + }) + + it('legacy progress doc: primary inherits the cursor, the added account backfills from epoch', async function() { + const { fetchOrders, calls } = makeFakeFetcher({ + PRIMARY: [{ orderId: 'n1', isoDate: '2025-02-10T00:00:00.000Z' }], + ADDED: [{ orderId: 'o1', isoDate: '2025-02-05T00:00:00.000Z' }] + }) + const legacyCursor = '2025-01-01T00:00:00.000Z' + await querySideshift( + { + apiKeys: { + sideshiftAffiliateId: 'PRIMARY', + sideshiftAffiliateSecret: 'sprimary', + sideshiftAffiliateId2: 'ADDED', + sideshiftAffiliateSecret2: 'sadded' + }, + // No `accounts` map: simulates a progress doc written before this change. + settings: { latestIsoDate: legacyCursor }, + log: noopLog + }, + fetchOrders, + processOrder + ) + + // Primary keeps the pre-existing watermark... + expect(firstStartTimeFor(calls, 'PRIMARY')).to.equal( + new Date(legacyCursor).getTime() - QUERY_LOOKBACK + ) + // ...while the newly-added account backfills from the epoch default + // (1970 - lookback is negative, clamped to 0) so its history is not skipped. + expect(firstStartTimeFor(calls, 'ADDED')).to.equal(0) + }) + + it('per-account cursors: one account does not skip the other', async function() { + const { fetchOrders, calls } = makeFakeFetcher({ + PRIMARY: [{ orderId: 'n1', isoDate: '2025-05-10T00:00:00.000Z' }], + ADDED: [{ orderId: 'o1', isoDate: '2025-03-10T00:00:00.000Z' }] + }) + await querySideshift( + { + apiKeys: { + sideshiftAffiliateId: 'PRIMARY', + sideshiftAffiliateSecret: 'sprimary', + sideshiftAffiliateId2: 'ADDED', + sideshiftAffiliateSecret2: 'sadded' + }, + settings: { + latestIsoDate: '2025-01-01T00:00:00.000Z', + accounts: { + PRIMARY: '2025-05-01T00:00:00.000Z', + ADDED: '2025-03-01T00:00:00.000Z' + } + }, + log: noopLog + }, + fetchOrders, + processOrder + ) + + // Each account resumes from its OWN cursor, not the shared/legacy one. + expect(firstStartTimeFor(calls, 'PRIMARY')).to.equal( + new Date('2025-05-01T00:00:00.000Z').getTime() - QUERY_LOOKBACK + ) + expect(firstStartTimeFor(calls, 'ADDED')).to.equal( + new Date('2025-03-01T00:00:00.000Z').getTime() - QUERY_LOOKBACK + ) + }) + + it('does not skip an unprocessable order: it aborts the page and holds the cursor', async function() { + // Reviewer preference (peachbits): an unprocessable order (e.g. an unmapped + // coin or network) must fail loudly rather than be silently skipped. A + // stopped account is noticed; a stray "skipped order" log is not. The thrown + // error surfaces through the block-level retry path, so the order is never + // recorded and the cursor never advances past it. The block-level retry + // snoozes once before the fake fetcher empties, so allow for that. + this.timeout(20000) + const { fetchOrders } = makeFakeFetcher({ + PRIMARY: [{ orderId: 'bad', isoDate: '2025-06-02T00:00:00.000Z' }] + }) + const throwingProcess = async (): Promise => { + throw new Error('Unknown network: bsv') + } + const result = await querySideshift( + { + apiKeys: { + sideshiftAffiliateId: 'PRIMARY', + sideshiftAffiliateSecret: 'sprimary' + }, + settings: {}, + log: noopLog + }, + fetchOrders, + throwingProcess + ) + + // Nothing is recorded and the cursor stays at the epoch default rather than + // advancing past unreported revenue. + expect(result.transactions).to.deep.equal([]) + expect(result.settings.accounts.PRIMARY).to.equal( + '1970-01-01T00:00:00.000Z' + ) + }) +})