Skip to content

Permissionless endRound() enables an on-chain expiry race where finalization can occur before a pending buyKeys() is mined #10

@Odhiambo526

Description

@Odhiambo526

The contract defines two mutually exclusive time gates around roundEnd: buyKeys requires block.timestamp < roundEnd (require(block.timestamp < roundEnd, "Round ended, call endRound()")), while endRound requires block.timestamp >= roundEnd (require(block.timestamp >= roundEnd, "Round not over yet")) and has no access control (function endRound() external). As a result, in any block whose block.timestamp is at or after roundEnd, endRound() can execute successfully (assuming lastBuyer != address(0)), and any buyKeys() transaction executed later in the same block will deterministically revert due to failing the time check. The contract does not include a “grace period,” a “last-buyer-only finalization window,” or any ordering constraint that prioritizes a pending buyKeys over endRound once the timestamp boundary is crossed; therefore, the outcome at expiry is sensitive to transaction ordering within the first block with block.timestamp >= roundEnd.
At the expiry boundary, a buyKeys() transaction that is not mined before the first block with block.timestamp >= roundEnd will revert, while endRound() remains callable by any address in that same block.

Mitigation/ Suggestion

Require endRound() to be callable only by lastBuyer for an initial post-expiry window (e.g., first N seconds) and allow anyone only after that window elapses.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions