Skip to content

Commit 5c1c497

Browse files
claude[bot]claude
andauthored
fix: guard terminal FILLED status from downgrade; reaper re-checks DB status before resolving (PROTO-1201) (#688)
* 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 * refactor: drop reaper status param for terminal-set guard; simplify repository condition per review --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 971244e commit 5c1c497

4 files changed

Lines changed: 112 additions & 9 deletions

File tree

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { BLOCK_RANGE, REAPER_MAX_ATTEMPTS, DYNAMO_BATCH_WRITE_MAX, OLDEST_BLOCK_
88
import { ethers } from 'ethers'
99
import { CosignedPriorityOrder, CosignedV2DutchOrder, CosignedV3DutchOrder, DutchOrder, FillInfo, CosignedHybridOrder, OrderType, OrderValidation, OrderValidator, REACTOR_ADDRESS_MAPPING, UniswapXEventWatcher, UniswapXOrder } from '@uniswap/uniswapx-sdk'
1010
import { parseOrder } from '../../handlers/OrderParser'
11-
import { AVERAGE_BLOCK_TIME, getSettledAmounts } from '../../handlers/check-order-status/util'
11+
import { AVERAGE_BLOCK_TIME, getSettledAmounts, IS_TERMINAL_STATE } from '../../handlers/check-order-status/util'
1212
import { ChainId } from '../../util/chain'
1313
import { getRpcUrl } from '../../Config'
1414
import { LimitOrdersRepository } from '../../repositories/limit-orders-repository'
@@ -412,7 +412,7 @@ async function checkCancelledOrders(
412412
provider: ethers.providers.StaticJsonRpcProvider,
413413
chainId: number,
414414
log: Logger,
415-
failedFillScanRanges: BlockRange[],
415+
failedFillScanRanges: BlockRange[]
416416
): Promise<Record<string, OrderUpdate>> {
417417
const orderUpdates = { ...existingUpdates }
418418
const quoter = new OrderValidator(provider, chainId)
@@ -461,6 +461,12 @@ async function checkCancelledOrders(
461461
if (!orderUpdates[orderHash]) {
462462
try {
463463
const { order, signature, entity } = await getOrderByHash(repo, orderHash)
464+
// Another writer (e.g. check-order-status) may have resolved this order since
465+
// the run's GET_OPEN_ORDERS snapshot; never re-resolve a terminal order.
466+
if (IS_TERMINAL_STATE(entity.orderStatus)) {
467+
log.info(`Order ${orderHash} is already terminal (${entity.orderStatus}); skipping resolution`)
468+
continue
469+
}
464470
// We only check for nonce used and expired for permissioned tokens
465471
// since the order quoter can't move input tokens
466472
// For v4 orders like Hybrid, input is at a different level

lib/repositories/generic-orders-repository.ts

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

1616
export const MAX_ORDERS = 50
17+
18+
// aws-sdk v2 (used by dynamodb-toolbox here) sets both code and name to the error code
19+
function isConditionalCheckFailed(e: unknown): boolean {
20+
return (e as { code?: string } | null)?.code === 'ConditionalCheckFailedException'
21+
}
22+
1723
// Shared implementation for Dutch and Limit orders
1824
// will work for orders with the same GSIs
1925
export abstract class GenericOrdersRepository<
@@ -114,14 +120,27 @@ export abstract class GenericOrdersRepository<
114120
`cannot find order by hash when updating order status, hash: ${orderHash}`
115121
)
116122

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-
})
123+
// FILLED is terminal; the DynamoDB condition makes the no-downgrade check atomic with the write
124+
const conditions =
125+
status === ORDER_STATUS.FILLED
126+
? {}
127+
: { conditions: [{ attr: TABLE_KEY.ORDER_STATUS, ne: ORDER_STATUS.FILLED }] }
128+
await this.entity.update(
129+
{
130+
[TABLE_KEY.ORDER_HASH]: orderHash,
131+
...this.indexMapper.getIndexFieldsForStatusUpdate(order, status),
132+
...(txHash && { txHash }),
133+
...(fillBlock && { fillBlock }),
134+
...(settledAmounts && { settledAmounts })
135+
},
136+
conditions
137+
)
124138
} catch (e) {
139+
if (isConditionalCheckFailed(e)) {
140+
// the order was FILLED by another writer since our read; skip the downgrade rather than throw
141+
log.warn('skipping updateOrderStatus: order is already in terminal status FILLED', { orderHash, status })
142+
return
143+
}
125144
log.error('updateOrderStatus error', { error: e })
126145
throw e
127146
}

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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,39 @@ 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 is already terminal', async () => {
356+
// Regression (PROTO-1201): another writer (e.g. the check-order-status
357+
// state machine) can resolve the order to FILLED between the run's
358+
// GET_OPEN_ORDERS snapshot and CHECK_CANCELLED. A used nonce is
359+
// consistent with that fill, so the reaper must skip already-terminal
360+
// orders instead of clobbering FILLED with CANCELLED.
361+
await mockOrdersRepository.addOrder({
362+
...MOCK_ORDER_ENTITY,
363+
orderStatus: ORDER_STATUS.FILLED,
364+
})
365+
366+
const state = {
367+
chainId: ChainId.MAINNET,
368+
currentBlock: OLDEST_BLOCK_BY_CHAIN[ChainId.MAINNET],
369+
earliestBlock: OLDEST_BLOCK_BY_CHAIN[ChainId.MAINNET],
370+
orderUpdates: {},
371+
orderHashes: [MOCK_ORDER_ENTITY.orderHash],
372+
failedFillScanRanges: [],
373+
stage: ReaperStage.CHECK_CANCELLED
374+
}
375+
376+
const { OrderValidation } = jest.requireActual('@uniswap/uniswapx-sdk')
377+
const mockOrderValidator = jest.requireMock('@uniswap/uniswapx-sdk').OrderValidator
378+
mockOrderValidator.mockImplementation(() => ({
379+
validate: jest.fn().mockResolvedValue(OrderValidation.NonceUsed)
380+
}))
381+
382+
const result = await reaper.processChainState(state)
383+
384+
expect(result?.stage).toBe(ReaperStage.UPDATE_DB)
385+
expect(result?.orderUpdates[MOCK_ORDER_ENTITY.orderHash]).toBeUndefined()
386+
})
387+
355388
it('does NOT mark a used-nonce order CANCELLED when a failed scan range may hide its fill', async () => {
356389
// Regression: a used nonce is consistent with both a fill and a cancel.
357390
// MOCK_ORDER_ENTITY has no createdAt, so its fill window cannot be

0 commit comments

Comments
 (0)