Skip to content
Merged
Show file tree
Hide file tree
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
46 changes: 46 additions & 0 deletions contracts/predictify-hybrid/PAYOUT_SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,52 @@

This document provides a comprehensive formal specification for the payout calculation and distribution mechanism in the Predictify Hybrid prediction market smart contract.

## 0. Resolution and Payout Timeline

Resolution and payouts are deliberately separated so that no winner can be paid
before the result has been challengeable for a full dispute window.

```text
market ends result proposed payouts open
| | |
|<--- resolution may ---->| |
| happen at any |<--- dispute window --------->|
| time after end | (dispute_window_seconds) |
| | |
end_time resolved_at resolved_at + window
```

Rules:

1. **Resolution is one-way.** `resolve_market_manual`, `resolve_market_with_ties`
and `force_resolve_market` reject a market whose `winning_outcomes` is set or
whose state is `Resolved` with `Error::MarketResolved`. A `Cancelled` market
is rejected with `Error::InvalidState`. Force-resolve may bypass the market's
end time, but it cannot re-settle a resolved market or revive a cancelled one.
2. **Resolution time is recorded.** Every successful resolution stores the
ledger timestamp as the market's claim-window start (the existing
`UnclaimedWinningsPolicy::set_claim_window_start_if_missing` key). This value
is the `resolved_at` used by the dispute and payout gates. Resolved markets
created before this field existed fall back to `end_time`.
3. **The dispute window runs from `resolved_at`, not `end_time`.** A result
proposed long after `end_time` still gets a full `dispute_window_seconds`
during which it can be disputed. Once that window closes the result is final.
4. **Payouts are a separate step.** Resolution never calls `distribute_payouts`.
`claim_winnings`, `distribute_payouts` and `sweep_unclaimed_winnings` all use
the shared `require_payout_allowed` gate, which requires:
- a final outcome (`winning_outcomes` is set; otherwise
`Error::MarketNotResolved`),
- `now >= resolved_at + dispute_window_seconds` (otherwise
`Error::InvalidState`),
- no open (`Active`) dispute (otherwise `Error::InvalidState`).
5. **Claims stay idempotent.** Once a winner is credited through either
`claim_winnings` or `distribute_payouts`, the market's `claimed` map prevents
any other payout path from crediting the same winner again.

Error precedence for payout paths is `MarketNotResolved`, then
`InvalidState` (window still open), then `InvalidState` (open dispute), then
`AlreadyClaimed`.

## 1. Fee Source and Payout Calculation

