Skip to content

fix(FLOW-10): Enforce fair FIFO queue ordering on-chain#46

Open
m-Peter wants to merge 3 commits intomainfrom
mpeter/FLOW-10-enforce-fifo-queue-ordering
Open

fix(FLOW-10): Enforce fair FIFO queue ordering on-chain#46
m-Peter wants to merge 3 commits intomainfrom
mpeter/FLOW-10-enforce-fifo-queue-ordering

Conversation

@m-Peter
Copy link
Copy Markdown
Collaborator

@m-Peter m-Peter commented Feb 3, 2026

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 & dequeue operations 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:

/// @notice Array of pending request IDs awaiting processing (FIFO order)
uint256[] public pendingRequestIds;

/// @notice Index of request ID in global pending array (for O(1) lookup)
mapping(uint256 => uint256) private _requestIndexInGlobalArray;

In additionn _startProcessingInternal now has a check which verifies that the request being processed is the head of the requestsQueue FIFO queue:

_removeUserPendingRequest(requestId);
uint256 reqId = _dequeueRequest();
if (reqId != requestId) revert RequestProcessOutOfOrder(reqId, requestId);

If that is not the case, the function call reverts with RequestProcessOutOfOrder.

The Cadence Scheduler that schedules & processes the requests, is fetching the request IDs with:

worker.getPendingRequestsFromEVM(startIndex: 0, count: fetchCount)

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 to startProcessingBatch(), which drops the rejected request IDs, and calls _startProcessingInternal() for each individual successful request ID.

Migration Notes:
With the public pendingRequestIds variable, the getter pendingRequestIds(uint256 index) → uint256 is no longer available. Callers should use getPendingRequestIds(), as is the case with the Cadence side in this repository.

@m-Peter m-Peter self-assigned this Feb 3, 2026
@m-Peter m-Peter added Low Low severity security finding ⎈ QuantStamp QuantStamp audit finding Performance Improvements around performance labels Feb 3, 2026
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch 3 times, most recently from ebe63d0 to 8ad764e Compare February 3, 2026 16:24
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch 7 times, most recently from 9a20c8f to 5717ad2 Compare February 9, 2026 14:41
@onflow onflow deleted a comment from claude bot Feb 10, 2026
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 74c948d to 371f132 Compare February 10, 2026 10:44
@claude
Copy link
Copy Markdown

claude bot commented Feb 10, 2026

Review: FLOW-10 — FIFO Queue Enforcement

The mapping-based queue design (_requestsQueue + head/tail pointers) is correct. The O(1) enqueue/dequeue, the _dropQueuedRequest shift-left logic, and the two-phase validation in startProcessingBatch all hold up under adversarial inputs (out-of-order batches, user cancellations mid-flight, duplicates across arrays, empty arrays). No accounting or ownership invariants are broken. The Cadence side handles all startProcessingBatch failure modes the same way (log + retry next cycle), which is sufficient.

Concrete issues

1 · Dead code in startProcessingBatch validation fallthrough (line 1031)

Request storage request = requests[reqId];
if (request.status != RequestStatus.PENDING)
    revert InvalidRequestState();

Every entry in _requestsQueue is PENDING by construction — all transitions out of PENDING (_dequeueRequest, _dropQueuedRequest) remove the ID from the queue before changing status. This check is unreachable. It misleads future readers into thinking non-PENDING entries can appear in the queue, which they cannot. Remove it or replace with a comment documenting the invariant.

2 · Misleading inline comment in _dropQueuedRequest (line 2094)

// Shift the matching request to the queue's tail, then delete it is backwards. The algorithm shifts every element after the match one slot to the left (filling the gap), then deletes the now-duplicated slot at tail-1. The comment should read something like: "Once found, shift each subsequent element one position left, then delete the last duplicate slot."

3 · reqId == 0 guard placement (line 1010)

The "went over the boundaries" check only fires inside the j < and k < branches. When one counter is exhausted, only the other branch's guard is active. This is safe today because totalRequests = j_max + k_max guarantees exactly one increment per iteration, making both counters simultaneously exhausted unreachable inside the loop. A brief comment documenting this loop invariant (j + k == i + 1 after each successful iteration) would make the reasoning self-evident and prevent someone from breaking the invariant in a future edit.

4 · Test gap: cancelled request in successfulRequestIds (test line 461)

test_StartProcessingBatch_WithUserCancellationRace covers a user cancelling a request that the COA put in rejectedRequestIds. The symmetric case — a request in successfulRequestIds is cancelled before startProcessingBatch executes — has no test. The behaviour is correct (the queue shifts, triggering RequestProcessOutOfOrder), but an explicit test would nail down the contract.

Non-issues / noted but OK

  • The O(m·n) complexity of dropRequests is correctly documented in the PR description and is an accepted trade-off.
  • Removing the pendingRequestIds(uint256) public getter is a documented breaking change; the Cadence side already uses getPendingRequestsUnpacked, so no on-chain caller is broken.
  • totalRequests = successfulRequestIds.length + rejectedRequestIds.length overflows safely in Solidity 0.8+ (checked arithmetic → revert), not silent corruption.

@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 71b9aeb to 9d7d107 Compare February 11, 2026 15:18
@m-Peter m-Peter changed the title Enforce fair FIFO queue ordering on-chain fix(FLOW-10): Enforce fair FIFO queue ordering on-chain Mar 10, 2026
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch 4 times, most recently from 1760973 to 95d1302 Compare March 11, 2026 11:45
@m-Peter m-Peter marked this pull request as ready for review March 11, 2026 11:54
@m-Peter m-Peter requested review from liobrasil and nvdtf March 11, 2026 14:26
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 95d1302 to 0da95a3 Compare March 12, 2026 16:50
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 0da95a3 to 76c7bec Compare March 23, 2026 16:44
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from c7cccd8 to ef05f59 Compare March 31, 2026 14:45
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from ef05f59 to 5267d82 Compare March 31, 2026 15:49
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 5267d82 to a1691be Compare March 31, 2026 16:51
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from a1691be to 2749c7f Compare March 31, 2026 17:35
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 2749c7f to a8d403a Compare March 31, 2026 17:53
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from a8d403a to 7001cdf Compare March 31, 2026 18:22
*
* @param requestId The request ID to remove from the pending requests queue.
*/
function _dropQueuedRequest(uint256 requestId) internal {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

@m-Peter m-Peter Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 order

So this was also O(n).

// === VALIDATION ===
Request storage request = requests[reqId];
if (request.status != RequestStatus.PENDING)
revert InvalidRequestState();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Low Low severity security finding Performance Improvements around performance ⎈ QuantStamp QuantStamp audit finding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FLOW-10: FIFO Queue Is Not Enforced on-Chain yet Costs O(n) to Maintain

2 participants