Skip to content

Commit b3c9429

Browse files
committed
fix: guard terminal FILLED status from downgrade; reaper re-checks DB status before resolving
Follow-up hardening to #683/#687 (PROTO-1201). Order 0x33e09be0... was filled on mainnet and recorded FILLED (txHash + settledAmounts) by check-order-status, but a concurrent GS Reaper run -- whose fill scan predated the fill -- read the order's used nonce as a cancellation and blindly overwrote the record to CANCELLED. Two gaps remained: 1. GenericOrdersRepository.updateOrderStatus wrote orderStatus unconditionally, so any writer could downgrade a terminal FILLED order. Now a non-FILLED write carries a DynamoDB ConditionExpression (orderStatus <> filled), making the guard atomic with the write. A ConditionalCheckFailedException is logged and skipped rather than thrown, so callers don't enter retry loops. Legitimate transitions (OPEN/INSUFFICIENT_FUNDS -> FILLED, re-writing FILLED with fill details) are unaffected. 2. The reaper's CHECK_CANCELLED stage validated orders from the run's GET_OPEN_ORDERS snapshot without re-checking their current DB status. It now skips any order whose status no longer matches the run's unresolved-status snapshot (e.g. resolved to FILLED mid-run by the status state machine). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EpJxMZvZepV198FGXBNUwk
1 parent a9dd8a3 commit b3c9429

4 files changed

Lines changed: 125 additions & 8 deletions

File tree

lib/crons/gs-reaper/gs-reaper.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,8 @@ export class GSReaper {
229229
provider,
230230
state.chainId,
231231
this.log,
232-
state.failedFillScanRanges
232+
state.failedFillScanRanges,
233+
this.unresolvedOrderStatus
233234
)
234235

235236
return {
@@ -413,6 +414,7 @@ async function checkCancelledOrders(
413414
chainId: number,
414415
log: Logger,
415416
failedFillScanRanges: BlockRange[],
417+
unresolvedOrderStatus: ORDER_STATUS,
416418
): Promise<Record<string, OrderUpdate>> {
417419
const orderUpdates = { ...existingUpdates }
418420
const quoter = new OrderValidator(provider, chainId)
@@ -461,6 +463,16 @@ async function checkCancelledOrders(
461463
if (!orderUpdates[orderHash]) {
462464
try {
463465
const { order, signature, entity } = await getOrderByHash(repo, orderHash)
466+
// Another writer (e.g. the check-order-status state machine) may have
467+
// resolved this order since the run's GET_OPEN_ORDERS snapshot -- most
468+
// importantly to FILLED, which a used nonce is also consistent with.
469+
// Only resolve orders whose DB status still matches the snapshot.
470+
if (entity.orderStatus !== unresolvedOrderStatus) {
471+
log.info(
472+
`Order ${orderHash} status is now ${entity.orderStatus} (no longer ${unresolvedOrderStatus}); skipping resolution`
473+
)
474+
continue
475+
}
464476
// We only check for nonce used and expired for permissioned tokens
465477
// since the order quoter can't move input tokens
466478
// For v4 orders like Hybrid, input is at a different level

lib/repositories/generic-orders-repository.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ import { BaseOrdersRepository, OrderEntityType, QueryResult } from './base'
1414
import { IndexMapper } from './IndexMappers/IndexMapper'
1515

1616
export const MAX_ORDERS = 50
17+
18+
function isConditionalCheckFailed(e: unknown): boolean {
19+
return (
20+
typeof e === 'object' &&
21+
e !== null &&
22+
((e as { code?: string }).code === 'ConditionalCheckFailedException' ||
23+
(e as { name?: string }).name === 'ConditionalCheckFailedException')
24+
)
25+
}
26+
1727
// Shared implementation for Dutch and Limit orders
1828
// will work for orders with the same GSIs
1929
export abstract class GenericOrdersRepository<
@@ -114,14 +124,29 @@ export abstract class GenericOrdersRepository<
114124
`cannot find order by hash when updating order status, hash: ${orderHash}`
115125
)
116126

117-
await this.entity.update({
118-
[TABLE_KEY.ORDER_HASH]: orderHash,
119-
...this.indexMapper.getIndexFieldsForStatusUpdate(order, status),
120-
...(txHash && { txHash }),
121-
...(fillBlock && { fillBlock }),
122-
...(settledAmounts && { settledAmounts })
123-
})
127+
await this.entity.update(
128+
{
129+
[TABLE_KEY.ORDER_HASH]: orderHash,
130+
...this.indexMapper.getIndexFieldsForStatusUpdate(order, status),
131+
...(txHash && { txHash }),
132+
...(fillBlock && { fillBlock }),
133+
...(settledAmounts && { settledAmounts })
134+
},
135+
// FILLED is terminal: once an order is recorded as filled, no writer may
136+
// downgrade it to another status (e.g. a reaper run whose fill scan
137+
// predates the fill misreading its used nonce as a cancellation).
138+
// Enforced as a DynamoDB condition so the check is atomic with the write.
139+
status === ORDER_STATUS.FILLED
140+
? {}
141+
: { conditions: [{ attr: TABLE_KEY.ORDER_STATUS, ne: ORDER_STATUS.FILLED }] }
142+
)
124143
} catch (e) {
144+
if (isConditionalCheckFailed(e)) {
145+
// The order reached FILLED between our read and this write; the update
146+
// would downgrade a terminal status, so skip it rather than retry.
147+
log.warn('skipping updateOrderStatus: order is already in terminal status FILLED', { orderHash, status })
148+
return
149+
}
125150
log.error('updateOrderStatus error', { error: e })
126151
throw e
127152
}

test/integ/repositories/dynamo-repository.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,51 @@ describe('OrdersRepository update status test', () => {
723723
new Error('cannot find order by hash when updating order status, hash: nonexistent')
724724
)
725725
})
726+
727+
it('should not downgrade a FILLED (terminal) order to another status', async () => {
728+
// Regression (PROTO-1201): a reaper run racing the check-order-status
729+
// state machine must not clobber a recorded fill with CANCELLED/EXPIRED.
730+
await ordersRepository.updateOrderStatus('0x1', ORDER_STATUS.FILLED, 'txHash', 1, [
731+
{ tokenOut: '0x1', amountOut: '1' } as SettledAmount,
732+
])
733+
734+
// The downgrade is skipped (not thrown), so callers don't enter retry loops.
735+
await expect(ordersRepository.updateOrderStatus('0x1', ORDER_STATUS.CANCELLED)).resolves.toBeUndefined()
736+
await expect(ordersRepository.updateOrderStatus('0x1', ORDER_STATUS.EXPIRED)).resolves.toBeUndefined()
737+
738+
await expect(ordersRepository.getByHash('0x1')).resolves.toMatchObject({
739+
orderStatus: ORDER_STATUS.FILLED,
740+
offerer_orderStatus: `${MOCK_ORDER_1.offerer}_${ORDER_STATUS.FILLED}`,
741+
chainId_orderStatus: `${MOCK_ORDER_1.chainId}_${ORDER_STATUS.FILLED}`,
742+
txHash: 'txHash',
743+
fillBlock: 1,
744+
settledAmounts: [{ tokenOut: '0x1', amountOut: '1' }],
745+
})
746+
})
747+
748+
it('should allow re-writing FILLED with updated fill details', async () => {
749+
await ordersRepository.updateOrderStatus('0x1', ORDER_STATUS.FILLED, 'txHash2', 2)
750+
await expect(ordersRepository.getByHash('0x1')).resolves.toMatchObject({
751+
orderStatus: ORDER_STATUS.FILLED,
752+
txHash: 'txHash2',
753+
fillBlock: 2,
754+
})
755+
})
756+
757+
it('should allow non-terminal transitions and upgrades to FILLED', async () => {
758+
// e.g. INSUFFICIENT_FUNDS -> FILLED (the insufficient-funds reaper finding
759+
// a fill) must keep working.
760+
await ordersRepository.updateOrderStatus('0x2', ORDER_STATUS.INSUFFICIENT_FUNDS)
761+
await expect(ordersRepository.getByHash('0x2')).resolves.toMatchObject({
762+
orderStatus: ORDER_STATUS.INSUFFICIENT_FUNDS,
763+
})
764+
await ordersRepository.updateOrderStatus('0x2', ORDER_STATUS.FILLED, 'txHash', 3)
765+
await expect(ordersRepository.getByHash('0x2')).resolves.toMatchObject({
766+
orderStatus: ORDER_STATUS.FILLED,
767+
txHash: 'txHash',
768+
fillBlock: 3,
769+
})
770+
})
726771
})
727772

728773
describe('OrdersRepository delete test', () => {

test/unit/handlers/gs-reaper/gs-reaper.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,41 @@ describe('GSReaper', () => {
352352
expect(result?.orderUpdates[MOCK_ORDER_ENTITY.orderHash].status).toBe(ORDER_STATUS.CANCELLED)
353353
})
354354

355+
it('does NOT resolve an order whose DB status changed since the run snapshot', async () => {
356+
// Regression (PROTO-1201): the run's order-hash snapshot is taken in
357+
// GET_OPEN_ORDERS, but another writer (e.g. the check-order-status state
358+
// machine) can resolve the order -- most importantly to FILLED -- before
359+
// CHECK_CANCELLED validates it. A used nonce is consistent with that
360+
// fill, so the reaper must re-check the CURRENT DB status and skip
361+
// orders that already moved on, instead of clobbering FILLED with
362+
// CANCELLED.
363+
await mockOrdersRepository.addOrder({
364+
...MOCK_ORDER_ENTITY,
365+
orderStatus: ORDER_STATUS.FILLED,
366+
})
367+
368+
const state = {
369+
chainId: ChainId.MAINNET,
370+
currentBlock: OLDEST_BLOCK_BY_CHAIN[ChainId.MAINNET],
371+
earliestBlock: OLDEST_BLOCK_BY_CHAIN[ChainId.MAINNET],
372+
orderUpdates: {},
373+
orderHashes: [MOCK_ORDER_ENTITY.orderHash],
374+
failedFillScanRanges: [],
375+
stage: ReaperStage.CHECK_CANCELLED
376+
}
377+
378+
const { OrderValidation } = jest.requireActual('@uniswap/uniswapx-sdk')
379+
const mockOrderValidator = jest.requireMock('@uniswap/uniswapx-sdk').OrderValidator
380+
mockOrderValidator.mockImplementation(() => ({
381+
validate: jest.fn().mockResolvedValue(OrderValidation.NonceUsed)
382+
}))
383+
384+
const result = await reaper.processChainState(state)
385+
386+
expect(result?.stage).toBe(ReaperStage.UPDATE_DB)
387+
expect(result?.orderUpdates[MOCK_ORDER_ENTITY.orderHash]).toBeUndefined()
388+
})
389+
355390
it('does NOT mark a used-nonce order CANCELLED when a failed scan range may hide its fill', async () => {
356391
// Regression: a used nonce is consistent with both a fill and a cancel.
357392
// MOCK_ORDER_ENTITY has no createdAt, so its fill window cannot be

0 commit comments

Comments
 (0)