Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions contracts/EventBasedPredictionMarket.sol
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import "@uma/core/contracts/data-verification-mechanism/interfaces/IdentifierWhi
* 4. Anyone can propose a resolution price via the OO. After the liveness period, the market settles.
* 5. If disputed, the OO escalates to UMA's DVM for arbitration and re-requests the price.
* 6. Once settled, users call settle() to redeem tokens for collateral based on the outcome.
* 7. If nobody proposes/resolves a price within SETTLEMENT_TIMEOUT of initialization, any token
* holder can call emergencyRefund() to redeem at a neutral 0.5/0.5 split instead of waiting
* indefinitely on the Optimistic Oracle.
*
* Resolution values:
* - 1e18 (YES): Long tokens worth 1 collateral each, Short tokens worth 0.
Expand Down Expand Up @@ -49,6 +52,10 @@ contract EventBasedPredictionMarket is Testable {
// Price returned from the Optimistic Oracle at settlement time.
int256 public expiryPrice;

// Timestamp after which emergencyRefund() becomes callable if the OO still hasn't settled a price.
uint256 public settlementDeadline;
uint256 public constant SETTLEMENT_TIMEOUT = 72 hours;

// External contract interfaces.
ExpandedERC20 public collateralToken;
ExpandedIERC20 public longToken;
Expand All @@ -70,6 +77,7 @@ contract EventBasedPredictionMarket is Testable {
event PositionSettled(address indexed sponsor, uint256 collateralReturned, uint256 longTokens, uint256 shortTokens);
event MarketInitialized(uint256 requestTimestamp);
event PriceDisputed(uint256 oldTimestamp, uint256 newTimestamp);
event EmergencyRefund(address indexed sponsor, uint256 collateralReturned, uint256 longTokens, uint256 shortTokens);

/****************************************
* MODIFIERS *
Expand Down Expand Up @@ -136,6 +144,7 @@ contract EventBasedPredictionMarket is Testable {
}

_requestOraclePrice();
settlementDeadline = getCurrentTime() + SETTLEMENT_TIMEOUT;

emit MarketInitialized(requestTimestamp);
}
Expand Down Expand Up @@ -199,6 +208,9 @@ contract EventBasedPredictionMarket is Testable {
requestTimestamp = getCurrentTime();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Refresh the deadline for a re-requested price

priceDisputed() creates a fresh oracle request but retains the deadline from the original request. A dispute immediately before expiry therefore leaves a live replacement request subject to an already-expired emergencyRefund() window.

I reproduced this against 77d9ee6: after creating 100 pairs and transferring the Short position to a second holder, I triggered priceDisputed() at settlementDeadline - 1. At settlementDeadline + 1, emergencyRefund(100, 0) returns 50 collateral to the Long holder while the replacement request is still pending. If that request then resolves NO, the Short holder is owed 100 collateral but only 50 remains, so settlement reverts on the collateral transfer.

Renew the deadline when the fresh request is created:

requestTimestamp = getCurrentTime();
settlementDeadline = requestTimestamp + SETTLEMENT_TIMEOUT;
_requestOraclePrice();

Please add a regression test for the dispute-at-deadline boundary and verify that the later settlement remains fully collateralized.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the PoC — the race condition is real and the fix is correct in isolation.

One thing worth flagging before we land it: unconditionally resetting settlementDeadline on every dispute opens a second attack vector. A malicious actor can repeatedly dispute every incoming proposal, pushing the deadline forward indefinitely and permanently blocking emergencyRefund(). That trades a one-shot race (current design) for an unbounded griefing path.

Three options as I see it:

Option A — Reset once, then lock
Reset settlementDeadline on the first dispute only. Subsequent disputes extend the oracle liveness period but leave the deadline fixed.

if (!deadlineExtended) {
    settlementDeadline = getCurrentTime() + SETTLEMENT_TIMEOUT;
    deadlineExtended = true;
}

Option B — Cap total extensions
Allow up to N resets (e.g. maxDisputeExtensions = 2), after which the deadline is frozen regardless of further disputes.

Option C — Keep current design, document the boundary
Accept the one-shot race as the lesser risk and strengthen the existing code comment so future maintainers understand the explicit trade-off.

I'd lean toward Option A as the simplest fix that closes both vectors. Happy to implement whichever direction the maintainers prefer before adding the regression test.

_requestOraclePrice();

// NOTE: settlementDeadline intentionally does NOT reset here. A dispute that occurs close to
// the deadline can race emergencyRefund() against a legitimate re-resolution — see PR discussion.

emit PriceDisputed(oldTimestamp, requestTimestamp);
}

Expand Down Expand Up @@ -252,6 +264,34 @@ contract EventBasedPredictionMarket is Testable {
emit PositionSettled(msg.sender, collateralReturned, longTokensToRedeem, shortTokensToRedeem);
}

/**
* @notice Emergency exit for token holders when the Optimistic Oracle never resolves a price.
* Callable only after SETTLEMENT_TIMEOUT has elapsed since initializeMarket() with no settlement
* price received. Pays out at a neutral 0.5/0.5 split (same math as an "Undetermined" OO result),
* since the true outcome was never established. Directional (one-sided) holders are otherwise
* unable to exit — redeem() only works for matched Long+Short pairs.
* @param longTokensToRedeem Number of Long tokens to redeem.
* @param shortTokensToRedeem Number of Short tokens to redeem.
* @return collateralReturned Total collateral returned.
*/
function emergencyRefund(
uint256 longTokensToRedeem,
uint256 shortTokensToRedeem
) public requestInitialized returns (uint256 collateralReturned) {
require(getCurrentTime() > settlementDeadline, "Settlement deadline not reached");
require(!receivedSettlementPrice, "Price already resolved, use settle()");

require(longToken.burnFrom(msg.sender, longTokensToRedeem));
require(shortToken.burnFrom(msg.sender, shortTokensToRedeem));

// Undetermined-equivalent split: every token (long or short) is worth exactly 0.5 collateral,
// since no outcome was ever established.
collateralReturned = ((longTokensToRedeem + shortTokensToRedeem) * 5e17) / 1e18;
collateralToken.safeTransfer(msg.sender, collateralReturned);

emit EmergencyRefund(msg.sender, collateralReturned, longTokensToRedeem, shortTokensToRedeem);
}

/****************************************
* INTERNAL FUNCTIONS *
****************************************/
Expand Down