Skip to content

Commit ee1c93b

Browse files
committed
Advance Sideshift cursor past skipped orders to prevent stall
1 parent b308478 commit ee1c93b

3 files changed

Lines changed: 166 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- changed: Query both old and new Sideshift affiliate accounts and merge completed orders to preserve full shift history across an affiliate-account rotation
6+
- changed: Sideshift orders that fail processing (e.g. unmapped coin or network) are now skipped and logged with their full raw payload instead of stalling the account; a skipped order is not re-fetched once the cursor advances past it
67
- changed: Add signature header support to Exolix
78
- changed: Add index for orderId
89
- changed: Add EVM chainId, pluginId, and tokenId fields to StandardTx

src/partners/sideshift.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,17 @@ export function getSideshiftAccounts(
275275
}
276276
]
277277

278+
// A half-configured pair means the operator intended a second account but
279+
// it would be silently skipped, so fail loudly instead.
280+
if (
281+
(sideshiftAffiliateIdNew != null) !==
282+
(sideshiftAffiliateSecretNew != null)
283+
) {
284+
throw new Error(
285+
'Sideshift config error: sideshiftAffiliateIdNew and sideshiftAffiliateSecretNew must both be set'
286+
)
287+
}
288+
278289
if (
279290
sideshiftAffiliateIdNew != null &&
280291
sideshiftAffiliateSecretNew != null &&
@@ -309,6 +320,26 @@ const defaultFetchSideshiftOrders: FetchSideshiftOrders = async (
309320
return asSideshiftResult(jsonObj)
310321
}
311322

323+
// Reads only the timestamp from a raw order. Used to advance an account's
324+
// cursor past an order that processOrder rejects (e.g. an unmapped coin or
325+
// network), so a page of only unprocessable orders can never stall the query
326+
// window. Returns undefined when the raw order has no usable createdAt.
327+
const asRawOrderDate = asMaybe(asObject({ createdAt: asString }))
328+
329+
const rawOrderIsoDate = (rawTx: unknown): string | undefined => {
330+
const parsed = asRawOrderDate(rawTx)
331+
if (parsed == null) return undefined
332+
try {
333+
return smartIsoDateFromTimestamp(parsed.createdAt).isoDate
334+
} catch {
335+
// This helper runs inside the skip handler after processOrder already
336+
// threw. If createdAt itself is the unparseable part, throwing here again
337+
// would escape the skip logic and stall the account on this order forever,
338+
// so treat it the same as a missing createdAt.
339+
return undefined
340+
}
341+
}
342+
312343
// Queries the completed orders for a single affiliate account, advancing its
313344
// own cursor. Mirrors the original per-account query/signature/retry/time-block
314345
// behavior exactly; only the fetch + per-order processing are abstracted so the
@@ -347,18 +378,34 @@ async function querySideshiftAccount(
347378
// the plugin maps) must not stall the account. Without skipping, the
348379
// throw aborts the page, the cursor never advances past it, and the
349380
// account retries-then-gives-up every cycle forever. Log the gap so
350-
// it can be mapped, then move on.
351-
log.warn(
352-
`${account.affiliateId} skipping unprocessable order: ${String(e)}`
381+
// it can be mapped, then advance the cursor past this order's own
382+
// timestamp so the window still moves forward even when an entire
383+
// page is unprocessable.
384+
// Once the cursor advances past a skipped order it is never
385+
// re-fetched (orders are queried with since=cursor), so include the
386+
// full raw order in the log: it is the only surviving copy from
387+
// which the record can be recovered after the mapping is fixed.
388+
log.error(
389+
`${account.affiliateId} skipping unprocessable order: ${String(
390+
e
391+
)} raw=${JSON.stringify(rawTx)}`
353392
)
393+
const skippedIsoDate = rawOrderIsoDate(rawTx)
394+
if (skippedIsoDate != null && skippedIsoDate > latestIsoDate) {
395+
latestIsoDate = skippedIsoDate
396+
}
354397
continue
355398
}
356399
standardTxs.push(standardTx)
357400
if (standardTx.isoDate > latestIsoDate) {
358401
latestIsoDate = standardTx.isoDate
359402
}
360403
}
361-
startTime = new Date(latestIsoDate).getTime()
404+
const advancedTime = new Date(latestIsoDate).getTime()
405+
// Guarantee forward progress even if every order in this block was skipped
406+
// without a usable timestamp, so the window can never spin on the same
407+
// startTime until the engine timeout.
408+
startTime = advancedTime > startTime ? advancedTime : endTime
362409
log(`${account.affiliateId} latestIsoDate ${latestIsoDate}`)
363410
if (endTime > now) {
364411
break

test/sideshift.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ interface FakeAccount {
1515
interface RawOrder {
1616
orderId: string
1717
isoDate: string
18+
// Only set by tests exercising the skip handler's cursor advance, which
19+
// reads createdAt from the raw order the way the live API shapes it.
20+
createdAt?: string
1821
}
1922
interface Captured {
2023
affiliateId: string
@@ -129,6 +132,27 @@ describe('getSideshiftAccounts', function() {
129132
{ affiliateId: 'SAME', affiliateSecret: 'sprimary' }
130133
])
131134
})
135+
136+
it('throws when only one of the added-account fields is configured', function() {
137+
// A half-configured pair means the operator intended a second account.
138+
// Silently querying only the primary would hide the misconfiguration.
139+
expect(() =>
140+
getSideshiftAccounts({
141+
sideshiftAffiliateId: 'PRIMARY',
142+
sideshiftAffiliateSecret: 'sprimary',
143+
sideshiftAffiliateIdNew: 'ADDED',
144+
sideshiftAffiliateSecretNew: undefined
145+
})
146+
).to.throw('Sideshift config error')
147+
expect(() =>
148+
getSideshiftAccounts({
149+
sideshiftAffiliateId: 'PRIMARY',
150+
sideshiftAffiliateSecret: 'sprimary',
151+
sideshiftAffiliateIdNew: undefined,
152+
sideshiftAffiliateSecretNew: 'sadded'
153+
})
154+
).to.throw('Sideshift config error')
155+
})
132156
})
133157

134158
describe('querySideshift dual-account merge', function() {
@@ -299,4 +323,94 @@ describe('querySideshift dual-account merge', function() {
299323
'2025-06-03T00:00:00.000Z'
300324
)
301325
})
326+
327+
it('advances past a page of only unprocessable orders instead of spinning forever', async function() {
328+
// Mirrors the live API re-serving the same window: a page containing ONLY
329+
// unprocessable orders keeps coming back until the cursor advances past
330+
// their timestamps. Without advancing the cursor on skip, the loop spins on
331+
// the same startTime until the engine timeout. makeFakeFetcher cannot model
332+
// this (it serves once then empty), so use a startTime-gated fetcher that
333+
// aborts loudly if the loop fails to terminate.
334+
const badDate = '2025-04-10T00:00:00.000Z'
335+
const badTime = new Date(badDate).getTime()
336+
let fetchCount = 0
337+
const fetchOrders = async (
338+
account: FakeAccount,
339+
startTime: number
340+
): Promise<unknown[]> => {
341+
fetchCount++
342+
if (fetchCount > 50) throw new Error('query loop did not terminate')
343+
if (startTime <= badTime) return [{ id: 'bad', createdAt: badDate }]
344+
return []
345+
}
346+
const alwaysThrows = async (): Promise<StandardTx> => {
347+
throw new Error('Unknown coin: FOO on network bar')
348+
}
349+
350+
const result = await querySideshift(
351+
{
352+
apiKeys: {
353+
sideshiftAffiliateId: 'PRIMARY',
354+
sideshiftAffiliateSecret: 'sprimary'
355+
},
356+
settings: {},
357+
log: noopLog
358+
},
359+
fetchOrders,
360+
alwaysThrows
361+
)
362+
363+
// No orders are reportable, but the account still advances its cursor past
364+
// the unprocessable window and the loop terminates.
365+
expect(result.transactions).to.deep.equal([])
366+
expect(new Date(result.settings.accounts.PRIMARY).getTime()).to.be.at.least(
367+
badTime
368+
)
369+
})
370+
371+
it('skips an order whose createdAt is unparseable instead of stalling', async function() {
372+
// When processOrder throws BECAUSE createdAt is malformed, the skip
373+
// handler re-reads createdAt to advance the cursor. That second read must
374+
// not throw again, or the error escapes the skip logic into the retry
375+
// path and the account stalls on this order forever.
376+
const { fetchOrders } = makeFakeFetcher({
377+
PRIMARY: [
378+
{ orderId: 'good1', isoDate: '2025-06-01T00:00:00.000Z' },
379+
{
380+
orderId: 'bad-date',
381+
isoDate: 'not-a-real-date',
382+
createdAt: 'not-a-real-date'
383+
},
384+
{ orderId: 'good2', isoDate: '2025-06-03T00:00:00.000Z' }
385+
]
386+
})
387+
const dateStrictProcess = async (rawTx: unknown): Promise<StandardTx> => {
388+
const order = rawTx as RawOrder
389+
const time = new Date(order.isoDate).getTime()
390+
if (Number.isNaN(time)) {
391+
throw new Error(`Invalid createdAt: ${order.isoDate}`)
392+
}
393+
return makeTx(order.orderId, order.isoDate)
394+
}
395+
const result = await querySideshift(
396+
{
397+
apiKeys: {
398+
sideshiftAffiliateId: 'PRIMARY',
399+
sideshiftAffiliateSecret: 'sprimary'
400+
},
401+
settings: {},
402+
log: noopLog
403+
},
404+
fetchOrders,
405+
dateStrictProcess
406+
)
407+
408+
expect(result.transactions.map(tx => tx.orderId)).to.deep.equal([
409+
'good1',
410+
'good2'
411+
])
412+
expect(result.settings.accounts.PRIMARY).to.equal(
413+
'2025-06-03T00:00:00.000Z'
414+
)
415+
})
302416
})

0 commit comments

Comments
 (0)