diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 2b6c6b7..f927205 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -29,6 +29,17 @@ const MIN_SEED: i128 = 100 * 10_000_000; /// seize control months later. 7 days. const ADMIN_PROPOSAL_TTL_SECS: u64 = 7 * 86_400; +/// How far a `halt_trading` effective time may be backdated (issue #22). +/// +/// Seven days, deliberately the *same* window the resolver already grants its +/// signers (`resolver::SIGNER_GRACE_SECS`: attestations are accepted until +/// `close_time + 7 days`). A halt may therefore reach back no further than the +/// period in which post-close trading is already tolerated today, which keeps +/// the earliest possible `effective_at` inside the window whose trades the +/// agreed late-trade policy discloses. Anything longer would rewrite history +/// beyond the evidence a signer set can credibly attest to. +const MAX_BACKDATE_SECS: u64 = 7 * 86_400; + // --------------------------------------------------------------------------- // Types shared with the frontend/backend // --------------------------------------------------------------------------- @@ -134,6 +145,41 @@ pub struct PendingAdmin { pub expires_at: u64, } +/// How a **voided** market pays its holders back (issue #21), chosen and +/// frozen by the creator at market creation. +/// +/// `HalfPerShare` is today's rule (`math::settle_value`) and remains the +/// default for every market that has no `DataKey::VoidPolicy` entry, so +/// markets created before this change read exactly as before. +/// `CostBasis` refunds each holder the collateral it actually put in, capped +/// pro-rata when the positive bases exceed the refund pot. +/// +/// Stored as a sidecar entry rather than as a `Market` field: `Market`'s XDR +/// is decoded by resolver, treasury and router through `contractimport!` +/// copies of this contract's wasm that the repo pins by sha256, so adding a +/// field would force a blob rebuild that cannot be performed here. See +/// `docs/architecture.md`, "Void refund policies". +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VoidPolicy { + HalfPerShare, + CostBasis, +} + +/// The two numbers a `CostBasis` void pays out of, frozen by `void_market` +/// (issue #21). Holders and the LP are settled against this snapshot and +/// never against live storage, which is what makes the payouts independent of +/// the order in which `redeem` is called. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VoidSnapshot { + /// Complete sets in existence at void time — the collateral the pot can + /// ever pay. `pot == m.sets_minted`. + pub pot: i128, + /// Sum of the *positive* holder bases at void time. + pub basis_total: i128, +} + // --------------------------------------------------------------------------- // Storage keys // --------------------------------------------------------------------------- @@ -161,6 +207,34 @@ pub enum DataKey { Breaker(u64), // temporary storage (expires on its own, one entry per market) Band(u64), + // ----- #21 cost-basis void refunds -------------------------------------- + // Append-only: `DataKey`'s discriminants are positional in XDR, so every + // entry above keeps its index. All four are absent for markets created + // before this change, and "absent" is defined as the legacy behaviour. + /// The market's [`VoidPolicy`]. Absent = [`VoidPolicy::HalfPerShare`]. + VoidPolicy(u64), + /// Net collateral a holder paid in minus took out, for `CostBasis` + /// markets only. Signed: a holder who sold at a profit has a negative + /// basis and is refunded nothing. Removed with its position. + CostBasis(u64, Address), + /// Sum of the *positive* `CostBasis` entries of a market, maintained in + /// O(1) on every basis write so the void pro-rata needs no holder + /// enumeration. + BasisTotal(u64), + /// [`VoidSnapshot`] written by `void_market` for `CostBasis` markets. + VoidSnapshot(u64), + // ----- #22 event-driven early close -------------------------------------- + /// `halt_trading` recorded: trading ends at this timestamp instead of at + /// `Market::close_time`. Absent = no halt, so `effective_close == close_time` + /// and every pre-upgrade market behaves exactly as before. One entry per + /// market, written at most once (there is no unfinalised state to clear: + /// disputes are settled on the resolver side *before* `halt_trading` runs). + EarlyClose(u64), + /// Timestamp of the last accepted `buy`/`sell`, written on every trade and + /// read back by `TradingHalted` so the disclosed late-trade window + /// `[effective_at, halted_at)` can be anchored to a real on-chain trade + /// (issue #22). One entry per market; it never grows. + LastTrade(u64), } // --------------------------------------------------------------------------- @@ -205,6 +279,11 @@ pub enum Error { NoPendingAdmin = 25, /// A proposed admin handover was not accepted within its `expires_at`. PendingAdminExpired = 26, + /// `halt_trading` called for a market that is already halted (issue #22). + /// A halt is a single, permanent fact: there is no re-halt and no lift. + AlreadyHalted = 27, + /// `halt_trading` backdated further than `MAX_BACKDATE_SECS` (issue #22). + HaltTooOld = 28, } impl From for Error { @@ -366,6 +445,48 @@ pub struct LpTransferred { pub to: Address, } +/// Emitted by `create_market_with_policy` (issue #21), *after* the unchanged +/// `MarketCreated` event: an indexer that knows nothing about void policies +/// still sees the market being created, and one that does can attribute the +/// policy without re-reading storage. Markets created through `create_market` +/// never emit this — their policy is the default `HalfPerShare`. +#[contractevent] +pub struct VoidPolicyChosen { + #[topic] + pub market_id: u64, + pub policy: VoidPolicy, +} + +/// Emitted by `void_market` for a `CostBasis` market (issue #21): the refund +/// pot and the positive basis total are frozen here, so every later `redeem` +/// and `claim_pool_remainder` pays from these numbers only. +#[contractevent] +pub struct CostBasisVoidSnapshotted { + #[topic] + pub market_id: u64, + pub pot: i128, + pub basis_total: i128, +} + +/// Emitted by `halt_trading` (issue #22). `effective_at` is when trading *ended* +/// and `halted_at` when the chain learned about it, so the pair is exactly the +/// window the agreed late-trade policy has to cover: trades in +/// `[effective_at, halted_at)` happened at a price the event says is stale and +/// are **not** reverted (see `docs/architecture.md`, "Early close and the +/// late-trade window", and ADR 0001). `last_trade_at` anchors the window to the +/// market's real final trade, which is the number an off-chain auditor needs to +/// quantify the damage; `close_time` is carried so a consumer does not have to +/// re-read a market whose state has since moved on. +#[contractevent] +pub struct TradingHalted { + #[topic] + pub market_id: u64, + pub effective_at: u64, + pub halted_at: u64, + pub close_time: u64, + pub last_trade_at: u64, +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -569,6 +690,12 @@ impl MarketCore { /// Creates a market and seeds the pool. `creator` becomes the sole LP. /// `initial_yes_bps` in [100, 9900] sets the opening YES probability; /// the creator receives the leftover shares of the likelier outcome. + /// + /// The void refund policy is the default [`VoidPolicy::HalfPerShare`]; + /// see [`MarketCore::create_market_with_policy`] to choose another one. + /// This entrypoint's behaviour and signature are unchanged, and it writes + /// no policy entry at all, so markets created through it are stored + /// bit-for-bit as before this change. #[allow(clippy::too_many_arguments)] pub fn create_market( env: Env, @@ -580,6 +707,45 @@ impl MarketCore { close_time: u64, seed_amount: i128, initial_yes_bps: u32, + ) -> Result { + Self::create_market_with_policy( + env, + creator, + question_hash, + resolution_hash, + meta_uri, + category, + close_time, + seed_amount, + initial_yes_bps, + VoidPolicy::HalfPerShare, + ) + } + + /// `create_market` plus the [`VoidPolicy`] the creator commits to for the + /// life of the market (issue #21). + /// + /// A **new entrypoint** rather than a trailing parameter on `create_market` + /// (which the issue also allows): `create_market`'s signature is part of + /// the interface that resolver, treasury and router decode from their + /// vendored copies of this contract's wasm, and those blobs are pinned by + /// sha256 and cannot be rebuilt here. Adding a parameter would change the + /// exported spec and every client's call site; adding an entrypoint changes + /// neither. Consequence: `treasury::agent_create_market` keeps creating + /// `HalfPerShare` markets until somebody with stellar-cli 27.1.0 refreshes + /// the vendored blob — an adoption limit, not a bug. + #[allow(clippy::too_many_arguments)] + pub fn create_market_with_policy( + env: Env, + creator: Address, + question_hash: BytesN<32>, + resolution_hash: BytesN<32>, + meta_uri: String, + category: Category, + close_time: u64, + seed_amount: i128, + initial_yes_bps: u32, + void_policy: VoidPolicy, ) -> Result { Self::assert_not_paused(&env)?; creator.require_auth(); @@ -645,6 +811,18 @@ impl MarketCore { }, ); } + // #21: only a CostBasis market gets the sidecar entries. For + // HalfPerShare this block is skipped entirely, so no `VoidPolicy`, + // `CostBasis` or `BasisTotal` key is ever written. + if void_policy == VoidPolicy::CostBasis { + Self::save_void_policy(&env, id, &void_policy); + // The creator paid `seed_amount` into the pool and received no + // collateral back, so its basis is the whole seed — the same + // "net collateral paid" rule used for `buy`. With it + // `Σ basis_h == sets_minted` right after creation, i.e. the LP's + // implicit residual basis (`sets_minted − Σ basis_h`) is 0. + Self::add_basis(&env, id, &creator, seed_amount, false)?; + } Self::token(&env)?.transfer(&creator, env.current_contract_address(), &seed_amount); @@ -657,6 +835,13 @@ impl MarketCore { initial_yes_bps, } .publish(&env); + if void_policy == VoidPolicy::CostBasis { + VoidPolicyChosen { + market_id: id, + policy: void_policy, + } + .publish(&env); + } Ok(id) } @@ -744,7 +929,7 @@ impl MarketCore { } trader.require_auth(); let mut m = Self::load_market(&env, market_id)?; - Self::assert_tradable(&env, &m)?; + Self::assert_tradable(&env, market_id, &m)?; let (r_bought, r_other) = Self::reserves_for(&m, outcome); let q = math::calc_buy(r_bought, r_other, collateral_in, m.fee_bps)?; @@ -783,6 +968,13 @@ impl MarketCore { Outcome::No => pos.no += q.shares_out, } Self::save_position(&env, market_id, &trader, &pos); + // #21: `q.net` is the collateral that minted complete sets, i.e. what + // left the trader's pocket into `sets_minted` (the fee went to the + // protocol and is never refunded, so it is excluded by design). + Self::add_basis(&env, market_id, &trader, q.net, false)?; + // #22: one extra persistent write per accepted trade, always to the same + // key, so the cost is a fixed 8-byte entry per market and never growth. + Self::save_last_trade(&env, market_id); Self::save_market(&env, market_id, &m); // interaction @@ -878,7 +1070,7 @@ impl MarketCore { Self::assert_not_paused(&env)?; trader.require_auth(); let mut m = Self::load_market(&env, market_id)?; - Self::assert_tradable(&env, &m)?; + Self::assert_tradable(&env, market_id, &m)?; let (r_sold, r_other) = Self::reserves_for(&m, outcome); let q = math::calc_sell(r_sold, r_other, collateral_out, m.fee_bps)?; @@ -913,7 +1105,18 @@ impl MarketCore { Outcome::Yes => pos.yes -= q.shares_in, Outcome::No => pos.no -= q.shares_in, } + let emptied = pos.yes == 0 && pos.no == 0; Self::save_position(&env, market_id, &trader, &pos); + // #21: reduce the basis by `q.gross` — the collateral that left + // `sets_minted` — and *not* proportionally to the shares sold, which + // is what keeps `Σ basis_h + basis_LP == sets_minted` exact. A holder + // who sells more than they paid in ends with a negative basis (they + // already took their refund out of the pool); the entry goes with the + // position once it empties, and its unrecovered positive part, if any, + // becomes the LP's residual. + Self::add_basis(&env, market_id, &trader, -q.gross, emptied)?; + // #22: see the note in `do_buy`. + Self::save_last_trade(&env, market_id); Self::set_reserves(&mut m, outcome, q.new_reserve_sold, q.new_reserve_other); m.sets_minted -= q.gross; let protocol_fee = Self::accrue_fees(&env, market_id, q.fee)?; @@ -945,13 +1148,73 @@ impl MarketCore { // Only the configured resolver contract may call these. When the resolver // contract is the direct caller, `require_auth` on its address passes. + /// Brings the end of trading forward to `effective_at` (issue #22). + /// + /// A "Will X happen by D?" question can be settled by the world long before + /// `D`; until now the pool stayed open and anyone faster than the repricing + /// crowd bought the already-known outcome at a stale price, with the loss + /// landing on the LP. `halt_trading` closes that window: from the moment it + /// is called, `assert_tradable`, `resolve` and the resolver's attestation + /// gate all read `effective_close = min(close_time, effective_at)`. + /// + /// Rules, in order: + /// - resolver-only, exactly like `resolve` / `void_market`; + /// - the market must still be `Open` (a resolved or void market has nothing + /// to halt, and `finalize_early_close` reaching a market that was voided + /// mid-dispute fails here rather than half-applying a halt); + /// - one halt per market — a halt is permanent, there is no lift entrypoint, + /// which is what makes "the halt is final" trivially true on-chain; + /// - `effective_at <= close_time` (a halt may only move the close earlier) + /// and `effective_at <= now` (a halt records a fact, it cannot schedule + /// one), plus `effective_at >= now - MAX_BACKDATE_SECS`. + /// + /// Because the halt only *records* a past instant, trades that landed + /// between `effective_at` and this transaction still happened. The chosen + /// policy is **accept and disclose**: they are not reverted, and the event + /// publishes the window so the backend can flag them. Rationale and the + /// rejected alternative are in `docs/adr/0001-resolution-commitment.md`. + pub fn halt_trading(env: Env, market_id: u64, effective_at: u64) -> Result<(), Error> { + Self::require_resolver(&env)?; + let m = Self::load_market(&env, market_id)?; + if m.status != MarketStatus::Open { + return Err(Error::MarketNotOpen); + } + if Self::load_early_close(&env, market_id).is_some() { + return Err(Error::AlreadyHalted); + } + if effective_at > m.close_time { + return Err(Error::InvalidCloseTime); + } + let now = env.ledger().timestamp(); + if effective_at > now { + return Err(Error::InvalidCloseTime); + } + if effective_at < now.saturating_sub(MAX_BACKDATE_SECS) { + return Err(Error::HaltTooOld); + } + Self::save_early_close(&env, market_id, effective_at); + TradingHalted { + market_id, + effective_at, + halted_at: now, + close_time: m.close_time, + last_trade_at: Self::load_last_trade(&env, market_id), + } + .publish(&env); + Ok(()) + } + pub fn resolve(env: Env, market_id: u64, outcome: Outcome) -> Result<(), Error> { Self::require_resolver(&env)?; let mut m = Self::load_market(&env, market_id)?; if m.status != MarketStatus::Open { return Err(Error::MarketNotOpen); } - if env.ledger().timestamp() < m.close_time { + // Issue #22: an early halt is final the instant it is written (it can + // only come from the resolver, and there is no way to lift it), so the + // effective close is the only gate. With no halt recorded this is the + // original `m.close_time` comparison, byte for byte. + if env.ledger().timestamp() < Self::effective_close(&env, market_id, &m) { return Err(Error::NotClosedYet); } m.status = MarketStatus::Resolved(outcome); @@ -965,6 +1228,11 @@ impl MarketCore { } /// Invalid or unresolvable market. Allowed any time before finality. + /// + /// For a `CostBasis` market (issue #21) this is also the moment the refund + /// pot is frozen: `void_market` snapshots `sets_minted` and the positive + /// basis total, and every later `redeem` / `claim_pool_remainder` pays from + /// that snapshot only. A `HalfPerShare` market writes nothing here. pub fn void_market(env: Env, market_id: u64) -> Result<(), Error> { Self::require_resolver(&env)?; let mut m = Self::load_market(&env, market_id)?; @@ -973,6 +1241,19 @@ impl MarketCore { } m.status = MarketStatus::Void; Self::save_market(&env, market_id, &m); + if Self::load_void_policy(&env, market_id) == VoidPolicy::CostBasis { + let snapshot = VoidSnapshot { + pot: m.sets_minted, + basis_total: Self::load_basis_total(&env, market_id), + }; + Self::save_void_snapshot(&env, market_id, &snapshot); + CostBasisVoidSnapshotted { + market_id, + pot: snapshot.pot, + basis_total: snapshot.basis_total, + } + .publish(&env); + } MarketResolved { market_id, outcome: None, @@ -985,6 +1266,10 @@ impl MarketCore { /// Pays out `holder`'s position for a final market and clears it. /// Losing positions clear with a payout of 0. + /// + /// Under the `CostBasis` void policy (issue #21) the payout is the holder's + /// share of the frozen refund pot instead of `settle_value`; a resolved + /// market ignores the policy entirely, and so does a `HalfPerShare` one. pub fn redeem(env: Env, holder: Address, market_id: u64) -> Result { holder.require_auth(); let m = Self::load_market(&env, market_id)?; @@ -993,9 +1278,26 @@ impl MarketCore { if pos.yes == 0 && pos.no == 0 { return Err(Error::NothingToRedeem); } - let payout = math::settle_value(kind, pos.yes, pos.no); + let payout = match (kind, Self::load_void_policy(&env, market_id)) { + (Settlement::Void, VoidPolicy::CostBasis) => { + match Self::load_void_snapshot_opt(&env, market_id) { + Some(snapshot) => math::cost_basis_refund( + Self::load_basis(&env, market_id, &holder), + snapshot.pot, + snapshot.basis_total, + )?, + // `void_market` always writes the snapshot for a CostBasis + // market, so falling back to the legacy rule here is only + // reachable if the entry was archived. Paying `settle_value` + // is the safe direction: paying 0 would strand the refund. + None => math::settle_value(kind, pos.yes, pos.no), + } + } + _ => math::settle_value(kind, pos.yes, pos.no), + }; Self::save_position(&env, market_id, &holder, &Position { yes: 0, no: 0 }); + Self::clear_basis(&env, market_id, &holder)?; if payout > 0 { Self::token(&env)?.transfer(&env.current_contract_address(), &holder, &payout); } @@ -1010,6 +1312,13 @@ impl MarketCore { /// Creator (the LP) withdraws whatever the pool still holds once final, /// plus any unclaimed LP fees. Returns the total paid out. + /// + /// Under the `CostBasis` void policy (issue #21) the LP takes the snapshot + /// residual `pot − min(basis_total, pot)` and never a live read of what + /// holders have already redeemed: the amount is fixed at void time, so + /// claiming before anybody else redeems cannot take their refunds, and + /// claiming last cannot take more either. Together with the floored holder + /// refunds this bounds the total void payout by `pot == sets_minted`. pub fn claim_pool_remainder(env: Env, market_id: u64) -> Result { let mut m = Self::load_market(&env, market_id)?; m.creator.require_auth(); @@ -1017,7 +1326,17 @@ impl MarketCore { if m.lp_claimed { return Err(Error::AlreadyClaimed); } - let pool_value = math::settle_value(kind, m.reserve_yes, m.reserve_no); + let pool_value = match (kind, Self::load_void_policy(&env, market_id)) { + (Settlement::Void, VoidPolicy::CostBasis) => { + match Self::load_void_snapshot_opt(&env, market_id) { + Some(snapshot) => { + math::cost_basis_lp_residual(snapshot.pot, snapshot.basis_total)? + } + None => math::settle_value(kind, m.reserve_yes, m.reserve_no), + } + } + _ => math::settle_value(kind, m.reserve_yes, m.reserve_no), + }; let payout = pool_value .checked_add(Self::load_lp_fees(&env, market_id)) .ok_or(Error::Overflow)?; @@ -1118,6 +1437,18 @@ impl MarketCore { Self::load_market(&env, market_id)?; let mut a = Self::load_position(&env, market_id, &from); let mut b = Self::load_position(&env, market_id, &to); + // #21: cost basis is a property of the collateral, so it follows the + // shares it paid for. A `CostBasis` transfer moves the same fraction of + // the sender's *positive* basis as the fraction of their shares moving; + // a negative basis (the sender already took out more than they paid in) + // stays with its owner, because it is a claim already spent and not an + // asset to hand over. No-op for every other market. + let held_before = a.yes.checked_add(a.no).ok_or(Error::Overflow)?; + let basis_slice = if held_before > 0 { + Self::transferable_basis(&env, market_id, &from, amount, held_before)? + } else { + 0 + }; match outcome { Outcome::Yes => { if a.yes < amount { @@ -1134,8 +1465,13 @@ impl MarketCore { b.no = b.no.checked_add(amount).ok_or(Error::Overflow)?; } } + let from_emptied = a.yes == 0 && a.no == 0; Self::save_position(&env, market_id, &from, &a); Self::save_position(&env, market_id, &to, &b); + if basis_slice != 0 || from_emptied { + Self::add_basis(&env, market_id, &from, -basis_slice, from_emptied)?; + Self::add_basis(&env, market_id, &to, basis_slice, false)?; + } PositionTransferred { market_id, from, @@ -1150,6 +1486,11 @@ impl MarketCore { /// Transfers the single LP role (the right to `claim_pool_remainder` and /// `claim_lp_fees`) to `new_creator`. Only until the remainder has been /// claimed; after that the role has no remaining value. + /// + /// No `CostBasis` bookkeeping is needed here (issue #21): the LP's own basis + /// is the *implicit* residual `sets_minted − Σ basis_h` and is never stored, + /// so it follows `m.creator` automatically and the new LP is the one who + /// claims `cost_basis_lp_residual`. pub fn transfer_lp(env: Env, market_id: u64, new_creator: Address) -> Result<(), Error> { let mut m = Self::load_market(&env, market_id)?; m.creator.require_auth(); @@ -1206,6 +1547,60 @@ impl MarketCore { Self::load_position(&env, market_id, &holder) } + /// The market's [`VoidPolicy`] (issue #21). A market with no sidecar entry + /// — every market created before this change, and every one created through + /// `create_market` — reports `HalfPerShare`. + pub fn get_void_policy(env: Env, market_id: u64) -> Result { + Self::load_market(&env, market_id)?; + Ok(Self::load_void_policy(&env, market_id)) + } + + /// Signed net collateral `holder` has put into a `CostBasis` market and + /// not taken back (issue #21). Always 0 for a `HalfPerShare` market, which + /// never writes the entry. + pub fn get_cost_basis(env: Env, market_id: u64, holder: Address) -> Result { + Self::load_market(&env, market_id)?; + Ok(Self::load_basis(&env, market_id, &holder)) + } + + /// Sum of the positive holder bases of a `CostBasis` market (issue #21), + /// the denominator of the void pro-rata. + pub fn get_basis_total(env: Env, market_id: u64) -> Result { + Self::load_market(&env, market_id)?; + Ok(Self::load_basis_total(&env, market_id)) + } + + /// The refund pot frozen by `void_market` for a `CostBasis` market + /// (issue #21). `Ok(None)` while the market is open, resolved, or voided + /// under `HalfPerShare`. + pub fn get_void_snapshot(env: Env, market_id: u64) -> Result, Error> { + Self::load_market(&env, market_id)?; + Ok(Self::load_void_snapshot_opt(&env, market_id)) + } + + /// The instant recorded by `halt_trading` (issue #22), `None` while the + /// market has never been halted. Read-only and permissionless, so the + /// resolver can gate attestation on it without a second source of truth. + pub fn get_early_close(env: Env, market_id: u64) -> Result, Error> { + Self::load_market(&env, market_id)?; + Ok(Self::load_early_close(&env, market_id)) + } + + /// `min(close_time, early_close)` — when trading really ends (issue #22). + /// Equals `get_market(..).close_time` for every market that was never + /// halted, which is the whole compatibility argument for the sidecar. + pub fn get_effective_close(env: Env, market_id: u64) -> Result { + let m = Self::load_market(&env, market_id)?; + Ok(Self::effective_close(&env, market_id, &m)) + } + + /// Timestamp of the last accepted `buy`/`sell`, 0 if the market never + /// traded (issue #22: the anchor for the disclosed late-trade window). + pub fn get_last_trade_at(env: Env, market_id: u64) -> Result { + Self::load_market(&env, market_id)?; + Ok(Self::load_last_trade(&env, market_id)) + } + /// Spot price of `outcome` in bps (0..=10_000). YES + NO ~ 10_000. pub fn get_price(env: Env, market_id: u64, outcome: Outcome) -> Result { let m = Self::load_market(&env, market_id)?; @@ -1353,11 +1748,17 @@ impl MarketCore { Ok(()) } - fn assert_tradable(env: &Env, m: &Market) -> Result<(), Error> { + /// Trading gates: open, and strictly before the **effective** close. + /// + /// Issue #22 adds the early-close term. A market with no `EarlyClose` entry + /// — every market created before this change, and every one never halted — + /// compares against `m.close_time` exactly as before, at the cost of one + /// storage read that misses. + fn assert_tradable(env: &Env, market_id: u64, m: &Market) -> Result<(), Error> { if m.status != MarketStatus::Open { return Err(Error::MarketNotOpen); } - if env.ledger().timestamp() >= m.close_time { + if env.ledger().timestamp() >= Self::effective_close(env, market_id, m) { return Err(Error::MarketClosed); } Ok(()) @@ -1440,6 +1841,219 @@ impl MarketCore { } } + // ----- #21 cost-basis void refunds -------------------------------------- + // + // Every one of these is a no-op (or a single storage read that misses) for + // a market without a `CostBasis` policy entry, which is what keeps + // `HalfPerShare` markets — including all pre-upgrade ones — bit-for-bit + // unchanged in storage. + // + // The invariant they maintain is `Σ basis_h + basis_LP == sets_minted`, + // where `basis_LP` is not stored but *is* `sets_minted − Σ basis_h`: every + // path that moves collateral across the pool boundary changes `sets_minted` + // by exactly the amount it changes one holder's basis (`+q.net` on a buy, + // `-q.gross` on a sell, `+seed_amount` at creation), so the identity holds + // at every step by construction. Deleting a basis with its emptied position + // hands its unrecovered positive part to the LP, which the identity absorbs. + + fn load_void_policy(env: &Env, id: u64) -> VoidPolicy { + env.storage() + .persistent() + .get(&DataKey::VoidPolicy(id)) + .unwrap_or(VoidPolicy::HalfPerShare) + } + + fn save_void_policy(env: &Env, id: u64, policy: &VoidPolicy) { + let key = DataKey::VoidPolicy(id); + env.storage().persistent().set(&key, policy); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + /// The holder's signed basis, 0 when absent. Never extends or creates an + /// entry: `add_basis` is the only writer. + fn load_basis(env: &Env, id: u64, holder: &Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::CostBasis(id, holder.clone())) + .unwrap_or(0) + } + + fn load_basis_total(env: &Env, id: u64) -> i128 { + env.storage() + .persistent() + .get(&DataKey::BasisTotal(id)) + .unwrap_or(0) + } + + fn save_basis_total(env: &Env, id: u64, total: i128) { + let key = DataKey::BasisTotal(id); + if total <= 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &total); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + } + + fn load_void_snapshot_opt(env: &Env, id: u64) -> Option { + let key = DataKey::VoidSnapshot(id); + let snapshot = env.storage().persistent().get(&key); + if snapshot.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + snapshot + } + + fn save_void_snapshot(env: &Env, id: u64, snapshot: &VoidSnapshot) { + let key = DataKey::VoidSnapshot(id); + env.storage().persistent().set(&key, snapshot); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + /// Applies `delta` to a `CostBasis` holder's basis and keeps + /// `DataKey::BasisTotal` exact. `position_empty` mirrors `save_position`: + /// an emptied position takes its entry with it, so what it was owed falls + /// back to the LP. Does nothing at all for `HalfPerShare` markets. + fn add_basis( + env: &Env, + id: u64, + holder: &Address, + delta: i128, + position_empty: bool, + ) -> Result<(), Error> { + if Self::load_void_policy(env, id) != VoidPolicy::CostBasis { + return Ok(()); + } + let before = Self::load_basis(env, id, holder); + let after = before.checked_add(delta).ok_or(Error::Overflow)?; + let key = DataKey::CostBasis(id, holder.clone()); + if position_empty || after == 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &after); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + let counted = if position_empty { 0 } else { after.max(0) }; + let total = Self::load_basis_total(env, id) + .checked_sub(before.max(0)) + .and_then(|t| t.checked_add(counted)) + .ok_or(Error::Overflow)?; + Self::save_basis_total(env, id, total); + Ok(()) + } + + /// Drops a redeemed holder's basis entry (issue #21). Called on every + /// `redeem`, including of a resolved market, so a `CostBasis` market cannot + /// accumulate entries for positions that no longer exist. + fn clear_basis(env: &Env, id: u64, holder: &Address) -> Result<(), Error> { + if Self::load_void_policy(env, id) != VoidPolicy::CostBasis { + return Ok(()); + } + let before = Self::load_basis(env, id, holder); + if before == 0 { + return Ok(()); + } + env.storage() + .persistent() + .remove(&DataKey::CostBasis(id, holder.clone())); + let total = Self::load_basis_total(env, id) + .checked_sub(before.max(0)) + .ok_or(Error::Overflow)?; + Self::save_basis_total(env, id, total); + Ok(()) + } + + /// The slice of `from`'s basis that moves with `amount` of its + /// `held_before` shares: `floor(basis * amount / held_before)`, or 0 when + /// the basis is not positive (see `transfer_position`). + fn transferable_basis( + env: &Env, + id: u64, + from: &Address, + amount: i128, + held_before: i128, + ) -> Result { + if Self::load_void_policy(env, id) != VoidPolicy::CostBasis { + return Ok(0); + } + let basis = Self::load_basis(env, id, from); + if basis <= 0 { + return Ok(0); + } + Ok(math::mul_div_floor(basis, amount, held_before)?) + } + + // ----- #22 event-driven early close ------------------------------------- + // + // A halt is sidecar state, not a `Market` field: `Market`'s XDR is decoded by + // resolver, treasury and router out of vendored copies of this contract's + // wasm that the repo pins by sha256, so adding a field would need a blob + // rebuild that cannot be performed here (the same reason as #21). The one + // behavioural consequence is that `close_time` stays the *nominal* close and + // every consumer that cares about the real one has to go through + // `effective_close` / the new views. + + /// The recorded halt instant, `None` when the market was never halted. + fn load_early_close(env: &Env, id: u64) -> Option { + let key = DataKey::EarlyClose(id); + let at: Option = env.storage().persistent().get(&key); + if at.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + at + } + + fn save_early_close(env: &Env, id: u64, effective_at: u64) { + let key = DataKey::EarlyClose(id); + env.storage().persistent().set(&key, &effective_at); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + /// When trading really ends: an early halt can only move the close + /// *earlier*, so the effective close is the minimum. Clamped defensively — + /// `halt_trading` already refuses `effective_at > close_time`, and a halt + /// must never extend a market's life. + fn effective_close(env: &Env, id: u64, m: &Market) -> u64 { + match Self::load_early_close(env, id) { + Some(at) if at < m.close_time => at, + _ => m.close_time, + } + } + + /// Timestamp of the last accepted trade, 0 when the market has never + /// traded. Read by `halt_trading` to anchor the disclosed late-trade window + /// and exposed as `get_last_trade_at` for the backend. + fn load_last_trade(env: &Env, id: u64) -> u64 { + env.storage() + .persistent() + .get(&DataKey::LastTrade(id)) + .unwrap_or(0) + } + + fn save_last_trade(env: &Env, id: u64) { + let key = DataKey::LastTrade(id); + env.storage() + .persistent() + .set(&key, &env.ledger().timestamp()); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + fn load_fee_terms(env: &Env, id: u64) -> Option { let key = DataKey::FeeTerms(id); let terms = env.storage().persistent().get(&key); @@ -3066,6 +3680,662 @@ mod test { .try_propose_treasury(&peer) .is_err()); } + + // ----- #21: optional cost-basis refunds for voided markets -------------- + + /// Seed used by every market in this module (100 USDC, 7 decimals). + const TEST_SEED: i128 = 100 * 10_000_000; + + /// A market created through `create_market_with_policy` (issue #21). + fn new_market_policy( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + initial_yes_bps: u32, + policy: VoidPolicy, + salt: u8, + ) -> u64 { + client.create_market_with_policy( + creator, + &BytesN::from_array(env, &[salt; 32]), + &BytesN::from_array(env, &[salt.wrapping_add(100); 32]), + &String::from_str(env, "ipfs://cost-basis"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &TEST_SEED, + &initial_yes_bps, + &policy, + ) + } + + /// Direct storage probe, so a test can assert an entry was never written. + fn has_entry(env: &Env, client: &MarketCoreClient, key: DataKey) -> bool { + let contract = client.address.clone(); + env.as_contract(&contract, || env.storage().persistent().has(&key)) + } + + /// The trade inputs a test needs to mirror the contract's own quotes. + fn quote(client: &MarketCoreClient, market_id: u64, outcome: &Outcome) -> (i128, i128, u32) { + let m = client.get_market(&market_id); + let reserves = match outcome { + Outcome::Yes => (m.reserve_yes, m.reserve_no), + Outcome::No => (m.reserve_no, m.reserve_yes), + }; + (reserves.0, reserves.1, m.fee_bps) + } + + /// The market's whole position book (used by the redemption loops below). + fn position_total(client: &MarketCoreClient, market_id: u64, holder: &Address) -> i128 { + let p = client.get_position(&market_id, holder); + p.yes + p.no + } + + #[test] + fn half_per_share_void_unchanged() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 9000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(30 * 10_000_000_i128), + &0_i128, + ); + + // `create_market` writes no policy sidecar at all, so a pre-upgrade + // market stays byte-identical in storage. + assert!(!has_entry(&env, &client, DataKey::VoidPolicy(market_id))); + assert!(!has_entry( + &env, + &client, + DataKey::CostBasis(market_id, trader.clone()) + )); + assert!(!has_entry(&env, &client, DataKey::BasisTotal(market_id))); + assert_eq!(client.get_void_policy(&market_id), VoidPolicy::HalfPerShare); + assert_eq!(client.get_cost_basis(&market_id, &trader), 0); + + client.void_market(&market_id); + assert!(!has_entry(&env, &client, DataKey::VoidSnapshot(market_id))); + assert!(client.get_void_snapshot(&market_id).is_none()); + + // The legacy rule, unchanged: half a unit per share, floor. + let pos = client.get_position(&market_id, &trader); + let expected = (pos.yes + pos.no) / 2; + assert_eq!(client.redeem(&trader, &market_id), expected); + let m = client.get_market(&market_id); + assert_eq!( + client.claim_pool_remainder(&market_id), + (m.reserve_yes + m.reserve_no) / 2 + ); + // The 44% haircut that motivates #21 is still there for a 0.9 entry. + assert!(expected < 30 * 10_000_000_i128); + } + + #[test] + fn cost_basis_void_refunds_buyer_at_high_price() { + let (env, client, creator, trader, _, collateral) = setup_market(); + // Opens at 90% YES: the case where half-per-share pays 0.5 for a 0.9 + // entry. `create_market_with_policy` costs the same market 5% more. + let market_id = new_market_policy(&env, &client, &creator, 9000, VoidPolicy::CostBasis, 22); + assert_eq!(client.get_void_policy(&market_id), VoidPolicy::CostBasis); + + let paid = 30 * 10_000_000_i128; + let (r_bought, r_other, fee_bps) = quote(&client, market_id, &Outcome::Yes); + let q = math::calc_buy(r_bought, r_other, paid, fee_bps).unwrap(); + client.buy(&trader, &market_id, &Outcome::Yes, &paid, &0_i128); + + // Basis is the *net* collateral: the fee is the protocol's and is never + // refunded, so the pot stays exactly `sets_minted`. + assert_eq!(client.get_cost_basis(&market_id, &trader), q.net); + assert_eq!(client.get_cost_basis(&market_id, &creator), TEST_SEED); + assert_eq!(client.get_basis_total(&market_id), TEST_SEED + q.net); + assert_eq!(client.get_market(&market_id).sets_minted, TEST_SEED + q.net); + + client.void_market(&market_id); + let snapshot = client.get_void_snapshot(&market_id).unwrap(); + assert_eq!(snapshot.pot, TEST_SEED + q.net); + assert_eq!(snapshot.basis_total, snapshot.pot); + + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let before = token.balance(&trader); + assert_eq!(client.redeem(&trader, &market_id), q.net); + assert_eq!(token.balance(&trader), before + q.net); + // The refunded entry is gone with the position. + assert!(!has_entry( + &env, + &client, + DataKey::CostBasis(market_id, trader.clone()) + )); + assert_eq!(client.get_basis_total(&market_id), TEST_SEED); + } + + #[test] + fn partial_sell_reduces_basis_by_gross() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market_policy(&env, &client, &creator, 5000, VoidPolicy::CostBasis, 23); + let buy_in = 20 * 10_000_000_i128; + let (rb, ro, fee_bps) = quote(&client, market_id, &Outcome::Yes); + let qb = math::calc_buy(rb, ro, buy_in, fee_bps).unwrap(); + client.buy(&trader, &market_id, &Outcome::Yes, &buy_in, &0_i128); + + let sell_out = 5 * 10_000_000_i128; + let (rs, ro2, _) = quote(&client, market_id, &Outcome::Yes); + let qs = math::calc_sell(rs, ro2, sell_out, fee_bps).unwrap(); + client.sell(&trader, &market_id, &Outcome::Yes, &sell_out, &qs.shares_in); + + // Minus the *gross* the pool burned, not a share-proportional slice: + // that is what keeps Σ basis + LP == sets_minted exact. + assert_eq!( + client.get_cost_basis(&market_id, &trader), + qb.net - qs.gross + ); + assert_eq!(qs.gross, sell_out + qs.fee); + assert_eq!( + client.get_market(&market_id).sets_minted, + TEST_SEED + qb.net - qs.gross + ); + assert_eq!( + client.get_basis_total(&market_id), + TEST_SEED + qb.net - qs.gross + ); + assert!(position_total(&client, market_id, &trader) > 0); + } + + #[test] + fn negative_basis_holder_gets_zero() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let other = Address::generate(&env); + // YES opens at 10%, so the trader's entry is cheap; `other` then buys + // heavily and the trader exits *half* their position at a profit, which + // is what pushes a basis negative while shares remain. + let market_id = new_market_policy(&env, &client, &creator, 1000, VoidPolicy::CostBasis, 24); + StellarAssetClient::new(&env, &collateral).mint(&other, &(1_000 * 10_000_000_i128)); + + let buy_in = 20 * 10_000_000_i128; + client.buy(&trader, &market_id, &Outcome::Yes, &buy_in, &0_i128); + let net = client.get_cost_basis(&market_id, &trader); + client.buy( + &other, + &market_id, + &Outcome::Yes, + &(300 * 10_000_000_i128), + &0_i128, + ); + + let held = client.get_position(&market_id, &trader).yes; + let half = held / 2; + let (rs, ro, fee_bps) = quote(&client, market_id, &Outcome::Yes); + let sell_out = math::collateral_out_for_shares(rs, ro, half, fee_bps).unwrap(); + let qs = math::calc_sell(rs, ro, sell_out, fee_bps).unwrap(); + client.sell(&trader, &market_id, &Outcome::Yes, &sell_out, &qs.shares_in); + + assert!(qs.gross > net, "the sell must exceed what was paid in"); + let basis = client.get_cost_basis(&market_id, &trader); + assert!(basis < 0); + assert!(client.get_position(&market_id, &trader).yes > 0); + + client.void_market(&market_id); + // Still holds shares, still gets nothing: they already took more out of + // the pool than they put in. + assert_eq!(client.redeem(&trader, &market_id), 0); + let snapshot = client.get_void_snapshot(&market_id).unwrap(); + assert!(snapshot.basis_total <= snapshot.pot); + } + + #[test] + fn redeem_order_independent_under_cost_basis() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let other = Address::generate(&env); + StellarAssetClient::new(&env, &collateral).mint(&other, &(1_000 * 10_000_000_i128)); + // Indexed by holder, so the two runs can be compared slot by slot no + // matter which order the redemptions were issued in. + let people = [trader.clone(), other.clone(), creator.clone()]; + + let mut run = |reverse: bool, salt: u8, out: &mut [i128; 3]| { + let market_id = + new_market_policy(&env, &client, &creator, 5000, VoidPolicy::CostBasis, salt); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(20 * 10_000_000_i128), + &0_i128, + ); + client.buy( + &other, + &market_id, + &Outcome::No, + &(10 * 10_000_000_i128), + &0_i128, + ); + client.void_market(&market_id); + let snapshot = client.get_void_snapshot(&market_id).unwrap(); + assert_eq!(snapshot.pot, client.get_market(&market_id).sets_minted); + let order = if reverse { [2, 1, 0] } else { [0, 1, 2] }; + for index in order { + out[index] += client.redeem(&people[index], &market_id); + } + // The creator is also the LP; their residual claim is order-free + // too, because the snapshot formula never reads live balances. + out[2] += client.claim_pool_remainder(&market_id); + }; + + let mut forwards = [0_i128; 3]; + let mut backwards = [0_i128; 3]; + run(false, 25, &mut forwards); + run(true, 26, &mut backwards); + assert_eq!(forwards, backwards); + assert!(forwards[0] > 0 && forwards[1] > 0); + } + + #[test] + fn lp_receives_residual_after_cost_basis_void() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_market_policy(&env, &client, &creator, 6800, VoidPolicy::CostBasis, 27); + let buy_in = 20 * 10_000_000_i128; + let (rb, ro, fee_bps) = quote(&client, market_id, &Outcome::Yes); + let qb = math::calc_buy(rb, ro, buy_in, fee_bps).unwrap(); + client.buy(&trader, &market_id, &Outcome::Yes, &buy_in, &0_i128); + + // The trader exits completely at a loss: their unrecovered basis has to + // go to the LP, because the position (and its entry) is deleted. + let held = client.get_position(&market_id, &trader).yes; + let (rs, ro2, _) = quote(&client, market_id, &Outcome::Yes); + let sell_out = math::collateral_out_for_shares(rs, ro2, held, fee_bps).unwrap(); + let qs = math::calc_sell(rs, ro2, sell_out, fee_bps).unwrap(); + client.sell(&trader, &market_id, &Outcome::Yes, &sell_out, &qs.shares_in); + let unrecovered = qb.net - qs.gross; + assert!(unrecovered > 0); + assert_eq!(position_total(&client, market_id, &trader), 0); + assert!(!has_entry( + &env, + &client, + DataKey::CostBasis(market_id, trader.clone()) + )); + + client.void_market(&market_id); + let snapshot = client.get_void_snapshot(&market_id).unwrap(); + assert_eq!(snapshot.basis_total, TEST_SEED); + assert_eq!(snapshot.pot, TEST_SEED + unrecovered); + + // The LP claims FIRST: the snapshot formula does not read live + // balances, so it cannot take the creator's own holder refund either. + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let before = token.balance(&creator); + assert_eq!(client.claim_pool_remainder(&market_id), unrecovered); + assert_eq!(token.balance(&creator), before + unrecovered); + assert_eq!(client.redeem(&creator, &market_id), TEST_SEED); + } + + #[test] + fn cost_basis_void_solvent_under_random_sequences() { + // Same tiny LCG as `math::tests::k_never_decreases_and_round_trip_never_profits` + // so the sequence is reproducible. 5 holders, 500 random buys and sells, + // then a void. The solvency proof is partly structural: a payout the + // contract cannot fund would trap the host and fail this test, so + // reaching the assertions at all already shows every transfer settled. + let (env, client, creator, trader, _, collateral) = setup_market(); + let holders = [ + trader.clone(), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + let bank = StellarAssetClient::new(&env, &collateral); + for holder in holders.iter().skip(1) { + bank.mint(holder, &(10_000 * 10_000_000_i128)); + } + let market_id = new_market_policy(&env, &client, &creator, 6800, VoidPolicy::CostBasis, 28); + + let mut x: u64 = 0x9E37_79B9_7F4A_7C15; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (x >> 33) as i128 + }; + let mut basis = [0_i128; 5]; + for _ in 0..500 { + let i = (next() % 5) as usize; + let outcome = if next() % 2 == 0 { + Outcome::Yes + } else { + Outcome::No + }; + let (r_bought, r_other, fee_bps) = quote(&client, market_id, &outcome); + if next() % 3 == 0 { + let held = match outcome { + Outcome::Yes => client.get_position(&market_id, &holders[i]).yes, + Outcome::No => client.get_position(&market_id, &holders[i]).no, + }; + if held <= 1 { + continue; + } + let shares = 1 + (next() % held); + let Ok(sell_out) = + math::collateral_out_for_shares(r_bought, r_other, shares, fee_bps) + else { + continue; + }; + let qs = math::calc_sell(r_bought, r_other, sell_out, fee_bps).unwrap(); + match client.try_sell(&holders[i], &market_id, &outcome, &sell_out, &qs.shares_in) { + Ok(Ok(_)) => { + basis[i] -= qs.gross; + if position_total(&client, market_id, &holders[i]) == 0 { + // mirrors `add_basis`: an emptied position takes its + // entry (and its positive part of the total) with it + basis[i] = 0; + } + } + _ => {} + } + } else { + let amount = 1 + next() % (10 * 10_000_000); + let qb = math::calc_buy(r_bought, r_other, amount, fee_bps).unwrap(); + match client.try_buy(&holders[i], &market_id, &outcome, &amount, &qb.shares_out) { + Ok(Ok(_)) => basis[i] += qb.net, + _ => {} + } + } + // The on-chain sidecar must agree with the model at every step. + assert_eq!(client.get_cost_basis(&market_id, &holders[i]), basis[i]); + } + + let mut positive_total = TEST_SEED; // the creator's basis is the seed + for holder_basis in basis.iter() { + if *holder_basis > 0 { + positive_total += *holder_basis; + } + } + assert_eq!(client.get_basis_total(&market_id), positive_total); + + let pot = client.get_market(&market_id).sets_minted; + client.void_market(&market_id); + let snapshot = client.get_void_snapshot(&market_id).unwrap(); + assert_eq!(snapshot.pot, pot); + assert_eq!(snapshot.basis_total, positive_total); + + let mut payouts = [0_i128; 5]; + let mut paid = 0_i128; + for i in 0..5 { + if position_total(&client, market_id, &holders[i]) == 0 { + continue; + } + payouts[i] = client.redeem(&holders[i], &market_id); + assert!(payouts[i] >= 0 && payouts[i] <= basis[i].max(0)); + paid += payouts[i]; + } + let creator_payout = client.redeem(&creator, &market_id); + paid += creator_payout; + paid += client.claim_pool_remainder(&market_id); + + // The invariant #21 exists to guarantee: holder refunds plus the LP + // residual never exceed the collateral the market actually holds. + assert!(paid <= pot, "void paid {paid} out of a pot of {pot}"); + assert!(creator_payout <= TEST_SEED); + } + + // ----- #22: event-driven early close ------------------------------------ + + /// 10 USDC, the size every #22 test trades. + const BUY_10: i128 = 10 * 10_000_000; + + /// A market whose nominal close is `close_in` seconds away. `new_market` + /// fixes it at 60_000s, which is shorter than `MAX_BACKDATE_SECS`; the + /// backdating tests need a market that is still open days later. + fn new_market_long_close( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + close_in: u64, + salt: u8, + ) -> u64 { + client.create_market( + creator, + &BytesN::from_array(env, &[salt; 32]), + &BytesN::from_array(env, &[salt.wrapping_add(100); 32]), + &String::from_str(env, "ipfs://early-close"), + &Category::Crypto, + &(env.ledger().timestamp() + close_in), + &TEST_SEED, + &5000u32, + ) + } + + #[test] + fn halt_trading_blocks_buys_after_effective_time() { + let (env, client, creator, trader, _, _) = setup_market(); + let start = env.ledger().timestamp(); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy(&trader, &market_id, &Outcome::Yes, &BUY_10, &0_i128); + + // The event happens 500s in; the resolver finalises its early-close + // flow 500s after that, so the halt is recorded at now = start + 1_000 + // with effective_at = start + 500. + env.ledger().set_timestamp(start + 1_000); + client.halt_trading(&market_id, &(start + 500)); + + assert_eq!(client.get_early_close(&market_id), Some(start + 500)); + assert_eq!(client.get_effective_close(&market_id), start + 500); + // `close_time` is untouched: `Market`'s XDR is frozen, so the halt only + // lives in the sidecar and the nominal close stays reportable. + assert_eq!(client.get_market(&market_id).close_time, start + 60_000); + // A halt closes trading, it does not resolve anything. + assert_eq!(client.get_market(&market_id).status, MarketStatus::Open); + + // Every later trade is refused even though `close_time` is 59_000s away. + assert_eq!( + client.try_buy(&trader, &market_id, &Outcome::Yes, &BUY_10, &0_i128), + Err(Ok(Error::MarketClosed)) + ); + assert_eq!( + client.try_sell(&trader, &market_id, &Outcome::Yes, &BUY_10, &0_i128), + Err(Ok(Error::MarketClosed)) + ); + // `buy_v2` / `sell_v2` share `do_buy` / `do_sell`, so they inherit it. + assert_eq!( + client.try_buy_v2( + &trader, + &market_id, + &Outcome::Yes, + &BUY_10, + &0_i128, + &0u32, + &(start + 5_000u64) + ), + Err(Ok(Error::MarketClosed)) + ); + } + + #[test] + fn halt_trading_rejects_future_or_after_close() { + let (env, client, creator, trader, _, _) = setup_market(); + let start = env.ledger().timestamp(); + // 40 days out, so a 7-day backdate is still comfortably before the close. + let close_in = 40 * 86_400; + let market_id = new_market_long_close(&env, &client, &creator, close_in, 31); + client.buy(&trader, &market_id, &Outcome::Yes, &BUY_10, &0_i128); + env.ledger().set_timestamp(start + 1_000); + let now = start + 1_000; + + // Never future-dated: a halt records a fact, it cannot schedule one. + assert_eq!( + client.try_halt_trading(&market_id, &(now + 1)), + Err(Ok(Error::InvalidCloseTime)) + ); + // Cannot move the close later than itself: recorded after the nominal + // close has passed, `effective_at` still has to be at or before it. + let close_time = start + close_in; + env.ledger().set_timestamp(close_time + 10); + assert_eq!( + client.try_halt_trading(&market_id, &(close_time + 5)), + Err(Ok(Error::InvalidCloseTime)) + ); + // Backdating is bounded by MAX_BACKDATE_SECS. + env.ledger().set_timestamp(start + 10 * 86_400); + assert_eq!( + client.try_halt_trading(&market_id, &(start + 1)), + Err(Ok(Error::HaltTooOld)) + ); + // Exactly at the boundary is fine, and `effective_at == now` is too. + let at = env.ledger().timestamp() - MAX_BACKDATE_SECS; + client.halt_trading(&market_id, &at); + assert_eq!(client.get_effective_close(&market_id), at); + assert_eq!( + client.try_halt_trading(&market_id, &at), + Err(Ok(Error::AlreadyHalted)) + ); + // A halt is permanent: the effective close stays at the first one and + // `close_time` still dominates the nominal view. + assert_eq!(client.get_early_close(&market_id), Some(at)); + assert_eq!(client.get_market(&market_id).close_time, close_time); + } + + #[test] + fn resolve_allowed_after_early_close() { + let (env, client, creator, trader, _, _) = setup_market(); + let start = env.ledger().timestamp(); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy(&trader, &market_id, &Outcome::Yes, &BUY_10, &0_i128); + env.ledger().set_timestamp(start + 1_000); + + // Before the halt the original rule still bites. + assert_eq!( + client.try_resolve(&market_id, &Outcome::Yes), + Err(Ok(Error::NotClosedYet)) + ); + + client.halt_trading(&market_id, &(start + 500)); + // The halt is final the moment it is written (resolver-only, no lift), + // so resolution unlocks immediately. + client.resolve(&market_id, &Outcome::Yes); + assert_eq!( + client.get_market(&market_id).status, + MarketStatus::Resolved(Outcome::Yes) + ); + // A resolved market has nothing left to halt. `finalize_early_close` + // racing a guardian void lands here too, which is why the resolver can + // treat a failed halt as harmless. + assert_eq!( + client.try_halt_trading(&market_id, &(start + 500)), + Err(Ok(Error::MarketNotOpen)) + ); + } + + #[test] + fn void_market_still_works_after_a_halt() { + let (env, client, creator, trader, _, _) = setup_market(); + let start = env.ledger().timestamp(); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy(&trader, &market_id, &Outcome::Yes, &BUY_10, &0_i128); + env.ledger().set_timestamp(start + 1_000); + client.halt_trading(&market_id, &(start + 500)); + + // Halting is not resolving: the market stays Open, so the resolver can + // still call it invalid afterwards. + client.void_market(&market_id); + assert_eq!(client.get_market(&market_id).status, MarketStatus::Void); + let pos = client.get_position(&market_id, &trader); + assert_eq!(client.redeem(&trader, &market_id), (pos.yes + pos.no) / 2); + } + + #[test] + fn wrong_caller_cannot_halt_trading() { + let (env, client, creator, trader, resolver, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + let at = env.ledger().timestamp(); + let attacker = Address::generate(&env); + + // `halt_trading` authorises the *resolver address*, exactly like + // `resolve`, so no other key can produce the auth it needs — not the + // admin, not the creator, not a trader. + env.set_auths(&[]); + for signer in [ + attacker.clone(), + trader.clone(), + creator.clone(), + resolver.clone(), + ] + .iter() + .enumerate() + { + let (index, address) = signer; + let result = client + .mock_auths(&[MockAuth { + address, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "halt_trading", + args: (market_id, at).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_halt_trading(&market_id, &at); + if index == 3 { + // The resolver key is the one path that must succeed. + assert_eq!(result, Ok(Ok(()))); + } else { + assert!(result.is_err(), "non-resolver {index} could halt trading"); + assert_eq!(client.get_early_close(&market_id), None); + } + } + } + + #[test] + fn last_trade_at_updated() { + let (env, client, creator, trader, _, _) = setup_market(); + let start = env.ledger().timestamp(); + let market_id = new_market(&env, &client, &creator, 5000); + // Seeding the pool is not a trade. + assert_eq!(client.get_last_trade_at(&market_id), 0); + + env.ledger().set_timestamp(start + 10); + client.buy(&trader, &market_id, &Outcome::Yes, &BUY_10, &0_i128); + assert_eq!(client.get_last_trade_at(&market_id), start + 10); + + env.ledger().set_timestamp(start + 20); + let held = client.get_position(&market_id, &trader).yes; + let (r_sold, r_other, fee_bps) = quote(&client, market_id, &Outcome::Yes); + let out = math::collateral_out_for_shares(r_sold, r_other, held / 2, fee_bps).unwrap(); + let qs = math::calc_sell(r_sold, r_other, out, fee_bps).unwrap(); + client.sell(&trader, &market_id, &Outcome::Yes, &out, &qs.shares_in); + assert_eq!(client.get_last_trade_at(&market_id), start + 20); + + // A rejected trade must not move it: the market is closed by then, and + // the write happens after every check that can fail. + env.ledger().set_timestamp(start + 30); + client.halt_trading(&market_id, &(start + 25)); + // `env.events().all()` only reports the *last* invocation, so this is + // the halt's own event count: `TradingHalted` publishes the whole + // disclosure window — effective_at, halted_at, close_time, last_trade_at + // — in that single event, so an indexer never has to re-read the market. + assert_eq!(env.events().all().events().len(), 1); + assert_eq!(client.get_last_trade_at(&market_id), start + 20); + assert_eq!( + client.try_buy(&trader, &market_id, &Outcome::Yes, &BUY_10, &0_i128), + Err(Ok(Error::MarketClosed)) + ); + assert_eq!(client.get_last_trade_at(&market_id), start + 20); + } + + #[test] + fn unhalted_markets_never_touch_the_early_close_keys() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + assert!(!has_entry(&env, &client, DataKey::EarlyClose(market_id))); + assert_eq!(client.get_early_close(&market_id), None); + // The compatibility claim in one line: an un-halted market's effective + // close *is* its close_time, so `assert_tradable` and `resolve` compare + // against the same number they did before this change. + assert_eq!( + client.get_effective_close(&market_id), + client.get_market(&market_id).close_time + ); + client.buy(&trader, &market_id, &Outcome::Yes, &BUY_10, &0_i128); + assert!(has_entry(&env, &client, DataKey::LastTrade(market_id))); + assert!(!has_entry(&env, &client, DataKey::EarlyClose(market_id))); + } } // --------------------------------------------------------------------------- diff --git a/oracledesk-stellar/contracts/market-core/src/math.rs b/oracledesk-stellar/contracts/market-core/src/math.rs index ebb56b0..4296f57 100644 --- a/oracledesk-stellar/contracts/market-core/src/math.rs +++ b/oracledesk-stellar/contracts/market-core/src/math.rs @@ -328,6 +328,43 @@ pub fn settle_value(kind: Settlement, yes: i128, no: i128) -> i128 { } } +/// One holder's refund when a `CostBasis` market is voided (issue #21). +/// +/// `basis` is the holder's signed net collateral in the pool, `pot` the +/// complete sets frozen at void time and `total` the sum of the *positive* +/// bases. A holder who took more out than they paid in (negative basis) is +/// refunded nothing even though they still hold shares, and a pot smaller than +/// the positive bases scales everybody down pro-rata. +/// +/// Always floors, so `Σ cost_basis_refund(..) <= min(total, pot)`: the pot can +/// never be over-drawn, and the dust that flooring leaves behind stays in the +/// pool (bounded by one base unit per redeeming holder). +pub fn cost_basis_refund(basis: i128, pot: i128, total: i128) -> Result { + if pot < 0 || total < 0 { + return Err(MathError::InvalidInput); + } + let positive = if basis > 0 { basis } else { 0 }; + if positive == 0 || pot == 0 { + return Ok(0); + } + if total <= pot { + return Ok(positive); + } + mul_div_floor(positive, pot, total) +} + +/// The LP's share of a `CostBasis` void pot (issue #21): whatever the pot has +/// left once every holder's (unscaled) positive basis has been paid. Paired +/// with [`cost_basis_refund`] this pays out `min(total, pot)` to holders plus +/// `pot - min(total, pot)` to the LP, i.e. exactly `pot`, so holder payouts +/// plus the LP residual can never exceed `sets_minted`. +pub fn cost_basis_lp_residual(pot: i128, total: i128) -> Result { + if pot < 0 || total < 0 { + return Err(MathError::InvalidInput); + } + Ok(pot - total.min(pot)) +} + #[cfg(test)] mod tests { extern crate std; @@ -558,4 +595,69 @@ mod tests { price_yes_bps(1_100, 910).unwrap() ); } + + // ----- #21 cost-basis void refunds -------------------------------------- + + #[test] + fn cost_basis_refund_exact_when_pot_sufficient() { + // pot >= positive total: everybody gets exactly what they put in and + // the LP takes the rest. `20 + 30 == 50 <= pot`. + assert_eq!(cost_basis_refund(20, 100, 50).unwrap(), 20); + assert_eq!(cost_basis_refund(30, 100, 50).unwrap(), 30); + assert_eq!(cost_basis_lp_residual(100, 50).unwrap(), 50); + // The boundary case `total == pot` is still "sufficient": no scaling. + assert_eq!(cost_basis_refund(50, 50, 50).unwrap(), 50); + assert_eq!(cost_basis_lp_residual(50, 50).unwrap(), 0); + // Basis is signed: a holder who already took more out than they paid + // in is refunded nothing, and the residual is not reduced by it. + assert_eq!(cost_basis_refund(-1, 100, 50).unwrap(), 0); + assert_eq!(cost_basis_refund(0, 100, 50).unwrap(), 0); + } + + #[test] + fn cost_basis_refund_never_exceeds_pot() { + // pot < positive total (possible because negative bases pull the + // signed sum down while `total` only counts the positive ones): scale + // pro-rata, flooring. + assert_eq!(cost_basis_refund(60, 100, 150).unwrap(), 40); + assert_eq!(cost_basis_refund(90, 100, 150).unwrap(), 60); + assert_eq!(cost_basis_lp_residual(100, 150).unwrap(), 0); + // An empty pot pays nobody, and an empty basis total pays the LP all. + assert_eq!(cost_basis_refund(500, 0, 500).unwrap(), 0); + assert_eq!(cost_basis_refund(500, 100, 0).unwrap(), 0); + assert_eq!(cost_basis_lp_residual(100, 0).unwrap(), 100); + // Negative pot / total are nonsense, not a way to mint collateral. + assert_eq!(cost_basis_refund(1, -1, 1), Err(MathError::InvalidInput)); + assert_eq!(cost_basis_refund(1, 1, -1), Err(MathError::InvalidInput)); + assert_eq!(cost_basis_lp_residual(-1, 1), Err(MathError::InvalidInput)); + + // Property: over arbitrary bases, the scaled refunds plus the LP + // residual never exceed the pot, and no refund exceeds its basis. + let mut x: u64 = 0x9E37_79B9_7F4A_7C15; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (x >> 33) as i128 + }; + for _ in 0..2_000 { + let pot = next() % 1_000_000_000; + let total = next() % 1_000_000_000; + let mut sum = 0; + for _ in 0..5 { + let basis = next() % total.max(1); + let payout = cost_basis_refund(basis, pot, total).unwrap(); + assert!(payout >= 0 && payout <= basis.max(0)); + sum += payout; + } + assert!( + sum <= total.min(pot), + "holder refunds {sum} exceed min(total, pot)" + ); + assert!( + sum + cost_basis_lp_residual(pot, total).unwrap() <= pot, + "void over-pays the pot" + ); + } + } } diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index 68e0025..568c056 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -4,8 +4,8 @@ extern crate std; use soroban_sdk::{ - contract, contracterror, contractevent, contractimpl, contracttype, token, xdr::ToXdr, Address, - Bytes, BytesN, Env, Symbol, Vec, + contract, contracterror, contractevent, contractimpl, contracttype, token, vec, xdr::ToXdr, + Address, Bytes, BytesN, Env, IntoVal, Symbol, Val, Vec, }; // market-core is imported from its compiled wasm rather than as a normal @@ -278,6 +278,40 @@ pub fn spec_v2_hash(env: &Env, spec: &ResolutionSpecV2) -> BytesN<32> { env.crypto().sha256(&preimage).into() } +/// Domain tag of the issue #22 commitment, whose preimage is +/// `sha256(EARLY_CLOSE_SPEC_DOMAIN || XDR(SignerConfig) || XDR(EarlyCloseSignerSpec))`. +/// +/// A *third*, disjoint hash space, for the same reason ADR 0004's V2 space is +/// disjoint from the legacy one: every legacy preimage is the XDR of an `ScVal` +/// and therefore starts `00 00 00 10` (`ScValType::Vec`), while this tag starts +/// with ASCII `o`; and it differs from `SPEC_DOMAIN` inside the first 13 bytes, +/// so no preimage can be valid under both tags. `register_signer_spec` and +/// `legacy_spec_hash` are untouched, so every existing spec hash — and every +/// existing golden vector — holds by construction. +pub const EARLY_CLOSE_SPEC_DOMAIN: &str = "oracledesk:early-close-spec"; + +/// How far back an early close may claim the event happened. The same seven days +/// market-core allows `halt_trading` to backdate (`market_core::MAX_BACKDATE_SECS`, +/// equal to `SIGNER_GRACE_SECS`), so a proposal that passes the checks here is +/// never rejected by market-core for staleness. +pub const MAX_BACKDATE_SECS: u64 = SIGNER_GRACE_SECS; + +/// Commitment hash of a signer market that also grants early-close powers +/// (issue #22). `signers` is the *frozen* `oracledesk_types::SignerConfig` the +/// market already commits to for outcome resolution; `early_close` is the +/// resolver-local companion struct. Hashing them together is what makes the +/// grant impossible to spring on traders after the fact. +pub fn signer_with_early_close_hash( + env: &Env, + signers: &SignerConfig, + early_close: &EarlyCloseSignerSpec, +) -> BytesN<32> { + let mut preimage = Bytes::from_slice(env, EARLY_CLOSE_SPEC_DOMAIN.as_bytes()); + preimage.append(&signers.clone().to_xdr(env)); + preimage.append(&early_close.clone().to_xdr(env)); + env.crypto().sha256(&preimage).into() +} + /// A proposed admin handover (#62). Absent when no transfer is pending. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -286,6 +320,60 @@ pub struct PendingAdmin { pub expires_at: u64, } +/// What happens to trades that land between the moment the underlying event +/// occurred and the moment the halt reaches the chain (issue #22). Part of the +/// market's commitment, so traders know it before they trade. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LateTradePolicy { + /// The chosen default. Late trades stand — they cannot be reverted without + /// clawing back settled transfers, which #22 lists as a non-goal — and + /// market-core's `TradingHalted` publishes `[effective_at, halted_at)` plus + /// the market's `last_trade_at` so the backend can flag and reprice them. + AcceptAndDisclose, + /// Reserved for a follow-up: the resolver voids instead of resolving when + /// `last_trade_at > event_time + grace`. Registering a market under it is + /// accepted (the commitment is honest), but nothing acts on it yet, so a + /// creator who wants enforcement today must use `AcceptAndDisclose`. + VoidIfLate, +} + +/// The early-close half of an issue #22 commitment. +/// +/// **Local to this crate on purpose.** The obvious home is a new +/// `ResolutionSpec::SignersWithEarlyClose` variant in `oracledesk-types`, but +/// that crate is the XDR-consensus surface shared by every consumer and is +/// frozen against us here (it is also the crate that fails `cargo +stable check` +/// with 21 pre-existing errors at this tip). So the config lives next to its +/// only consumer, with its own domain tag, exactly the pattern PR #171 proved: +/// legacy `Price`/`Signers` preimages stay byte-identical, and the canonical +/// append into `ResolutionSpec`/`SignerConfig` is named as a maintainer +/// follow-up in the PR. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EarlyCloseSignerSpec { + /// Votes from the market's existing signer group needed to *propose* a halt. + /// Must be `>= SignerConfig.threshold` (anti-grief: freezing a market cannot + /// be easier than resolving one) and `<= signers.len()`. + pub early_close_threshold: u32, + /// How long `EarlyCloseProposed` stays cancellable by the guardian before + /// `finalize_early_close` becomes permissionless. Separate from + /// `SignerConfig.dispute_window` because a halt is only a freeze — it never + /// decides the outcome — so a shorter window is acceptable. + pub early_close_window: u64, + pub late_trade_policy: LateTradePolicy, +} + +/// A pending early close, written when `early_close_threshold` votes agree. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EarlyCloseProposal { + /// The instant the signers claim the underlying event happened; becomes + /// market-core's `effective_at` verbatim. + pub event_time: u64, + pub proposed_at: u64, +} + #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] @@ -335,6 +423,24 @@ pub enum Error { NoPendingAdmin = 42, /// A proposed admin handover was not accepted within its `expires_at`. PendingAdminExpired = 43, + /// #22: the `event_time` a signer proposed cannot be used — it is in the + /// future, older than `MAX_BACKDATE_SECS`, or disagrees with the event time + /// already under vote. Votes must agree exactly (see `propose_early_close`). + EventTimeMismatch = 44, + /// #22: an early close is already proposed (inside its dispute window) or + /// already applied for this market; there is nothing left to propose or + /// finalise. + AlreadyEarlyClosed = 45, + /// #22: market-core rejected `halt_trading` (or could not be reached). The + /// proposal survives so the guardian can investigate or cancel. + HaltRejected = 46, + /// #22: `propose_early_close` at or after the market's effective close, or + /// on a market that is no longer open: halting then changes nothing. + EarlyCloseTooLate = 47, + /// A market-core call failed in the #22 paths. Kept separate from + /// `MarketNotConfigured` (which means "this resolver has no rule for the + /// market") so an unreadable core is never reported as a missing rule. + CoreUnavailable = 48, } #[contracttype] @@ -382,6 +488,32 @@ pub enum DataKey { CountV2(u64, AttestedOutcome), ProposalV2(u64), Snapshot(u64), + // ----- #22 event-driven early close ------------------------------------- + // Append-only, like every other `DataKey` here: the discriminants are + // positional in XDR, so nothing above may move or renumber. All six are + // absent for markets that never committed to early close, which is what + // keeps the legacy paths byte-identical. + /// The market's [`EarlyCloseSignerSpec`], written only by + /// `register_signer_early_close_spec` next to a normal `Signers` rule. + /// Absent = the market has no early-close powers (`WrongMode`). + EarlyCloseSpec(u64), + /// The `event_time` currently under vote, written with the first vote so + /// later votes can be required to match it exactly. Cleared with the + /// proposal. + EarlyCloseEvent(u64), + /// One entry per proposing signer, valued with the `event_time` they voted + /// for: deduplication and agreement in a single read. + EarlyCloseVote(u64, Address), + /// Votes cast for the current early-close round. + EarlyCloseCount(u64), + /// [`EarlyCloseProposal`] once `early_close_threshold` votes agree. + EarlyCloseProposal(u64), + /// The `event_time` that was actually applied to market-core. This is the + /// resolver's own record of the halt, which is what `attest` gates on: + /// reading the effective close back from market-core would need a new + /// market-core view, and the vendored `market_core.wasm` pinned here predates + /// one (it also predates `halt_trading` itself — see `finalize_early_close`). + EarlyCloseApplied(u64), } /// The registered signer group/authority, whichever kind the market committed to. @@ -622,6 +754,58 @@ pub struct AdminTransferred { #[derive(Clone, Debug, Eq, PartialEq)] pub struct AdminTransferCancelled {} +// ----- #22 event-driven early close: appended after every event above ------- + +/// Emitted once per market by `register_signer_early_close_spec`, recording on +/// the event log which early-close authority the commitment actually granted — +/// the disclosure half of the issue's "cannot spring it on traders" rule. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EarlyCloseSpecRegistered { + #[topic] + pub market_id: u64, + pub early_close_threshold: u32, + pub early_close_window: u64, + pub late_trade_policy: LateTradePolicy, +} + +/// `early_close_threshold` signers agreed on `event_time`; the halt can now be +/// cancelled by the guardian until `proposed_at + early_close_window`. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EarlyCloseProposed { + #[topic] + pub market_id: u64, + pub event_time: u64, + pub proposed_at: u64, + pub votes: u32, +} + +/// The guardian blocked a proposal inside its window. Votes are cleared with it, +/// so a fresh quorum is needed to try again (unlike `cancel_proposal`, which +/// leaves the outcome counters in place). +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EarlyCloseCancelled { + #[topic] + pub market_id: u64, + pub event_time: u64, + pub cancelled_at: u64, +} + +/// The halt reached market-core. `effective_at` is what `assert_tradable` and +/// `resolve` now use; the disclosure of trades in `[effective_at, halted_at)` +/// is market-core's `TradingHalted`, which is published in the same call. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EarlyCloseFinalized { + #[topic] + pub market_id: u64, + pub effective_at: u64, + pub halted_at: u64, + pub late_trade_policy: LateTradePolicy, +} + #[soroban_sdk::contractclient(name = "ReflectorPriceClient")] pub trait ReflectorPrice { fn price(env: Env, asset: OracleAsset, timestamp: u64) -> Option; @@ -1543,6 +1727,281 @@ impl Resolver { Ok(proposal.outcome) } + // ----- #22: event-driven early close ------------------------------------ + + /// Reveals the *two-part* commitment of a signer market that also grants + /// early-close powers (issue #22). + /// + /// `resolution_hash` must be `signer_with_early_close_hash(signers, + /// early_close)`, so the creator fixed the halt authority before anybody + /// traded and no later call can add it. The outcome-resolution half is + /// stored through the ordinary `Signers` path, which means `attest`, + /// `finalize_signers`, `dispute`, `expire_market` and the committee modes + /// behave exactly as they do today; only `propose_early_close` and friends + /// read the extra entry. + /// + /// A market committed to the plain `Signers` hash cannot register here (and + /// vice versa): each entrypoint checks its own commitment, so the two + /// authorities are not interchangeable after the fact. + pub fn register_signer_early_close_spec( + env: Env, + market_id: u64, + spec: SignerConfig, + early_close: EarlyCloseSignerSpec, + ) -> Result<(), Error> { + Self::assert_unconfigured(&env, market_id)?; + Self::validate_rule(&ResolutionSpec::Signers(spec.clone()))?; + Self::validate_early_close_spec(&spec, &early_close)?; + let commitment = signer_with_early_close_hash(&env, &spec, &early_close); + // The same check `assert_matches_commitment` performs, but reached + // through `Self::core_market`. The shared helper runs on `core_call`, + // which this file calls 21 times without defining it (it lives in + // `contracts/treasury/src/lib.rs` — a pre-existing breakage of + // `cargo check -p resolver` that this PR discloses rather than repairs). + // Issue #22's paths stay self-contained so they stand on their own. + let market = Self::core_market(&env, market_id)?; + if commitment != market.resolution_hash { + return Err(Error::SpecHashMismatch); + } + Self::store_rule(&env, market_id, &ResolutionSpec::Signers(spec)); + Self::persist(&env, &DataKey::EarlyCloseSpec(market_id), &early_close); + EarlyCloseSpecRegistered { + market_id, + early_close_threshold: early_close.early_close_threshold, + early_close_window: early_close.early_close_window, + late_trade_policy: early_close.late_trade_policy, + } + .publish(&env); + Ok(()) + } + + /// One signer's vote that the underlying event already happened, at + /// `event_time` (issue #22). + /// + /// The event time is an evidentiary claim, so votes must agree *exactly* + /// with the first one cast (the issue asks for "match or take the maximum"; + /// matching is what is auditable — a maximum would let a single late voter + /// silently move refunds, and a signer who believes a different instant is + /// the true one should attest `Invalid` on the outcome instead). One vote + /// per signer, `event_time` within `[now - MAX_BACKDATE_SECS, now]`, and the + /// market must still have a live trading window to shorten. + /// + /// Reaching `early_close_threshold` writes the proposal and starts the + /// guardian's chance to object; it does **not** halt anything yet. + pub fn propose_early_close( + env: Env, + market_id: u64, + signer: Address, + event_time: u64, + ) -> Result<(), Error> { + signer.require_auth(); + let spec = Self::load_early_close_spec(&env, market_id)?; + let cfg: SignerConfig = Self::load(&env, DataKey::Signers(market_id))?; + if !Self::contains(&cfg.signers, &signer) { + return Err(Error::NotSigner); + } + + let storage = env.storage().persistent(); + if storage.has(&DataKey::EarlyCloseApplied(market_id)) + || Self::load_early_close_proposal(&env, market_id).is_some() + { + return Err(Error::AlreadyEarlyClosed); + } + if storage.has(&DataKey::EarlyCloseVote(market_id, signer.clone())) { + // Reuses the outcome path's code: one signature per signer per + // round, for the same reason. + return Err(Error::AlreadyAttested); + } + + let market = Self::core_market(&env, market_id)?; + let now = env.ledger().timestamp(); + // Either the nominal close has passed (nothing left to shorten) or the + // market is no longer open; both are "too late", and a halt against a + // resolved/void market would be rejected by market-core anyway. + if now >= Self::effective_close(&env, market_id, market.close_time) + || market.status != market_core::MarketStatus::Open + { + return Err(Error::EarlyCloseTooLate); + } + if !Self::event_time_is_usable(now, event_time) { + return Err(Error::EventTimeMismatch); + } + + let event_key = DataKey::EarlyCloseEvent(market_id); + if let Some(under_vote) = storage.get::<_, u64>(&event_key) { + if under_vote != event_time { + return Err(Error::EventTimeMismatch); + } + } else { + Self::persist(&env, &event_key, &event_time); + } + Self::persist( + &env, + &DataKey::EarlyCloseVote(market_id, signer), + &event_time, + ); + let count_key = DataKey::EarlyCloseCount(market_id); + let votes: u32 = storage.get(&count_key).unwrap_or(0) + 1; + Self::persist(&env, &count_key, &votes); + + if votes >= spec.early_close_threshold { + let proposal = EarlyCloseProposal { + event_time, + proposed_at: now, + }; + Self::persist(&env, &DataKey::EarlyCloseProposal(market_id), &proposal); + EarlyCloseProposed { + market_id, + event_time, + proposed_at: now, + votes, + } + .publish(&env); + } + Ok(()) + } + + /// Settles an early-close proposal once its dispute window elapsed and asks + /// market-core to record the halt (issue #22). Permissionless, like + /// `finalize_signers`: the votes, not the caller, carry the authority. + /// + /// `halt_trading` is dispatched **by name** through + /// `env.try_invoke_contract`, not through the generated `MarketCoreClient`. + /// The client is built from the `market_core.wasm` vendored in this crate + /// and pinned by sha256, and that blob predates `halt_trading`, so the + /// method simply does not exist in its spec. A dynamic call needs no + /// compile-time interface, which means the resolution works unchanged + /// against a deployed core once somebody with stellar-cli 27.1.0 refreshes + /// the blob — and against today's blob it fails loudly + /// (`HaltRejected`, leaving the proposal intact for the guardian) instead of + /// silently no-op-ing a halt the market never received. + pub fn finalize_early_close(env: Env, market_id: u64) -> Result { + let spec = Self::load_early_close_spec(&env, market_id)?; + let proposal = Self::load_early_close_proposal(&env, market_id).ok_or(Error::NoProposal)?; + if env.ledger().timestamp() < proposal.proposed_at.saturating_add(spec.early_close_window) { + return Err(Error::DisputeWindowOpen); + } + if Self::load_early_close_applied(&env, market_id).is_some() { + return Err(Error::AlreadyEarlyClosed); + } + + let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; + Self::call_halt_trading(&env, &market_core, market_id, proposal.event_time)?; + + // Applied. Remember the instant locally (this is what `attest` now gates + // on) and clear the round so nothing can be re-finalised or re-cancelled. + let storage = env.storage().persistent(); + Self::persist( + &env, + &DataKey::EarlyCloseApplied(market_id), + &proposal.event_time, + ); + storage.remove(&DataKey::EarlyCloseProposal(market_id)); + storage.remove(&DataKey::EarlyCloseEvent(market_id)); + storage.remove(&DataKey::EarlyCloseCount(market_id)); + let cfg: SignerConfig = Self::load(&env, DataKey::Signers(market_id))?; + for signer in cfg.signers.iter() { + storage.remove(&DataKey::EarlyCloseVote(market_id, signer)); + } + EarlyCloseFinalized { + market_id, + effective_at: proposal.event_time, + halted_at: env.ledger().timestamp(), + late_trade_policy: spec.late_trade_policy, + } + .publish(&env); + Ok(proposal.event_time) + } + + /// The guardian blocks a pending early close while its dispute window is + /// still open (issue #22), which is the anti-grief valve: the worst a + /// captured signer majority can do is freeze trading, and that freeze is + /// visible and cancellable before it lands. + /// + /// Unlike `cancel_proposal` this clears the *votes* as well as the proposal + /// (the issue requires it: a stale set of votes for a rejected event time + /// must not count towards the next round), and it deliberately does **not** + /// consume `MAX_CANCELS_PER_MARKET`: that budget bounds churn of an outcome + /// proposal, whereas a guardian refusing a halt cannot strand a market — + /// trading simply continues to `close_time`, and resolution is untouched. + pub fn cancel_early_close( + env: Env, + market_id: u64, + reason: GuardianReason, + ) -> Result<(), Error> { + Self::require_admin(&env)?; + // Same authority as `cancel_proposal`: "a proposal is pending and its + // window is open". No new variant, so no existing consumer changes. + if reason != GuardianReason::DisputeUpheld { + return Err(Error::GuardianNotAllowed); + } + let spec = Self::load_early_close_spec(&env, market_id)?; + let proposal = Self::load_early_close_proposal(&env, market_id).ok_or(Error::NoProposal)?; + if env.ledger().timestamp() >= proposal.proposed_at.saturating_add(spec.early_close_window) + { + return Err(Error::DisputeWindowClosed); + } + + let storage = env.storage().persistent(); + storage.remove(&DataKey::EarlyCloseProposal(market_id)); + storage.remove(&DataKey::EarlyCloseEvent(market_id)); + storage.remove(&DataKey::EarlyCloseCount(market_id)); + let cfg: SignerConfig = Self::load(&env, DataKey::Signers(market_id))?; + for signer in cfg.signers.iter() { + storage.remove(&DataKey::EarlyCloseVote(market_id, signer)); + } + GuardianAction { + market_id, + action: GuardianActionKind::CancelProposal, + reason, + at: env.ledger().timestamp(), + } + .publish(&env); + EarlyCloseCancelled { + market_id, + event_time: proposal.event_time, + cancelled_at: env.ledger().timestamp(), + } + .publish(&env); + Ok(()) + } + + /// The market's early-close authority, `None` when it committed to plain + /// `Signers` (or another mode): then every #22 entrypoint is closed to it. + pub fn get_early_close_spec(env: Env, market_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::EarlyCloseSpec(market_id)) + } + + /// The pending early close, if any. + pub fn get_early_close_proposal(env: Env, market_id: u64) -> Option { + Self::load_early_close_proposal(&env, market_id) + } + + /// Votes cast in the current early-close round. + pub fn get_early_close_votes(env: Env, market_id: u64) -> u32 { + env.storage() + .persistent() + .get(&DataKey::EarlyCloseCount(market_id)) + .unwrap_or(0) + } + + /// The `event_time` that was actually applied to market-core, `None` until + /// `finalize_early_close` succeeds. + pub fn get_applied_early_close(env: Env, market_id: u64) -> Option { + Self::load_early_close_applied(&env, market_id) + } + + /// When this market's trading really ends, as the resolver sees it (issue + /// #22): `min(close_time, applied halt)`. Reads the nominal close through + /// the same generated `get_market` call the attestation path already uses, + /// so it works against the pinned blob. + pub fn get_effective_close(env: Env, market_id: u64) -> Result { + let market = Self::core_market(&env, market_id)?; + Ok(Self::effective_close(&env, market_id, market.close_time)) + } + /// Challenges the current proposal of a `SignersDisputable` market while /// its dispute window is still open (strictly before `proposed_at + /// dispute_window`, the instant `finalize_signers` becomes callable). The @@ -1913,6 +2372,143 @@ impl Resolver { .publish(env); } + // ----- #22 event-driven early close: private helpers -------------------- + + /// The market record, read through the generated client. Issue #22 needs + /// the nominal close and the status; the *effective* close is deliberately + /// not read back from market-core, because the `market_core.wasm` vendored + /// here predates `get_effective_close` — see `DataKey::EarlyCloseApplied`. + fn core_market(env: &Env, market_id: u64) -> Result { + let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; + match MarketCoreClient::new(env, &market_core).try_get_market(&market_id) { + Ok(Ok(market)) => Ok(market), + // Unknown market or unreadable core: either way the caller cannot + // proceed. Kept distinct from `MarketNotConfigured`, which means + // "this resolver holds no rule for the market". + _ => Err(Error::CoreUnavailable), + } + } + + fn load_early_close_spec(env: &Env, market_id: u64) -> Result { + let storage = env.storage().persistent(); + let key = DataKey::EarlyCloseSpec(market_id); + // `WrongMode` is the code every other "this market's authority is a + // different kind" path already returns (`record_attestation` uses it for + // weighted and hybrid markets) and is what issue #22 names for "no + // committed early-close variant". + let spec: EarlyCloseSignerSpec = storage.get(&key).ok_or(Error::WrongMode)?; + storage.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + Ok(spec) + } + + fn load_early_close_proposal(env: &Env, market_id: u64) -> Option { + let storage = env.storage().persistent(); + let key = DataKey::EarlyCloseProposal(market_id); + let proposal: Option = storage.get(&key); + if proposal.is_some() { + storage.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + proposal + } + + fn load_early_close_applied(env: &Env, market_id: u64) -> Option { + let storage = env.storage().persistent(); + let key = DataKey::EarlyCloseApplied(market_id); + let applied: Option = storage.get(&key); + if applied.is_some() { + storage.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + applied + } + + /// The effective-close rule as a pure function, so it is testable on a host + /// that cannot accept a halt (see [`Self::attestation_gate`]). + fn effective_close_of(nominal_close: u64, applied: Option) -> u64 { + match applied { + // Clamped: a halt may only move the close earlier, so a corrupt or + // later value can never extend a market's life. + Some(at) if at < nominal_close => at, + _ => nominal_close, + } + } + + fn effective_close(env: &Env, market_id: u64, nominal_close: u64) -> u64 { + let applied = Self::load_early_close_applied(env, market_id); + Self::effective_close_of(nominal_close, applied) + } + + /// `attest`'s timing gate (issue #22): the market must be past its + /// **effective** close, so an applied early halt unlocks attestation while + /// the nominal `close_time` is still in the future. With no halt recorded + /// locally this is the pre-#22 comparison `now < market.close_time`, + /// unchanged — including its boundary. + /// + /// Note the evidence check further down `record_attestation` still compares + /// `trace.published_at` to the *nominal* close. Leaving it is deliberate: + /// `EvidenceTooEarly` guards against reasoning written before the market was + /// due, and tightening it to an early close would reject traces that are + /// perfectly good evidence for the halt. Widening it is a follow-up. + fn attestation_gate(now: u64, nominal_close: u64, applied: Option) -> Result<(), Error> { + if now < Self::effective_close_of(nominal_close, applied) { + Err(Error::MarketNotClosed) + } else { + Ok(()) + } + } + + /// The window an `event_time` claim may land in: `[now - MAX_BACKDATE_SECS, + /// now]`. The upper bound is what makes the claim a *record* of something + /// that already happened rather than a schedule (market-core enforces the + /// same shape on `halt_trading`'s `effective_at`, so a vote accepted here + /// is never rejected there for being in the future); the lower bound is + /// `MAX_BACKDATE_SECS`, so a halt cannot reach back further than the seven + /// days the signer grace period already tolerates late trading. Pure, so + /// the boundary is testable without a live market-core. + fn event_time_is_usable(now: u64, event_time: u64) -> bool { + event_time <= now && event_time >= now.saturating_sub(MAX_BACKDATE_SECS) + } + + /// `early_close_threshold` may make freezing a market harder than resolving + /// it, never easier (issue #22 §7): the set that can halt trading must be at + /// least as large as the set that can propose an outcome, and it must exist. + fn validate_early_close_spec( + cfg: &SignerConfig, + spec: &EarlyCloseSignerSpec, + ) -> Result<(), Error> { + if spec.early_close_threshold == 0 + || spec.early_close_threshold < cfg.threshold + || spec.early_close_threshold > cfg.signers.len() + || spec.early_close_window == 0 + { + return Err(Error::InvalidConfig); + } + Ok(()) + } + + /// Dynamic `market_core::halt_trading(market_id, effective_at)`; see + /// [`Self::finalize_early_close`] for why the generated client cannot be + /// used. Same name-dispatch pattern as `treasury::core_trade`. + fn call_halt_trading( + env: &Env, + market_core: &Address, + market_id: u64, + effective_at: u64, + ) -> Result<(), Error> { + let args: Vec = vec![env, market_id.into_val(env), effective_at.into_val(env)]; + match env.try_invoke_contract::<(), soroban_sdk::Error>( + market_core, + &Symbol::new(env, "halt_trading"), + args, + ) { + Ok(Ok(_)) => Ok(()), + // Covers "this core has no halt_trading export" (the pinned blob), + // "the market is not open" and "a halt is already applied". The + // proposal survives so the guardian can cancel or a signer can + // retry once the market's state is understood. + _ => Err(Error::HaltRejected), + } + } + fn signer_authority(env: &Env, market_id: u64) -> Result { let storage = env.storage().persistent(); if let Some(cfg) = storage.get(&DataKey::Signers(market_id)) { @@ -2398,9 +2994,19 @@ impl Resolver { // Before any write: this is what keeps an `Invalid` proposal (and so // `void_market`, which has no close-time guard) from existing before // close. - if env.ledger().timestamp() < market.close_time { - return Err(Error::MarketNotClosed); - } + // + // Issue #22: the gate is the market's *effective* close, so signers can + // attest as soon as an early halt has ended trading instead of waiting + // out the nominal `close_time`. The halt is read from this contract's + // own record of applying it (`DataKey::EarlyCloseApplied`), never from + // market-core: the vendored blob has no `early_close` field to decode + // and no `get_effective_close` to call. With no halt applied the + // comparison is `now < market.close_time`, exactly as before. + Self::attestation_gate( + env.ledger().timestamp(), + market.close_time, + Self::load_early_close_applied(env, market_id), + )?; if let Some(trace_id) = evidence { if policy == EvidencePolicy::Disabled { @@ -5087,4 +5693,576 @@ mod golden { let b = DeclaredReversed { beta: 2, alpha: 1 }.to_xdr(&env); assert_eq!(a, b); } + + // ----- #22: event-driven early close, resolver side --------------------- + // + // The halt itself is market-core's (`halt_trading`, covered in + // `contracts/market-core/src/lib.rs`). This section covers the resolver + // half: the commitment that grants the power, the signer quorum that + // exercises it, the guardian's window, and what every other resolver + // entrypoint does once a halt is on the record. + // + // One host limit shapes two of them and is worth stating plainly rather + // than designing around: the `market_core.wasm` vendored next to this crate + // predates `halt_trading` and is pinned by sha256, so `finalize_early_close` + // cannot succeed against it here. Where a test needs the state *after* a + // halt, it performs the same `DataKey::EarlyCloseApplied` write + // `finalize_early_close` performs after the core call, instead of pretending + // the pinned blob can accept one. `finalize_early_close`'s `HaltRejected` + // path is tested directly, because "fails loudly until the blob is + // refreshed" is itself the behaviour being claimed. + + const EC_WINDOW: u64 = 50; + + fn early_close_cfg(threshold: u32) -> EarlyCloseSignerSpec { + EarlyCloseSignerSpec { + early_close_threshold: threshold, + early_close_window: EC_WINDOW, + late_trade_policy: LateTradePolicy::AcceptAndDisclose, + } + } + + /// A market committing to the #22 preimage: signer group *and* early-close + /// authority together, hashed under the resolver's own domain tag. + fn early_close_fixture(early: &EarlyCloseSignerSpec) -> Fixture { + let early = *early; + fixture_with( + |env, _reflector, a, b| ResolutionSpec::Signers(signer_spec(env, a, b)), + move |env, rule, _core| { + let ResolutionSpec::Signers(cfg) = rule else { + unreachable!() + }; + signer_with_early_close_hash(env, cfg, &early) + }, + ) + } + + /// [`early_close_fixture`] with the rule already revealed, which is what + /// every later entrypoint requires. + fn registered_early_close_fixture(early: &EarlyCloseSignerSpec) -> Fixture { + let f = early_close_fixture(early); + let ResolutionSpec::Signers(cfg) = f.spec.clone() else { + unreachable!() + }; + f.resolver + .register_signer_early_close_spec(&f.market_id, &cfg, early); + f + } + + /// `early_close_threshold` votes agreeing on `event_time`, at `now`. + fn reach_early_close_quorum(f: &Fixture, now: u64, event_time: u64) { + f.env.ledger().set_timestamp(now); + f.resolver + .propose_early_close(&f.market_id, &f.signer_a, &event_time); + f.resolver + .propose_early_close(&f.market_id, &f.signer_b, &event_time); + } + + #[test] + fn early_close_commitment_is_a_third_disjoint_hash_space() { + let env = Env::default(); + env.mock_all_auths(); + let a = Address::generate(&env); + let b = Address::generate(&env); + let cfg = signer_spec(&env, &a, &b); + let early = early_close_cfg(2); + + let legacy = legacy_spec_hash(&env, &ResolutionSpec::Signers(cfg.clone())); + let ec = signer_with_early_close_hash(&env, &cfg, &early); + // The grant is never the hash a plain `Signers` market already holds, so + // no existing market can be reinterpreted as early-closable, and no + // existing spec-hash golden moves. + assert_ne!(legacy, ec); + assert_eq!(ec, signer_with_early_close_hash(&env, &cfg, &early)); + + // Every disclosed field is part of the commitment: none of them can be + // swapped after traders have relied on the hash. + assert_ne!( + ec, + signer_with_early_close_hash(&env, &cfg, &early_close_cfg(3)) + ); + let mut void_late = early; + void_late.late_trade_policy = LateTradePolicy::VoidIfLate; + assert_ne!(ec, signer_with_early_close_hash(&env, &cfg, &void_late)); + let mut shorter = early; + shorter.early_close_window = EC_WINDOW - 1; + assert_ne!(ec, signer_with_early_close_hash(&env, &cfg, &shorter)); + } + + #[test] + fn early_close_and_plain_signers_commitments_are_not_interchangeable() { + // A market that committed to plain `Signers` cannot acquire the power. + let f = fixture(|env, _r, a, b| ResolutionSpec::Signers(signer_spec(env, a, b))); + let ResolutionSpec::Signers(cfg) = f.spec.clone() else { + unreachable!() + }; + assert_eq!( + f.resolver.try_register_signer_early_close_spec( + &f.market_id, + &cfg, + &early_close_cfg(2) + ), + Err(Ok(Error::SpecHashMismatch)) + ); + + // And a market that committed to the #22 preimage is not revealed by the + // legacy entrypoint: the two authorities are chosen before creation, not + // negotiated afterwards. + let g = early_close_fixture(&early_close_cfg(2)); + let ResolutionSpec::Signers(gcfg) = g.spec.clone() else { + unreachable!() + }; + assert_eq!( + g.resolver.try_register_signer_spec(&g.market_id, &gcfg), + Err(Ok(Error::SpecHashMismatch)) + ); + } + + #[test] + fn register_early_close_records_the_authority_it_grants() { + let early = early_close_cfg(2); + let f = registered_early_close_fixture(&early); + assert_eq!(f.resolver.get_early_close_spec(&f.market_id), Some(early)); + + // The disclosure half of "cannot spring it on traders": the grant is on + // the event log, not only in storage. + assert_eq!( + f.env.events().all().filter_by_contract(&f.resolver_id), + [soroban_sdk::Event::to_xdr( + &EarlyCloseSpecRegistered { + market_id: f.market_id, + early_close_threshold: 2, + early_close_window: EC_WINDOW, + late_trade_policy: LateTradePolicy::AcceptAndDisclose, + }, + &f.env, + &f.resolver_id + )] + ); + + // One reveal per market, like every other mode. + let ResolutionSpec::Signers(cfg) = f.spec.clone() else { + unreachable!() + }; + assert_eq!( + f.resolver + .try_register_signer_early_close_spec(&f.market_id, &cfg, &early), + Err(Ok(Error::MarketAlreadyConfigured)) + ); + } + + #[test] + fn register_early_close_rejects_configs_that_make_freezing_easier() { + let f = early_close_fixture(&early_close_cfg(2)); + let ResolutionSpec::Signers(cfg) = f.spec.clone() else { + unreachable!() + }; + // The group's `threshold` is 2 and it has 2 members, so anything below + // 2 would let a subset halt trading that could not resolve it, 0 or 3 + // is not a quorum of this group at all, and a window of 0 would skip the + // guardian's chance to object entirely. + for bad in [ + early_close_cfg(0), + early_close_cfg(1), + early_close_cfg(3), + { + let mut w = early_close_cfg(2); + w.early_close_window = 0; + w + }, + ] { + assert_eq!( + f.resolver + .try_register_signer_early_close_spec(&f.market_id, &cfg, &bad), + Err(Ok(Error::InvalidConfig)) + ); + } + assert_eq!(f.resolver.get_early_close_spec(&f.market_id), None); + } + + #[test] + fn early_close_entrypoints_are_closed_without_the_commitment() { + let f = signer_fixture(); + assert_eq!( + f.resolver + .try_propose_early_close(&f.market_id, &f.signer_a, &1_500), + Err(Ok(Error::WrongMode)) + ); + assert_eq!( + f.resolver.try_finalize_early_close(&f.market_id), + Err(Ok(Error::WrongMode)) + ); + assert_eq!( + f.resolver + .try_cancel_early_close(&f.market_id, &GuardianReason::DisputeUpheld), + Err(Ok(Error::WrongMode)) + ); + assert_eq!(f.resolver.get_early_close_spec(&f.market_id), None); + assert_eq!(f.resolver.get_early_close_proposal(&f.market_id), None); + assert_eq!(f.resolver.get_early_close_votes(&f.market_id), 0); + assert_eq!(f.resolver.get_applied_early_close(&f.market_id), None); + // With nothing applied the effective close *is* the nominal one: the + // plain-signer market sees no change anywhere. + assert_eq!(f.resolver.get_effective_close(&f.market_id), 2_000); + } + + #[test] + fn propose_early_close_needs_an_agreeing_quorum_once() { + let early = early_close_cfg(2); + let f = registered_early_close_fixture(&early); + let intruder = Address::generate(&f.env); + assert_eq!( + f.resolver + .try_propose_early_close(&f.market_id, &intruder, &1_400), + Err(Ok(Error::NotSigner)) + ); + + f.env.ledger().set_timestamp(1_500); + f.resolver + .propose_early_close(&f.market_id, &f.signer_a, &1_400); + assert_eq!(f.resolver.get_early_close_votes(&f.market_id), 1); + assert_eq!(f.resolver.get_early_close_proposal(&f.market_id), None); + assert_eq!( + f.resolver + .try_propose_early_close(&f.market_id, &f.signer_a, &1_400), + Err(Ok(Error::AlreadyAttested)), + "one vote per signer per round, as in the outcome path" + ); + + // A vote for a different instant does not move the one under vote, and + // does not count. + assert_eq!( + f.resolver + .try_propose_early_close(&f.market_id, &f.signer_b, &1_450), + Err(Ok(Error::EventTimeMismatch)) + ); + assert_eq!(f.resolver.get_early_close_votes(&f.market_id), 1); + + f.resolver + .propose_early_close(&f.market_id, &f.signer_b, &1_400); + assert_eq!( + f.resolver.get_early_close_proposal(&f.market_id), + Some(EarlyCloseProposal { + event_time: 1_400, + proposed_at: 1_500, + }) + ); + assert_eq!(f.resolver.get_early_close_votes(&f.market_id), 2); + + // Reaching the threshold is a proposal, not a halt: nothing moved in + // market-core and the effective close is unchanged. + assert_eq!(f.resolver.get_applied_early_close(&f.market_id), None); + assert_eq!(f.resolver.get_effective_close(&f.market_id), 2_000); + assert_eq!( + f.resolver + .try_propose_early_close(&f.market_id, &f.signer_a, &1_400), + Err(Ok(Error::AlreadyEarlyClosed)) + ); + + assert_eq!( + f.env.events().all().filter_by_contract(&f.resolver_id), + [ + soroban_sdk::Event::to_xdr( + &EarlyCloseSpecRegistered { + market_id: f.market_id, + early_close_threshold: 2, + early_close_window: EC_WINDOW, + late_trade_policy: LateTradePolicy::AcceptAndDisclose, + }, + &f.env, + &f.resolver_id + ), + soroban_sdk::Event::to_xdr( + &EarlyCloseProposed { + market_id: f.market_id, + event_time: 1_400, + proposed_at: 1_500, + votes: 2, + }, + &f.env, + &f.resolver_id + ), + ] + ); + } + + #[test] + fn propose_early_close_bounds_the_event_time() { + let early = early_close_cfg(2); + let f = registered_early_close_fixture(&early); + f.env.ledger().set_timestamp(1_500); + assert_eq!( + f.resolver + .try_propose_early_close(&f.market_id, &f.signer_a, &1_501), + Err(Ok(Error::EventTimeMismatch)), + "a halt records that an event already happened, it cannot schedule one" + ); + // The boundary: "this very ledger" is a legitimate claim. + f.resolver + .propose_early_close(&f.market_id, &f.signer_a, &1_500); + assert_eq!(f.resolver.get_early_close_votes(&f.market_id), 1); + + // The lower bound cannot be exercised through a fixture whose market + // closes at 2_000 (`now - MAX_BACKDATE_SECS` saturates to 0 well inside + // it), so it is pinned against the pure helper market-core mirrors. + let now = MAX_BACKDATE_SECS + 10_000; + assert!(Resolver::event_time_is_usable(now, now)); + assert!(Resolver::event_time_is_usable(now, now - MAX_BACKDATE_SECS)); + assert!(!Resolver::event_time_is_usable( + now, + now - MAX_BACKDATE_SECS - 1 + )); + assert!(!Resolver::event_time_is_usable(now, now + 1)); + } + + #[test] + fn propose_early_close_is_too_late_once_trading_has_ended() { + let early = early_close_cfg(2); + let f = registered_early_close_fixture(&early); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver + .try_propose_early_close(&f.market_id, &f.signer_a, &1_999), + Err(Ok(Error::EarlyCloseTooLate)), + "shortening a close that already happened changes nothing" + ); + + // A resolved market is rejected for the same reason even before its + // nominal close: there is nothing left to halt. + let g = registered_early_close_fixture(&early); + g.env.ledger().set_timestamp(2_000); + g.resolver + .attest(&g.market_id, &g.signer_a, &AttestedOutcome::No); + g.resolver + .attest(&g.market_id, &g.signer_b, &AttestedOutcome::No); + g.env.ledger().set_timestamp(2_100); + assert_eq!( + g.resolver.finalize_signers(&g.market_id), + AttestedOutcome::No + ); + assert_eq!( + g.resolver + .try_propose_early_close(&g.market_id, &g.signer_a, &2_050), + Err(Ok(Error::EarlyCloseTooLate)) + ); + } + + #[test] + fn finalize_early_close_waits_for_the_window_then_fails_loudly_on_the_pinned_core() { + let early = early_close_cfg(2); + let f = registered_early_close_fixture(&early); + assert_eq!( + f.resolver.try_finalize_early_close(&f.market_id), + Err(Ok(Error::NoProposal)) + ); + + reach_early_close_quorum(&f, 1_500, 1_400); + for at in [1_500, 1_500 + EC_WINDOW - 1] { + f.env.ledger().set_timestamp(at); + assert_eq!( + f.resolver.try_finalize_early_close(&f.market_id), + Err(Ok(Error::DisputeWindowOpen)), + "the guardian's window is the whole point of the two-step" + ); + } + + f.env.ledger().set_timestamp(1_500 + EC_WINDOW); + assert_eq!( + f.resolver.try_finalize_early_close(&f.market_id), + Err(Ok(Error::HaltRejected)), + "`market_core.wasm` pinned here predates `halt_trading`; the dynamic \ + call must reject rather than silently no-op a halt the market never got" + ); + // And it fails *before* writing: the proposal, the votes and the event + // time all survive, so the guardian can cancel or the signers retry + // against a refreshed core. + assert_eq!(f.resolver.get_applied_early_close(&f.market_id), None); + assert_eq!( + f.resolver.get_early_close_proposal(&f.market_id), + Some(EarlyCloseProposal { + event_time: 1_400, + proposed_at: 1_500, + }) + ); + assert_eq!(f.resolver.get_early_close_votes(&f.market_id), 2); + assert_eq!(f.resolver.get_effective_close(&f.market_id), 2_000); + } + + #[test] + fn an_applied_halt_moves_the_effective_close_and_unlocks_attestation() { + let early = early_close_cfg(2); + let f = registered_early_close_fixture(&early); + f.env.ledger().set_timestamp(1_500); + assert_eq!( + f.resolver + .try_attest(&f.market_id, &f.signer_a, &AttestedOutcome::No), + Err(Ok(Error::MarketNotClosed)), + "before any halt, 1_500 is still inside the nominal trading window" + ); + + // The write `finalize_early_close` performs once `halt_trading` returns + // Ok, made directly because the pinned blob cannot return Ok here. + f.env.as_contract(&f.resolver_id, || { + Resolver::persist(&f.env, &DataKey::EarlyCloseApplied(f.market_id), &1_400u64); + }); + assert_eq!(f.resolver.get_effective_close(&f.market_id), 1_400); + assert_eq!( + f.resolver.get_applied_early_close(&f.market_id), + Some(1_400) + ); + + // Same timestamp, now legal: signers settle the outcome as soon as the + // event is known instead of waiting out `close_time` (#22's goal). + f.resolver + .attest(&f.market_id, &f.signer_a, &AttestedOutcome::No); + f.resolver + .attest(&f.market_id, &f.signer_b, &AttestedOutcome::No); + + // The halt is final and one-shot: no re-proposing, no re-finalising. + assert_eq!( + f.resolver + .try_propose_early_close(&f.market_id, &f.signer_a, &1_450), + Err(Ok(Error::AlreadyEarlyClosed)) + ); + assert_eq!( + f.resolver.try_finalize_early_close(&f.market_id), + Err(Ok(Error::NoProposal)) + ); + assert_eq!( + f.resolver + .try_cancel_early_close(&f.market_id, &GuardianReason::DisputeUpheld), + Err(Ok(Error::NoProposal)) + ); + } + + #[test] + fn effective_close_is_a_clamped_minimum_and_gates_attestation() { + assert_eq!(Resolver::effective_close_of(2_000, None), 2_000); + assert_eq!(Resolver::effective_close_of(2_000, Some(1_400)), 1_400); + // A halt may only move the close earlier; a corrupt or later value can + // never extend a market's life, and the boundary is not "earlier than". + assert_eq!(Resolver::effective_close_of(2_000, Some(2_000)), 2_000); + assert_eq!(Resolver::effective_close_of(2_000, Some(3_000)), 2_000); + + assert_eq!( + Resolver::attestation_gate(1_500, 2_000, None), + Err(Error::MarketNotClosed) + ); + assert_eq!(Resolver::attestation_gate(2_000, 2_000, None), Ok(())); + assert_eq!( + Resolver::attestation_gate(1_400, 2_000, Some(1_400)), + Ok(()) + ); + assert_eq!( + Resolver::attestation_gate(1_399, 2_000, Some(1_400)), + Err(Error::MarketNotClosed) + ); + } + + #[test] + fn cancel_early_close_clears_the_whole_round_for_the_guardian() { + let early = early_close_cfg(2); + let f = registered_early_close_fixture(&early); + assert_eq!( + f.resolver + .try_cancel_early_close(&f.market_id, &GuardianReason::DisputeUpheld), + Err(Ok(Error::NoProposal)) + ); + + reach_early_close_quorum(&f, 1_500, 1_400); + f.env.ledger().set_timestamp(1_510); + assert_eq!( + f.resolver + .try_cancel_early_close(&f.market_id, &GuardianReason::UnrevealedSpec), + Err(Ok(Error::GuardianNotAllowed)), + "same authority and the same reason set as `cancel_proposal`" + ); + + f.env.ledger().set_timestamp(1_500 + EC_WINDOW - 1); + f.resolver + .cancel_early_close(&f.market_id, &GuardianReason::DisputeUpheld); + assert_eq!(f.resolver.get_early_close_proposal(&f.market_id), None); + assert_eq!(f.resolver.get_early_close_votes(&f.market_id), 0); + assert_eq!(f.resolver.get_applied_early_close(&f.market_id), None); + assert_eq!(f.resolver.get_effective_close(&f.market_id), 2_000); + + // Votes are cleared with the proposal, unlike `cancel_proposal`'s + // outcome counters: a stale set of votes for a rejected event time must + // not count towards the next round, so a fresh quorum is required. + f.env.ledger().set_timestamp(1_520); + f.resolver + .propose_early_close(&f.market_id, &f.signer_a, &1_510); + assert_eq!(f.resolver.get_early_close_votes(&f.market_id), 1); + assert_eq!(f.resolver.get_early_close_proposal(&f.market_id), None); + } + + #[test] + fn cancel_early_close_after_the_window_is_too_late() { + let early = early_close_cfg(2); + let f = registered_early_close_fixture(&early); + reach_early_close_quorum(&f, 1_500, 1_400); + f.env.ledger().set_timestamp(1_500 + EC_WINDOW); + assert_eq!( + f.resolver + .try_cancel_early_close(&f.market_id, &GuardianReason::DisputeUpheld), + Err(Ok(Error::DisputeWindowClosed)), + "the instant `finalize_early_close` becomes callable is the instant \ + the guardian loses its veto, exactly as in the outcome path" + ); + } + + #[test] + fn wrong_caller_cannot_cancel_early_close() { + let early = early_close_cfg(2); + let f = registered_early_close_fixture(&early); + reach_early_close_quorum(&f, 1_500, 1_400); + f.env.ledger().set_timestamp(1_510); + + let intruder = Address::generate(&f.env); + f.env.set_auths(&[]); + assert!(f + .resolver + .mock_auths(&[MockAuth { + address: &intruder, + invoke: &MockAuthInvoke { + contract: &f.resolver_id, + fn_name: "cancel_early_close", + args: (f.market_id, GuardianReason::DisputeUpheld).into_val(&f.env), + sub_invokes: &[], + } + }]) + .try_cancel_early_close(&f.market_id, &GuardianReason::DisputeUpheld) + .is_err()); + + f.resolver + .mock_auths(&[MockAuth { + address: &f.admin, + invoke: &MockAuthInvoke { + contract: &f.resolver_id, + fn_name: "cancel_early_close", + args: (f.market_id, GuardianReason::DisputeUpheld).into_val(&f.env), + sub_invokes: &[], + }, + }]) + .cancel_early_close(&f.market_id, &GuardianReason::DisputeUpheld); + assert_eq!(f.resolver.get_early_close_proposal(&f.market_id), None); + } + + #[test] + fn void_if_late_is_registerable_and_deliberately_not_yet_enforced() { + let mut early = early_close_cfg(2); + early.late_trade_policy = LateTradePolicy::VoidIfLate; + let f = registered_early_close_fixture(&early); + assert_eq!(f.resolver.get_early_close_spec(&f.market_id), Some(early)); + // The commitment is honest, the behaviour is not: nothing acts on the + // flag yet, so the halt path is byte-identical to the default policy. + reach_early_close_quorum(&f, 1_500, 1_400); + assert_eq!( + f.resolver.get_early_close_proposal(&f.market_id), + Some(EarlyCloseProposal { + event_time: 1_400, + proposed_at: 1_500, + }) + ); + } } diff --git a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md index 51dfdda..c8e7f06 100644 --- a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md +++ b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md @@ -176,3 +176,94 @@ To eliminate unbounded administrative discretion and guarantee credible decentra - `guardian_void_market` can only be invoked after grace periods have expired: `close_time + REVEAL_GRACE_SECS` (3 days) for unrevealed specs, `close_time + ORACLE_GRACE_SECS` (1 day) for failing price feeds, and `close_time + SIGNER_GRACE_SECS` (7 days) for unresponsive signer sets. - Cancellation rate limiting: `cancel_proposal` is strictly capped at `MAX_CANCELS_PER_MARKET = 2` cancellations per market to prevent indefinite griefing. - Audit trail: both `cancel_proposal` and `guardian_void_market` require a typed `GuardianReason` enum (`StaleOracleFeed = 1`, `CompromisedSigner = 2`, `AmbiguousSpec = 3`, `LivenessFailure = 4`, `Other = 5`), emitting structured `GuardianAction` events with execution metadata. + +## Early close: the commitment, the threat model, the late-trade policy (#22) + +Context. A commitment must cover *who may end trading early and on what +evidence*, or a halt can be sprung on traders after they have priced the market. +`halt_trading` alone (market-core) is resolver-only and backdated at most +`MAX_BACKDATE_SECS`, but the authority to call it is delegated by a signer set, +so the delegation needs a commitment of its own. + +Decision. The early-close config is **committed, resolver-local, and hashed in +its own domain tag**: + +``` +signer_with_early_close_hash = sha256( + "oracledesk:early-close-spec" || XDR(SignerConfig) || XDR(EarlyCloseSignerSpec) +) +``` + +`EarlyCloseSignerSpec { early_close_threshold, early_close_window, +late_trade_policy }` lives in `contracts/resolver/src/lib.rs`, not in +`oracledesk-types`, for the same reason PR #171 kept its own config local: that +crate is the XDR-consensus surface shared by every consumer (and is the crate +that fails `cargo +stable check` with 21 pre-existing errors at this tip). The +preimage is a *third* hash space, disjoint from ADR 0001's legacy +`sha256(XDR(ResolutionSpec))` and ADR 0004's V2 `sha256(SPEC_DOMAIN || XDR(...))`: +every legacy preimage is the XDR of an `ScVal` and therefore starts with the +bytes `00 00 00 10`, while both tags start with ASCII `o`, and the two tags +differ within their first 13 bytes, so no preimage can be valid under both. +`register_signer_spec` and `legacy_spec_hash` are untouched, which is why every +existing spec hash and every existing golden vector holds **by construction** +rather than by re-baselining. `scripts/spec-hash` still only ever computes +legacy hashes; delegating the new tag into it is deliberately not done here. + +A market reveals through `register_signer_early_close_spec` or +`register_signer_spec`, never both: each entrypoint checks its own commitment, +so the two authorities are not interchangeable after the fact +(`SpecHashMismatch` either way). + +Vote agreement. `propose_early_close(market_id, signer, event_time)` requires +signer auth, membership in the market's existing signer group, the market still +`Open`, `now` before the effective close, one vote per signer per round, and +**exact agreement** on `event_time` with the vote already under consideration. +#22 allowed "match or take the maximum"; matching is what is auditable. A +maximum would let whichever signer votes latest silently move the refund +boundary for everyone, and a signer who genuinely believes a different instant is +the true one should be attesting `Invalid` on the outcome, not dragging a halt +forward. `event_time` must lie in `[now − MAX_BACKDATE_SECS, now]`: the upper +bound is what makes a halt a record of a fact rather than a schedule, the lower +bound is the same seven days the guardian's `SIGNER_GRACE_SECS` grace already +tolerates. + +Threat model. The worst a captured or mistaken signer majority can do is +**freeze a market early**; it cannot choose the outcome, cannot move collateral, +and cannot resolve anything, because `finalize_signers`, `resolve` and +`void_market` are untouched. Three bounds make that the whole damage: +`early_close_threshold >= SignerConfig.threshold` (freezing is never easier than +resolving, validated at registration, and `<= signers.len()` so a quorum can +exist), `early_close_window > 0` with the halt gated behind it +(`EarlyCloseProposed` is visible before it lands and `cancel_early_close` is the +guardian's veto while it is open), and one halt per market, final and +non-extending (`effective_close` is a clamped `min`, so a corrupt value can never +lengthen a market's life). `cancel_early_close` clears the votes as well as the +proposal, unlike `cancel_proposal`, which leaves outcome counters in place: votes +for a rejected event time must not count towards the next round. It +deliberately does **not** consume `MAX_CANCELS_PER_MARKET`, because that budget +bounds churn of an *outcome*, and a guardian refusing a halt strands nothing — +trading simply continues to `close_time`. + +Late trades. The policy is **accept and disclose**, committed in +`late_trade_policy` and enforced by `TradingHalted`, which publishes +`effective_at`, `halted_at`, `close_time` and `last_trade_at` so the window +`[effective_at, halted_at)` can be flagged and repriced off-chain. Trades in it +are not reverted: they settled against a pool that was still open, and +clawing them back would move collateral belonging to parties who relied on the +market being tradable — the explicit non-goal of #22. *Void-if-late* was +rejected because it converts one signer group's latency into a total loss for +every holder (a void pays half per share, which under `CostBasis` (#21) is a +second-order refund surprise on top of a halt), and because `last_trade_at` +measures the last accepted trade, not every trade in the window, so it cannot +decide a void honestly. `LateTradePolicy::VoidIfLate` remains registerable so a +creator can be honest about intending it, and nothing acts on it yet. + +Follow-up for maintainers, named rather than assumed: the canonical home of +`EarlyCloseSignerSpec` is a `ResolutionSpec::SignersWithEarlyClose` variant in +`oracledesk-types::ResolutionSpec` / `SignerConfig`, which would let +`scripts/spec-hash` and `packages/bindings` see it. That append changes the XDR +every consumer decodes and therefore requires re-rendering the sha256-pinned +`market_core.wasm`/`treasury` blobs and the generated bindings together with it; +neither is possible from this checkout, so it is left as the maintainer step it +has always been. +\n \ No newline at end of file diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index c34e2ea..da033af 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -604,3 +604,134 @@ no admin. deployments (this repo's or a fork's) from trading against the same market-core markets; the treasury's caps bound one agent deployment's own risk, not systemic market behavior. + +## Void refund policies (#21) + +`void_market` has always refunded every share at half a set (`Settlement::Void`, +floor division, so a void never overpays). That is the right default for a +question that turns out to be unanswerable *before anything is known*, but it is +the wrong number for a market voided after the event it described: a holder who +bought Yes at 90 cents on the dollar and is paid 50 has lost 40 to a decision +they had no part in. Markets may now choose which of the two they owe. + +- `VoidPolicy { HalfPerShare, CostBasis }`, stored per market under + `DataKey::VoidPolicy(u64)`. **Absent means `HalfPerShare`.** Every market + created before this change, and every caller of `create_market`, is + therefore bit-for-bit unchanged: no field was added to `Market` or `Position`, + and `create_market`'s signature is untouched. A creator that wants the other + policy calls `create_market_with_policy(...)`, which is the same entrypoint + plus a trailing policy argument. Adding an optional trailing argument to + `create_market` itself would have changed the XDR signature every consumer + (including the sha256-pinned `market_core.wasm` vendored in resolver and + treasury, and `packages/bindings`) decodes, so it is not what happened here. +- **Cost basis is a sidecar, not a field.** `DataKey::CostBasis(u64, Address)` + holds one holder's signed net collateral flow into that market, and + `DataKey::BasisTotal(u64)` holds the sum of the *positive* bases. Both are + updated by the code paths that already move a position, and only when the + market's policy is `CostBasis` (otherwise `add_basis`/`clear_basis` return + immediately, so a `HalfPerShare` market never writes either key): + + | Path | Basis delta | + | --- | --- | + | creator seed (`create_market`) | `+seed_amount` | + | `buy` / `buy_v2` | `+q.net` (collateral in, after fee) | + | `sell` / `sell_v2` | `-q.gross` (collateral out, before fee) | + | `transfer_position` | sliced from the sender, added to the receiver | + | `redeem` | entry removed (the position is gone) | + + A **partial sell reduces basis by `q.gross`, not proportionally.** That is + what keeps the global identity exact, because the pool's `sets_minted` drops + by precisely the same `q.gross`. Consequence, and it looks wrong until you + meet it: a holder who takes out more than they paid in — selling creator seed + shares, or buying cheap and selling into a spike — carries a **negative** + basis, and a negative basis refunds **0** at void even though the holder still + holds shares. They kept their money out of the pool; the pool is not their + counterparty. `get_cost_basis(market_id, holder)` exposes the number so nobody + has to guess from a token balance. + +- **The formula** is pure (`math::cost_basis_refund`), so it is testable without + a chain: + `refund_h = floor(basis_h * pot / basis_total)` clamped to + `[0, basis_h]`, where `pot == m.sets_minted` at void time and `basis_total` + sums only positive bases. The LP then receives + `math::cost_basis_lp_residual(pot, basis_total) = pot − min(basis_total, pot)`. + Together these pay `min(basis_total, pot)` to holders plus the remainder to + the LP, exactly the pot: **`Σ holder payouts + LP residual ≤ sets_minted`**, + which is the property the randomized Rust test + (`cost_basis_void_solvent_under_random_sequences`) asserts over arbitrary + buy/sell/transfer sequences. +- **Rounding and the dust bound.** Every holder payout floors, so no holder is + ever overpaid and the pot can never be overdrawn. The shortfall is strictly + less than one unit per holder with a positive basis — i.e. `< H` stroops for a + market with `H` such holders — and it stays in the pool as the LP residual + (it is included in `pot − min(basis_total, pot)`), not burned. A `pot` of 0 + pays nobody; a `basis_total` of 0 pays the LP the whole pot. +- **Order independence.** `void_market` freezes `VoidSnapshot { pot, basis_total }` + once and emits `CostBasisVoidSnapshotted`. Every later `redeem` and + `claim_pool_remainder` pays from that snapshot and never from live storage, so + the order in which holders redeem cannot change anyone's amount (tested: + A-B-C vs C-B-A). This matters because the *first* redemption of a void market + would otherwise alter `basis_total` for the second. +- **Treasury note.** `reserve_capacity` and the treasury's own exposure + accounting are unaffected — the void pot is the market's collateral either + way. But a treasury `redeem` against a `CostBasis` market returns a different + amount than against a `HalfPerShare` one, and treasury's + `agent_create_market` keeps `HalfPerShare` (see `docs/STATUS.md`); switching + an agent's markets over is a deliberate later decision, not a side effect of + this change. + +## Early close and the late-trade window (#22) + +A "Will X happen by D?" market keeps trading until `D` even after `X` has +happened. Anyone faster than the repricing crowd buys the now-known outcome at a +stale price and the LP absorbs the difference; `resolve` could not help, because +it required `now >= close_time`. Trading may now end at the event instead of at +the deadline. + +- **Effective close.** `effective_close = min(close_time, early_close)`, where + `early_close` is `DataKey::EarlyClose(u64)` — an `Option` sidecar, absent + for every market that was never halted. `assert_tradable` (so `buy`/`sell` and + `buy_v2`/`sell_v2`), `resolve`, and the resolver's attestation gate all read + through it; `close_time` itself remains the *nominal* close, because `Market` + is frozen and consumers that decode it must keep seeing the same bytes. + `get_market` is unchanged; the real date is `get_effective_close`. +- **Halting.** `halt_trading(market_id, effective_at)` is resolver-only (same + `require_resolver` as `resolve`/`void_market`), requires the market to be + `Open`, allows exactly one halt per market, and accepts only + `close_time >= effective_at >= now − MAX_BACKDATE_SECS`. A halt records a fact, + so it can neither schedule a future close (`effective_at > now`) nor push one + later. `MAX_BACKDATE_SECS` is 7 days — the same window `SIGNER_GRACE_SECS` + already grants late signers before the guardian may void an unresponsive + market, so a halt cannot reach back further than the period in which late + trading is already tolerated. Anything older would be un-attestable anyway. +- **The agreed policy for trades in `[effective_at, halted_at)` is *accept and + disclose*.** They happened, they settled, and reverting them would mean + clawing back transfers that other parties already relied on, which #22 lists + as a non-goal. `TradingHalted` therefore publishes the whole window in one + event — `effective_at`, `halted_at`, `close_time`, `last_trade_at` — so the + backend can flag and reprice exactly the trades that landed at a price the + event says is stale. `DataKey::LastTrade(u64)` is written by `do_buy`/`do_sell` + after every check that can fail, so `last_trade_at` is anchored to an accepted + on-chain trade rather than to an indexer's opinion. The rejected alternative — + *void-if-late*, voiding the market when `last_trade_at > effective_at + grace` + — is the reason `LateTradePolicy::VoidIfLate` exists and is registerable today: + a creator may commit to it, and nothing acts on it yet. See + `docs/adr/0001-resolution-commitment.md` for why, and for the threat model. +- **Who may halt, and how far.** The resolver, not the creator and not the + fastest caller: `register_signer_early_close_spec` (a commitment that includes + the early-close authority), `propose_early_close` votes from the market's + existing signer group, `early_close_threshold` agreement opens an + `EarlyCloseProposed` dispute window during which the guardian may + `cancel_early_close` (clearing the proposal *and* the votes), and + `finalize_early_close` — permissionless, like `finalize_signers` — asks + market-core to halt. Freezing a market is never easier than resolving it: + `early_close_threshold >= SignerConfig.threshold` is validated at registration. +- **Adoption limit, stated rather than hidden.** `finalize_early_close` calls + `halt_trading` by name through `env.try_invoke_contract`, because the + `market_core.wasm` vendored in `contracts/resolver/` is pinned by sha256 and + predates the method, so it is absent from the generated client's spec. Against + that blob `finalize_early_close` returns `HaltRejected` and writes nothing, + leaving the proposal cancellable; against a core that has the export the same + code works unchanged. Re-rendering the pinned blob (and `create_market` in + `packages/bindings`) is a `make build` step for someone with a working + stellar-cli, not something this change can fake by editing a binary.