Skip to content

Commit c439b81

Browse files
committed
Query both old and new Sideshift affiliate accounts and merge completed orders
Loop the per-account completed-orders query over each configured affiliate account and merge the streams so a Sideshift affiliate-account rotation keeps reporting the old account's historical and in-flight shifts. Track latestIsoDate per account so neither account's cursor skips the other's orders, falling back to the legacy single cursor for backward compatibility. Single-account config is unchanged. Add a mocha test covering the dual-account merge and per-account cursor behavior. Claude-Session: https://claude.ai/code/session_01YNwNii1LxjqaeowP5d7dgJ
1 parent 458438a commit c439b81

3 files changed

Lines changed: 431 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- changed: Query both old and new Sideshift affiliate accounts and merge completed orders to preserve full shift history across an affiliate-account rotation
56
- changed: Add signature header support to Exolix
67
- changed: Add index for orderId
78
- changed: Add EVM chainId, pluginId, and tokenId fields to StandardTx

src/partners/sideshift.ts

Lines changed: 168 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
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+
169197
const 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>
179208
type SideshiftTx = ReturnType<typeof asSideshiftTx>
180209
type SideshiftStatus = ReturnType<typeof asSideshiftStatus>
181210
const 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+
183222
const MAX_RETRIES = 5
184223
const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 5 // 5 days
185224
const 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}&currentTime=${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}&currentTime=${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

Comments
 (0)