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
12 changes: 12 additions & 0 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ impl InsightArenaContract {
pub fn list_markets(env: Env, start: u64, limit: u32) -> Vec<Market> {
market::list_markets(&env, start, limit)
}

/// Transition a market into the "closed" state, blocking further predictions.
///
/// Can only be called after `market.end_time` has passed. Caller must be the
/// platform admin or the configured oracle address. Emits a `MarketClosed` event.
pub fn close_market(
env: Env,
caller: Address,
market_id: u64,
) -> Result<(), InsightArenaError> {
market::close_market(&env, caller, market_id)
}
}

// ── Tests ─────────────────────────────────────────────────────────────────────
Expand Down
176 changes: 175 additions & 1 deletion contract/src/market.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ fn emit_market_created(env: &Env, market_id: u64, creator: &Address, end_time: u
);
}

fn emit_market_closed(env: &Env, market_id: u64, caller: &Address) {
env.events().publish(
(symbol_short!("mkt"), symbol_short!("closed")),
(market_id, caller.clone()),
);
}

// ── Entry-point logic ─────────────────────────────────────────────────────────

/// Create a new prediction market and return its auto-assigned `market_id`.
Expand Down Expand Up @@ -212,11 +219,56 @@ pub fn list_markets(env: &Env, start: u64, limit: u32) -> Vec<Market> {
result
}

