11import {
22 asArray ,
3+ asMap ,
34 asMaybe ,
45 asObject ,
56 asOptional ,
@@ -13,6 +14,7 @@ import {
1314 PartnerPlugin ,
1415 PluginParams ,
1516 PluginResult ,
17+ ScopedLog ,
1618 StandardTx ,
1719 Status
1820} from '../types'
@@ -166,20 +168,57 @@ const asSideshiftTx = asObject({
166168 createdAt : asString
167169} )
168170
171+ // apiKeys reaching a partner plugin are a flat string->string map
172+ // (see asPartnerInfo in types.ts), so additional accounts are carried as
173+ // explicit string fields rather than a nested array. The primary
174+ // sideshiftAffiliateId/Secret pair is the active (new) account; the optional
175+ // *Old pair is the previous affiliate account kept during a rotation so its
176+ // historical and in-flight completed orders are still reported. Backward
177+ // compatible: when the *Old fields are absent only the primary pair is queried.
178+ const asSideshiftApiKeys = asObject ( {
179+ sideshiftAffiliateId : asString ,
180+ sideshiftAffiliateSecret : asString ,
181+ sideshiftAffiliateIdOld : asOptional ( asString ) ,
182+ sideshiftAffiliateSecretOld : asOptional ( asString )
183+ } )
184+
185+ const DEFAULT_LATEST_ISO_DATE = '1970-01-01T00:00:00.000Z'
186+
187+ // `accounts` is a per-affiliateId cursor map. Legacy progress docs only carry
188+ // the top-level `latestIsoDate`; the primary account inherits it across the
189+ // upgrade (so the existing single account keeps its watermark), while any
190+ // newly-added account starts from the epoch default to backfill its full
191+ // history. See querySideshift for the per-account fallback rationale.
192+ const asSideshiftSettings = asObject ( {
193+ latestIsoDate : asOptional ( asString , DEFAULT_LATEST_ISO_DATE ) ,
194+ accounts : asOptional ( asMap ( asString ) , ( ) => ( { } ) )
195+ } )
196+
169197const asSideshiftPluginParams = asObject ( {
170- apiKeys : asObject ( {
171- sideshiftAffiliateId : asString ,
172- sideshiftAffiliateSecret : asString
173- } ) ,
174- settings : asObject ( {
175- latestIsoDate : asOptional ( asString , '1970-01-01T00:00:00.000Z' )
176- } )
198+ apiKeys : asSideshiftApiKeys ,
199+ settings : asSideshiftSettings
177200} )
178201
202+ interface SideshiftAccount {
203+ affiliateId : string
204+ affiliateSecret : string
205+ }
206+
207+ type SideshiftApiKeys = ReturnType < typeof asSideshiftApiKeys >
179208type SideshiftTx = ReturnType < typeof asSideshiftTx >
180209type SideshiftStatus = ReturnType < typeof asSideshiftStatus >
181210const asSideshiftResult = asArray ( asUnknown )
182211
212+ // Fetches one raw page of completed orders for an account, and processes a raw
213+ // order into a StandardTx. Both are injectable so the multi-account merge and
214+ // cursor logic can be exercised in tests without the live Sideshift API.
215+ type FetchSideshiftOrders = (
216+ account : SideshiftAccount ,
217+ startTime : number ,
218+ now : number
219+ ) => Promise < unknown [ ] >
220+ type ProcessSideshiftOrder = ( rawTx : unknown ) => Promise < StandardTx >
221+
183222const MAX_RETRIES = 5
184223const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days
185224const QUERY_TIME_BLOCK_MS = QUERY_LOOKBACK
@@ -209,13 +248,72 @@ function affiliateSignature(
209248 . digest ( 'hex' )
210249}
211250
212- export async function querySideshift (
213- pluginParams : PluginParams
214- ) : Promise < PluginResult > {
215- const { log } = pluginParams
216- const { settings, apiKeys } = asSideshiftPluginParams ( pluginParams )
217- const { sideshiftAffiliateId, sideshiftAffiliateSecret } = apiKeys
218- let { latestIsoDate } = settings
251+ // Builds the list of affiliate accounts to query from the configured apiKeys.
252+ // Returns the primary (new) account plus the optional old account, deduped by
253+ // affiliateId so a single-account config yields exactly one account.
254+ export function getSideshiftAccounts (
255+ apiKeys : SideshiftApiKeys
256+ ) : SideshiftAccount [ ] {
257+ const {
258+ sideshiftAffiliateId,
259+ sideshiftAffiliateSecret,
260+ sideshiftAffiliateIdOld,
261+ sideshiftAffiliateSecretOld
262+ } = apiKeys
263+
264+ const accounts : SideshiftAccount [ ] = [
265+ {
266+ affiliateId : sideshiftAffiliateId ,
267+ affiliateSecret : sideshiftAffiliateSecret
268+ }
269+ ]
270+
271+ if (
272+ sideshiftAffiliateIdOld != null &&
273+ sideshiftAffiliateSecretOld != null &&
274+ sideshiftAffiliateIdOld !== sideshiftAffiliateId
275+ ) {
276+ accounts . push ( {
277+ affiliateId : sideshiftAffiliateIdOld ,
278+ affiliateSecret : sideshiftAffiliateSecretOld
279+ } )
280+ }
281+
282+ return accounts
283+ }
284+
285+ const defaultFetchSideshiftOrders : FetchSideshiftOrders = async (
286+ account ,
287+ startTime ,
288+ now
289+ ) : Promise < unknown [ ] > => {
290+ const signature = affiliateSignature (
291+ account . affiliateId ,
292+ account . affiliateSecret ,
293+ now
294+ )
295+ const url = `https://sideshift.ai/api/affiliate/completedOrders?affiliateId=${ account . affiliateId } &since=${ startTime } ¤tTime=${ now } &signature=${ signature } `
296+ const response = await retryFetch ( url )
297+ if ( ! response . ok ) {
298+ const text = await response . text ( )
299+ throw new Error ( text )
300+ }
301+ const jsonObj = await response . json ( )
302+ return asSideshiftResult ( jsonObj )
303+ }
304+
305+ // Queries the completed orders for a single affiliate account, advancing its
306+ // own cursor. Mirrors the original per-account query/signature/retry/time-block
307+ // behavior exactly; only the fetch + per-order processing are abstracted so the
308+ // merge can be tested without the live API.
309+ async function querySideshiftAccount (
310+ account : SideshiftAccount ,
311+ initialLatestIsoDate : string ,
312+ fetchOrders : FetchSideshiftOrders ,
313+ processOrder : ProcessSideshiftOrder ,
314+ log : ScopedLog
315+ ) : Promise < { transactions : StandardTx [ ] ; latestIsoDate : string } > {
316+ let latestIsoDate = initialLatestIsoDate
219317
220318 let lastCheckedTimestamp = new Date ( latestIsoDate ) . getTime ( ) - QUERY_LOOKBACK
221319 if ( lastCheckedTimestamp < 0 ) lastCheckedTimestamp = 0
@@ -228,33 +326,20 @@ export async function querySideshift(
228326 const endTime = startTime + QUERY_TIME_BLOCK_MS
229327 const now = Date . now ( )
230328
231- const signature = affiliateSignature (
232- sideshiftAffiliateId ,
233- sideshiftAffiliateSecret ,
234- now
235- )
236-
237- const url = `https://sideshift.ai/api/affiliate/completedOrders?affiliateId=${ sideshiftAffiliateId } &since=${ startTime } ¤tTime=${ now } &signature=${ signature } `
238329 try {
239- const response = await retryFetch ( url )
240- if ( ! response . ok ) {
241- const text = await response . text ( )
242- throw new Error ( text )
243- }
244- const jsonObj = await response . json ( )
245- const orders = asSideshiftResult ( jsonObj )
330+ const orders = await fetchOrders ( account , startTime , now )
246331 if ( orders . length === 0 ) {
247332 break
248333 }
249334 for ( const rawTx of orders ) {
250- const standardTx = await processSideshiftTx ( rawTx , pluginParams )
335+ const standardTx = await processOrder ( rawTx )
251336 standardTxs . push ( standardTx )
252337 if ( standardTx . isoDate > latestIsoDate ) {
253338 latestIsoDate = standardTx . isoDate
254339 }
255340 }
256341 startTime = new Date ( latestIsoDate ) . getTime ( )
257- log ( `latestIsoDate ${ latestIsoDate } ` )
342+ log ( `${ account . affiliateId } latestIsoDate ${ latestIsoDate } ` )
258343 if ( endTime > now ) {
259344 break
260345 }
@@ -273,8 +358,60 @@ export async function querySideshift(
273358 }
274359 }
275360
361+ return { transactions : standardTxs , latestIsoDate }
362+ }
363+
364+ export async function querySideshift (
365+ pluginParams : PluginParams ,
366+ fetchOrders : FetchSideshiftOrders = defaultFetchSideshiftOrders ,
367+ processOrder : ProcessSideshiftOrder = async rawTx =>
368+ await processSideshiftTx ( rawTx , pluginParams )
369+ ) : Promise < PluginResult > {
370+ const { log } = pluginParams
371+ const { settings, apiKeys } = asSideshiftPluginParams ( pluginParams )
372+ const {
373+ latestIsoDate : legacyLatestIsoDate ,
374+ accounts : accountCursors
375+ } = settings
376+
377+ const accounts = getSideshiftAccounts ( apiKeys )
378+
379+ const standardTxs : StandardTx [ ] = [ ]
380+ const newAccountCursors : { [ affiliateId : string ] : string } = { }
381+ let overallLatestIsoDate = legacyLatestIsoDate
382+
383+ for ( let index = 0 ; index < accounts . length ; index ++ ) {
384+ const account = accounts [ index ]
385+ // Resume from the account's own cursor when known. On the first run that an
386+ // account appears (no map entry), the PRIMARY account (index 0) inherits the
387+ // legacy single cursor so the pre-existing account keeps its watermark, while
388+ // a newly-added account starts from the epoch default to backfill its full
389+ // history. Inheriting the primary's advanced watermark would make a new
390+ // account skip every order older than that point permanently.
391+ const fallbackCursor =
392+ index === 0 ? legacyLatestIsoDate : DEFAULT_LATEST_ISO_DATE
393+ const startCursor = accountCursors [ account . affiliateId ] ?? fallbackCursor
394+
395+ const { transactions, latestIsoDate } = await querySideshiftAccount (
396+ account ,
397+ startCursor ,
398+ fetchOrders ,
399+ processOrder ,
400+ log
401+ )
402+
403+ standardTxs . push ( ...transactions )
404+ newAccountCursors [ account . affiliateId ] = latestIsoDate
405+ if ( latestIsoDate > overallLatestIsoDate ) {
406+ overallLatestIsoDate = latestIsoDate
407+ }
408+ }
409+
276410 const out = {
277- settings : { latestIsoDate } ,
411+ settings : {
412+ latestIsoDate : overallLatestIsoDate ,
413+ accounts : newAccountCursors
414+ } ,
278415 transactions : standardTxs
279416 }
280417 return out
0 commit comments