The `ConfigManager` platform fee is the canonical source. The fee applies at
Expand Down
85 changes: 63 additions & 22 deletions contracts/predictify-hybrid/src/disputes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -947,7 +947,7 @@ impl DisputeManager {

// Get and validate market
let mut market = MarketStateManager::get_market(env, &market_id)?;
DisputeValidator::validate_market_for_dispute(env, &market)?;
DisputeValidator::validate_market_for_dispute(env, &market_id, &market)?;

// Enforce anti-grief floor (market specific or global)
let global_anti_grief_floor = Self::get_anti_grief_floor(env).unwrap_or(0);
Expand Down Expand Up @@ -1232,7 +1232,7 @@ impl DisputeManager {
env.storage().persistent().extend_ttl(&DataKey::DisputeHistory(market_id.clone()), 535680, 535680);
}

let _ = crate::resolution::ResolutionOutcomeCache::refresh(env, &market_id, &market);
crate::resolution::ResolutionOutcomeCache::refresh(env, &market_id, &market)?;
crate::monitoring::ContractMonitor::emit_dispute_transition_hook(
env,
&market_id,
Expand Down Expand Up @@ -2496,35 +2496,55 @@ impl DisputeValidator {
///
/// A dispute is only valid when:
/// 1. The market has ended (`current_time >= end_time`).
/// 2. The dispute window is still open (`current_time < end_time + dispute_window_seconds`).
/// Allowing disputes after the window closes would create an ambiguous overlap with
/// the payout phase and could re-open markets that users already consider settled.
/// 3. The market has not already been resolved.
/// 4. An oracle result is available to dispute.
pub fn validate_market_for_dispute(env: &Env, market: &Market) -> Result<(), Error> {
/// 2. The dispute window is still open. For a resolved market the window is
/// measured from the recorded resolution time (`resolved_at`), so a result
/// proposed long after `end_time` still gets a full dispute window.
/// 3. An oracle result (unresolved markets) or a proposed resolution
/// (resolved markets) is available to dispute.
///
/// Resolved markets may be disputed only while their post-resolution window
/// is open. Once it closes the market is final and payouts are allowed.
pub fn validate_market_for_dispute(
env: &Env,
market_id: &Symbol,
market: &Market,
) -> Result<(), Error> {
let current_time = env.ledger().timestamp();

if market.state == crate::types::MarketState::Cancelled {
return Err(Error::InvalidState);
}

// Market must have ended before a dispute can be filed.
if current_time < market.end_time {
return Err(Error::MarketClosed);
}

// Disputes must be filed within the dispute window.
// After `end_time + dispute_window_seconds` the window is closed and payouts
// are unambiguously allowed, so late disputes are rejected.
// The dispute window starts when a result is proposed. Resolved markets
// use the recorded resolution time (stored as the claim-window start by
// the resolution entrypoints); unresolved markets, and legacy resolved
// markets without a recorded time, fall back to `end_time`.
let window_start = if market.winning_outcomes.is_some() {
crate::recovery::UnclaimedWinningsPolicy::get_claim_window_start(
env,
market_id,
market.end_time,
)
} else {
market.end_time
};

if market.dispute_window_seconds > 0
&& current_time >= market.end_time + market.dispute_window_seconds
&& current_time
>= window_start.saturating_add(market.dispute_window_seconds)
{
return Err(Error::MarketResolved);
}

// Check if market is already resolved.
if market.winning_outcomes.is_some() {
return Err(Error::MarketResolved);
}

// Check if oracle result is available to dispute.
if market.oracle_result.is_none() {
// An unresolved market needs an oracle result to dispute. A resolved
// market already has a proposed outcome, so manual resolutions (which
// do not carry an oracle result) remain disputable too.
if market.winning_outcomes.is_none() && market.oracle_result.is_none() {
return Err(Error::OracleUnavailable);
}

Expand Down Expand Up @@ -3640,20 +3660,41 @@ mod tests {
let mut market = create_test_market(&env, env.ledger().timestamp() + 86400);

// Market not ended - should fail
assert!(DisputeValidator::validate_market_for_dispute(&env, &market).is_err());
assert!(
DisputeValidator::validate_market_for_dispute(
&env,
&Symbol::new(&env, "market_1"),
&market,
)
.is_err()
);

// Set market as ended

market.end_time = env.ledger().timestamp().saturating_sub(1);

// No oracle result - should fail
assert!(DisputeValidator::validate_market_for_dispute(&env, &market).is_err());
assert!(
DisputeValidator::validate_market_for_dispute(
&env,
&Symbol::new(&env, "market_1"),
&market,
)
.is_err()
);

// Add oracle result
market.oracle_result = Some(String::from_str(&env, "yes"));

// Should pass
assert!(DisputeValidator::validate_market_for_dispute(&env, &market).is_ok());
assert!(
DisputeValidator::validate_market_for_dispute(
&env,
&Symbol::new(&env, "market_1"),
&market,
)
.is_ok()
);
}

#[test]
Expand Down
22 changes: 11 additions & 11 deletions contracts/predictify-hybrid/src/force_resolve_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ fn test_force_resolve_idempotency_key_replay() {

#[test]
#[ignore = "known failing: pre-existing test/API drift, see docs/TESTING.md"]
fn test_force_resolve_different_keys_same_market() {
fn test_force_resolve_different_keys_same_market_is_rejected() {
let ctx = Ctx::new();
let market_id = ctx.create_market();

Expand All @@ -262,18 +262,18 @@ fn test_force_resolve_different_keys_same_market() {
)
.unwrap();

ctx.client()
.try_force_resolve_market(
&ctx.admin,
&market_id,
&outcomes(&ctx.env, &["no"]),
&reason(&ctx.env, "Second"),
&key(&ctx.env, "key-b"),
)
.unwrap();
// Force-resolve bypasses time constraints, not the one-way resolution rule.
let result = ctx.client().try_force_resolve_market(
&ctx.admin,
&market_id,
&outcomes(&ctx.env, &["no"]),
&reason(&ctx.env, "Second"),
&key(&ctx.env, "key-b"),
);
assert_eq!(result, Err(Ok(Error::MarketResolved)));

let market = ctx.client().get_market(&market_id).unwrap();
assert_eq!(market.winning_outcomes, Some(outcomes(&ctx.env, &["no"])));
assert_eq!(market.winning_outcomes, Some(outcomes(&ctx.env, &["yes"])));
}

#[test]
Expand Down
Loading
Loading