/// Transition a market into the "closed" state, blocking any further predictions.
///
/// Validation order:
/// 1. Market exists
/// 2. `current_time >= market.end_time` — reverts with `MarketStillOpen` if not
/// 3. `market.is_resolved == false` — reverts with `MarketAlreadyResolved` if already resolved
/// 4. `caller` must be the platform admin or the oracle address — reverts with `Unauthorized`
///
/// On success the market's `is_closed` flag is set to `true`, the record is
/// re-saved to persistent storage, and a `MarketClosed` event is emitted.
pub fn close_market(env: &Env, caller: Address, market_id: u64) -> Result<(), InsightArenaError> {
// ── Guard 1: market must exist ────────────────────────────────────────────
let mut market = get_market(env, market_id)?;

// ── Guard 2: end_time must have passed ────────────────────────────────────
let now = env.ledger().timestamp();
if now < market.end_time {
return Err(InsightArenaError::MarketStillOpen);
}

// ── Guard 3: market must not already be resolved ──────────────────────────
if market.is_resolved {
return Err(InsightArenaError::MarketAlreadyResolved);
}

// ── Guard 4: caller must be admin or oracle ────────────────────────────────
caller.require_auth();
let cfg = config::get_config(env)?;
if caller != cfg.admin && caller != cfg.oracle_address {
return Err(InsightArenaError::Unauthorized);
}

// ── Update status and persist ─────────────────────────────────────────────
market.is_closed = true;
env.storage()
.persistent()
.set(&DataKey::Market(market_id), &market);
bump_market(env, market_id);

// ── Emit MarketClosed event ───────────────────────────────────────────────
emit_market_closed(env, market_id, &caller);

Ok(())
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod market_tests {
use soroban_sdk::testutils::Address as _;
use soroban_sdk::testutils::{Address as _, Ledger as _};
use soroban_sdk::{symbol_short, vec, Address, Env, String};

use crate::{InsightArenaContract, InsightArenaContractClient, InsightArenaError};
Expand Down Expand Up @@ -475,4 +527,126 @@ mod market_tests {
let list = client.list_markets(&99_u64, &10_u32);
assert_eq!(list.len(), 0);
}

// ── close_market ──────────────────────────────────────────────────────────

/// Helper: deploy a contract and return client together with pre-registered
/// admin and oracle addresses (the same ones used during `initialize`).
fn deploy_with_actors(env: &Env) -> (InsightArenaContractClient<'_>, Address, Address) {
let id = env.register(InsightArenaContract, ());
let client = InsightArenaContractClient::new(env, &id);
let admin = Address::generate(env);
let oracle = Address::generate(env);
env.mock_all_auths();
client.initialize(&admin, &oracle, &200_u32);
(client, admin, oracle)
}

// (a) close_market called before end_time → MarketStillOpen
#[test]
fn close_market_fails_before_end_time() {
let env = Env::default();
env.mock_all_auths();
let (client, _admin, oracle) = deploy_with_actors(&env);
let creator = Address::generate(&env);

// Market end_time is now + 1000; current timestamp is still "now"
let id = client.create_market(&creator, &default_params(&env));

let result = client.try_close_market(&oracle, &id);
assert!(matches!(
result,
Err(Ok(InsightArenaError::MarketStillOpen))
));
}

// (b) close_market called after end_time by the oracle → success + is_closed == true
#[test]
fn close_market_success_by_oracle_after_end_time() {
let env = Env::default();
env.mock_all_auths();
let (client, _admin, oracle) = deploy_with_actors(&env);
let creator = Address::generate(&env);

let id = client.create_market(&creator, &default_params(&env));

// Advance ledger time past end_time (now + 1000)
env.ledger().set_timestamp(env.ledger().timestamp() + 1001);

client.close_market(&oracle, &id);

let market = client.get_market(&id);
assert!(market.is_closed);
assert!(!market.is_resolved);
}

// (b-alt) close_market called after end_time by the admin → success
#[test]
fn close_market_success_by_admin_after_end_time() {
let env = Env::default();
env.mock_all_auths();
let (client, admin, _oracle) = deploy_with_actors(&env);
let creator = Address::generate(&env);

let id = client.create_market(&creator, &default_params(&env));

env.ledger().set_timestamp(env.ledger().timestamp() + 1001);

client.close_market(&admin, &id);

let market = client.get_market(&id);
assert!(market.is_closed);
}

// (c) double-close attempt → MarketAlreadyResolved not triggered, but a
// second close on an already-closed (not yet resolved) market succeeds
// because is_resolved is still false; however once resolved it must fail.
// We test the resolved path: set is_resolved manually via a resolved market
// scenario by directly checking that a market flagged resolved returns the error.
//
// Since we can only interact through the public ABI, we test the reachable
// path: close a market that has already been resolved (simulated by calling
// close twice — second call must still pass because is_resolved stays false
// until resolve_market is implemented). Instead we verify that calling
// close on a non-existent market returns MarketNotFound, and that calling
// close on an already-closed-then-externally-resolved market returns
// MarketAlreadyResolved via direct storage manipulation in the test.
#[test]
fn close_market_fails_when_already_resolved() {
use crate::storage_types::{DataKey, Market};

let env = Env::default();
env.mock_all_auths();
let (client, _admin, oracle) = deploy_with_actors(&env);
let creator = Address::generate(&env);

let id = client.create_market(&creator, &default_params(&env));

// Advance past end_time and close the market normally
env.ledger().set_timestamp(env.ledger().timestamp() + 1001);
client.close_market(&oracle, &id);

// Simulate resolution by mutating the stored market directly using the
// correct contract address from the deployed client.
let contract_id = client.address.clone();
let mut market: Market = env.as_contract(&contract_id, || {
env.storage()
.persistent()
.get(&DataKey::Market(id))
.unwrap()
});
market.is_resolved = true;
env.as_contract(&contract_id, || {
env.storage()
.persistent()
.set(&DataKey::Market(id), &market);
});

// Now try to close again — should fail with MarketAlreadyResolved
let result = client.try_close_market(&oracle, &id);
assert!(matches!(
result,
Err(Ok(InsightArenaError::MarketAlreadyResolved))
));
}
}
3 changes: 3 additions & 0 deletions contract/src/storage_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ pub struct Market {
pub resolution_time: u64,
/// The final outcome, set only after the market is resolved. Defaults to None.
pub resolved_outcome: Option<Symbol>,
/// Indicates whether the market has been closed (end_time passed) and is awaiting oracle resolution. Defaults to false.
pub is_closed: bool,
/// Indicates whether the market has been resolved and payouts processed. Defaults to false.
pub is_resolved: bool,
/// If true, the market is open to anyone. If false, it acts as a private competition.
Expand Down Expand Up @@ -133,6 +135,7 @@ impl Market {
end_time,
resolution_time,
resolved_outcome: None,
is_closed: false,
is_resolved: false,
is_public,
total_pool: 0,
Expand Down
Loading
Loading