fix(FLOW-10): Enforce fair FIFO queue ordering on-chain#46
fix(FLOW-10): Enforce fair FIFO queue ordering on-chain#46
Conversation
ebe63d0 to
8ad764e
Compare
9a20c8f to
5717ad2
Compare
74c948d to
371f132
Compare
Review: FLOW-10 — FIFO Queue EnforcementThe mapping-based queue design ( Concrete issues1 · Dead code in Request storage request = requests[reqId];
if (request.status != RequestStatus.PENDING)
revert InvalidRequestState();Every entry in 2 · Misleading inline comment in
3 · The "went over the boundaries" check only fires inside the 4 · Test gap: cancelled request in
Non-issues / noted but OK
|
71b9aeb to
9d7d107
Compare
1760973 to
95d1302
Compare
95d1302 to
0da95a3
Compare
0da95a3 to
76c7bec
Compare
c7cccd8 to
ef05f59
Compare
ef05f59 to
5267d82
Compare
5267d82 to
a1691be
Compare
a1691be to
2749c7f
Compare
2749c7f to
a8d403a
Compare
a8d403a to
7001cdf
Compare
| * | ||
| * @param requestId The request ID to remove from the pending requests queue. | ||
| */ | ||
| function _dropQueuedRequest(uint256 requestId) internal { |
There was a problem hiding this comment.
O(n) regression for cancelRequest
_dropQueuedRequest does a linear scan-and-shift across the entire queue, replacing the former O(1) swap-and-pop (which used _requestIndexInGlobalArray). This is the correct trade-off to maintain FIFO order, but it means that every user-initiated cancelRequest now costs gas proportional to queue depth, not O(1).
With maxPendingRequestsPerUser = 10 and N active users the queue is bounded at 10 × N entries, so gas stays manageable at current scale. Worth noting explicitly in the migration notes so operators are aware that raising maxPendingRequestsPerUser amplifies cancellation cost.
There was a problem hiding this comment.
cancelRequest previously used _removePendingRequest(requestId);, which stated:
// === GLOBAL PENDING ARRAY REMOVAL ===
// Uses O(1) lookup + O(n) shift to maintain FIFO order
// FIFO order is critical for DeFi fairness - requests must be processed in submission orderSo this was also O(n).
| // === VALIDATION === | ||
| Request storage request = requests[reqId]; | ||
| if (request.status != RequestStatus.PENDING) | ||
| revert InvalidRequestState(); |
There was a problem hiding this comment.
This InvalidRequestState check is dead code. The _requestsQueue mapping only ever holds IDs for requests that are currently PENDING — every transition out of PENDING (_dequeueRequest, _dropQueuedRequest) removes the ID from the queue first. So reqId read from the queue is always PENDING here.
Leaving dead code in a hot code path is misleading: it suggests to future readers that non-PENDING entries can appear in the queue, which they cannot. Consider removing it (or replacing with a assert-style comment if you want to document the invariant).
| requestFound = true; | ||
| } | ||
|
|
||
| // Shift the matching request to the queue's tail, then delete it |
There was a problem hiding this comment.
Inaccurate comment. The algorithm does not "shift the matching request to the tail." It shifts all elements after the match one slot to the left (closing the gap), and then deletes the now-duplicated last slot. A clearer description:
// Once found, shift each subsequent element one position left,
// then delete the last (now-duplicate) slot at tail-1.
| uint256 requestId; | ||
| // If reqId is 0, it means we went over the boundaries of | ||
| // _requestsQueue. | ||
| uint256 reqId = _requestsQueue[_requestsQueueHead+i]; |
There was a problem hiding this comment.
The reqId == 0 guard fires only inside the if (j < successfulRequestIds.length) and if (k < rejectedRequestIds.length) branches. When both arrays are exhausted midway through the loop (impossible in practice because totalRequests = j_max + k_max keeps j + k == i+1 at every iteration), a zero value at a queue slot would silently skip past the guard and reach the RequestProcessOutOfOrder revert instead.
This is unreachable today, but worth noting that the "went over the boundaries" invariant is only protected within each branch. The loop invariant j + k == i+1 (one match per iteration) prevents this from being exploitable, but it is worth a clarifying comment.
| vm.stopPrank(); | ||
|
|
||
| vm.startPrank(coa); | ||
| // First 3 successful, transition to PROCESSING |
There was a problem hiding this comment.
Good test. There is a symmetric gap worth covering: a request that the COA lists in successfulRequestIds is user-cancelled before startProcessingBatch executes. The expected behaviour is identical — the queue has shifted, so the validation loop fires RequestProcessOutOfOrder at the position where the cancelled request used to sit — but an explicit test would document that invariant.
Closes #25
FLOW-10: FIFO Queue Is Not Enforced on-Chain yet Costs O(n) to Maintain
Introduce an optimized queue data structure (mapping-based with head & tail pointers), to avoid the high gas costs of maintaining the FIFO order on-chain. Both
enqueue&dequeueoperations are now O(1), the cancellation part remain O(n), while the batch drop is now O(m*n) for dropping a batch of m requests.The previous queue data structure required an array and a mapping, and had O(n) performance when removing a request from the pending queue:
In additionn
_startProcessingInternalnow has a check which verifies that the request being processed is the head of therequestsQueueFIFO queue:If that is not the case, the function call reverts with
RequestProcessOutOfOrder.The Cadence
Schedulerthat schedules & processes the requests, is fetching the request IDs with:which returns a given count of pending EVM requests from the queue, in FIFO order.
These are fed to
preprocessRequests(), and after the validation checks, they are classified as successful/rejected, and they are then passed in tostartProcessingBatch(), which drops the rejected request IDs, and calls_startProcessingInternal()for each individual successful request ID.Migration Notes:
With the public
pendingRequestIdsvariable, the getterpendingRequestIds(uint256 index) → uint256is no longer available. Callers should usegetPendingRequestIds(), as is the case with the Cadence side in this repository.