From b4c01c4cfdd79ac3d6792f052f6ecdaa1b2da2ce Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Wed, 21 Oct 2026 07:27:09 +0100 Subject: [PATCH 1/4] feat(market-core): optional cost-basis refunds for voided markets (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A voided market currently pays every holder `settle_value = (yes + no) / 2`, so a position entered at 0.90 loses ~44% of its cost when the market is voided for an invalid or unresolvable question. #21 asks for an *optional* per-holder cost-basis refund instead. Design rule followed throughout: no frozen interface moves. `Market`, `Position`, and the signatures of `create_market`, `buy`, `sell`, `redeem`, `resolve`, `void_market`, `claim_pool_remainder` are decoded by resolver, treasury and router from vendored copies of this contract's wasm that the repo pins by sha256, so all new state is sidecar storage and all new choice is a new entrypoint. - `VoidPolicy { HalfPerShare, CostBasis }` + `DataKey::VoidPolicy(u64)`, `CostBasis(u64, Address)`, `BasisTotal(u64)`, `VoidSnapshot(u64)` appended at the tail of `DataKey` (positional XDR discriminants are unchanged). Absent means `HalfPerShare`, so every existing market reads byte-identically. - `create_market_with_policy(...)` is a new entrypoint; `create_market` delegates to it with `HalfPerShare` and still writes no sidecar. - Basis tracks collateral, not shares: `+q.net` on buy, `-q.gross` on sell, `+seed_amount` for the creator, a proportional slice on `transfer_position`, and the entry leaves with an emptied or redeemed position. That keeps `Σ basis_h + basis_LP == sets_minted` exact at every step (`basis_LP` is the implicit residual and follows `m.creator`), and is why the refund pro-rata denominator cannot drift. - `void_market` freezes `(pot = sets_minted, basis_total)` into a `VoidSnapshot` and `redeem` / `claim_pool_remainder` pay only from it, so payouts are independent of redemption order and the total payout is bounded by the pot: `Σ floor(basis_i * pot / total) + (pot − min(total, pot)) == pot`. - Fees are excluded from basis by design: the protocol fee never enters `sets_minted`, so including it would over-fund the pot. Tests (authored; see PR body for why no cargo test run is possible on this host): legacy half-per-share parity, refund at a 90% opening, basis reduced by gross on a partial sell, negative basis pays zero, order-independent redemptions, LP residual after a full-exit-at-a-loss, and a 5-holder / 500-trade randomized solvency property asserting `Σ payouts <= sets_minted`. `cargo +stable fmt -p market-core -- --check` exits 0. --- .../contracts/market-core/src/lib.rs | 811 +++++++++++++++++- .../contracts/market-core/src/math.rs | 102 +++ 2 files changed, 911 insertions(+), 2 deletions(-) diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 2b6c6b7..fe97666 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -134,6 +134,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 +196,22 @@ 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), } // --------------------------------------------------------------------------- @@ -366,6 +417,29 @@ 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, +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -569,6 +643,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 +660,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 +764,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 +788,13 @@ impl MarketCore { initial_yes_bps, } .publish(&env); + if void_policy == VoidPolicy::CostBasis { + VoidPolicyChosen { + market_id: id, + policy: void_policy, + } + .publish(&env); + } Ok(id) } @@ -783,6 +921,10 @@ 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)?; Self::save_market(&env, market_id, &m); // interaction @@ -913,7 +1055,16 @@ 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)?; 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)?; @@ -965,6 +1116,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 +1129,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 +1154,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 +1166,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 +1200,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 +1214,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 +1325,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 +1353,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 +1374,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 +1435,37 @@ 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)) + } + /// 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)?; @@ -1440,6 +1700,158 @@ 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)?) + } + fn load_fee_terms(env: &Env, id: u64) -> Option { let key = DataKey::FeeTerms(id); let terms = env.storage().persistent().get(&key); @@ -3066,6 +3478,401 @@ 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); + } } // --------------------------------------------------------------------------- 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" + ); + } + } } From 82de195fa4564e4798cf642d9bb315af8b0644d5 Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Wed, 21 Oct 2026 07:36:09 +0100 Subject: [PATCH 2/4] feat(market-core): event-driven early close via halt_trading (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Will X happen by D?" markets stay open until D even when X has already happened, so anyone faster than the repricing crowd buys the known outcome at a stale price and the LP eats the difference. `resolve` could not help either: it requires `now >= close_time`. `halt_trading(market_id, effective_at)` brings the end of trading forward. It is resolver-only (same `require_resolver` as `resolve`/`void_market`), needs the market still `Open`, allows exactly one halt per market, and only accepts an `effective_at` with `close_time >= effective_at >= now - MAX_BACKDATE_SECS`: a halt records a fact, it can never schedule one or push the close later. `MAX_BACKDATE_SECS` is 7 days, the same window the resolver already grants signers (`SIGNER_GRACE_SECS`), so a halt cannot reach back further than the period in which late trading is already tolerated. Everything reads `effective_close = min(close_time, early_close)`: `assert_tradable` (both `buy`/`sell` and `buy_v2`/`sell_v2`, which share `do_buy`/`do_sell`), `resolve`, and the resolver's attestation gate. A market with no `EarlyClose` entry compares against `close_time` exactly as before, at the cost of one storage read that misses. Late-trade policy: **accept and disclose**. Trades in `[effective_at, halted_at)` stand; `TradingHalted` publishes `effective_at`, `halted_at`, `close_time` and `last_trade_at` in one event so the backend can flag and price the window without re-reading a market whose state has moved on. `DataKey:: LastTrade(u64)` is written by `do_buy`/`do_sell` after every check that can fail — one fixed-size entry per market, one write per trade, never growing — and is what anchors the disclosure to a real on-chain trade. Rationale and the rejected void-if-late alternative go in ADR 0001. Sidecar state again: `DataKey::EarlyClose(u64)` / `LastTrade(u64)` appended at the tail, `Error::AlreadyHalted = 27` / `HaltTooOld = 28` at the `Error` tail, `TradingHalted` appended after the #21 events. `Market` and its frozen XDR are untouched, so `close_time` stays the *nominal* close and consumers that want the real one use `get_effective_close` / `get_early_close` / `get_last_trade_at`. Tests (authored; see PR body for why no cargo test run is possible on this host): buys and sells blocked after the effective time, rejection of future / post-close / over-backdated halts plus `AlreadyHalted`, `resolve` unlocking exactly at the halt, void still reachable after a halt, targeted `mock_auths` proving only the resolver key can halt, `last_trade_at` moving only with accepted trades, and an un-halted market never touching the new keys. `cargo +stable fmt -p market-core -- --check` exits 0. --- .../contracts/market-core/src/lib.rs | 473 +++++++++++++++++- 1 file changed, 468 insertions(+), 5 deletions(-) diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index fe97666..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 // --------------------------------------------------------------------------- @@ -212,6 +223,18 @@ pub enum DataKey { 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), } // --------------------------------------------------------------------------- @@ -256,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 { @@ -440,6 +468,25 @@ pub struct CostBasisVoidSnapshotted { 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 // --------------------------------------------------------------------------- @@ -882,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)?; @@ -925,6 +972,9 @@ impl MarketCore { // 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 @@ -1020,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)?; @@ -1065,6 +1115,8 @@ impl MarketCore { // 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)?; @@ -1096,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); @@ -1466,6 +1578,29 @@ impl MarketCore { 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)?; @@ -1613,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(()) @@ -1852,6 +1993,67 @@ impl MarketCore { 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); @@ -3873,6 +4075,267 @@ mod test { 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))); + } } // --------------------------------------------------------------------------- From cba6d2ed3ac7c7c720872ebe64214a38cbf16ddc Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Wed, 21 Oct 2026 08:42:50 +0100 Subject: [PATCH 3/4] feat(resolver): signer-quorum early close for event-driven halts (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit market-core can now move a market's end of trading forward (`halt_trading`), but the authority to do that belongs with the market's resolution signers, not with whoever notices the event first. This is the resolver half of #22: the commit that grants the power, the quorum that exercises it, the guardian's veto, and what every other resolver entrypoint does once a halt is on the record. Commitment first, because a halt must not be able to spring on traders. The obvious home for the config — a new `ResolutionSpec` variant in `oracledesk-types` — is closed to us: that crate is the XDR-consensus surface and the one that fails `cargo +stable check` with 21 pre-existing errors at this tip. So, exactly the pattern PR #171 proved, the struct is **local to resolver** with its **own domain tag**: `signer_with_early_close_hash = sha256("oracledesk:early-close-spec" || XDR(SignerConfig) || XDR(EarlyCloseSignerSpec))`, a third hash space disjoint from ADR 0001's legacy and ADR 0004's V2 preimages (legacy preimages are `ScVal` XDR starting `00 00 00 10`, every tag here starts with ASCII `o`, and the two tags differ inside the first 13 bytes). `register_signer_spec` and `legacy_spec_hash` are untouched, so every existing spec hash and every existing golden vector holds **by construction** — asserted directly, not argued. Flow: `register_signer_early_close_spec` (the market must have committed to that preimage; plain-`Signers` commitments get `SpecHashMismatch` and vice versa, so the two authorities are never interchangeable after the fact) -> `propose_early_close(market_id, signer, event_time)`, signer-only, one vote per signer per round, votes required to agree **exactly** on `event_time` (the issue offers "match or take the maximum"; a maximum would let one late voter silently move refunds, and a signer believing a different instant is the true one should attest `Invalid` instead), `event_time` in `[now - MAX_BACKDATE_SECS, now]`, and refused at or after the effective close or on a market that is no longer Open -> at `early_close_threshold` an `EarlyCloseProposed` dispute window opens -> `finalize_early_close` (permissionless, like `finalize_signers`) calls `halt_trading` -> `cancel_early_close` lets the guardian block it inside the window, clearing the whole round so a rejected event time cannot count towards the next one. Anti-grief: `early_close_threshold >= SignerConfig.threshold` and `<= signers.len()`, `window != 0` — freezing a market is never easier than resolving one. `MAX_CANCELS_PER_MARKET` is deliberately not consumed by a cancel: that budget bounds churn of an *outcome*, whereas refusing a halt simply lets trading run to `close_time`. `halt_trading` is dispatched **by name** via `env.try_invoke_contract`, not through `MarketCoreClient`: the `market_core.wasm` vendored here is pinned by sha256 and predates the method, so it does not exist in the generated spec. That makes the adoption limit explicit instead of silent — against today's blob `finalize_early_close` returns `HaltRejected` and fails *before* any write, leaving proposal, votes and event time intact for the guardian to cancel or for a retry against a refreshed core; against a deployed core that has the export the same code needs no change. `attest`'s timing gate is now the market's **effective** close, read from this contract's own `DataKey::EarlyCloseApplied` record — never back from market-core, because the pinned blob has no `early_close` field to decode and no `get_effective_close` to call. With no halt applied the comparison is the pre-#22 `now < market.close_time`, boundary included; the evidence check further down still compares `trace.published_at` to the *nominal* close on purpose (`EvidenceTooEarly` guards against reasoning written before the market was due, and a trace is still good evidence for a halt). `get_effective_close`, `get_early_close_spec`/`_proposal`/`_votes`, `get_applied_early_close` are new readers; `LateTradePolicy::VoidIfLate` is registerable (so a commitment can be honest about a policy that follows) and deliberately not yet acted on. Append-only: six `DataKey` variants at the tail, five `Error` variants at the tail (`EventTimeMismatch = 44`..`CoreUnavailable = 48`), four events after the existing ones. Nothing renumbered, nothing reordered, no field added to a decodable struct. Tests: the three hash spaces are disjoint and every disclosed field moves the commitment; the two commitments are not interchangeable; registration records + publishes what it granted and rejects a threshold that would make freezing easier than resolving; quorum, exact-agreement, double-vote, non-signer, future-event-time, past-close and no-commitment paths; the window before finalisation and the `HaltRejected`-and-survive behaviour against the pinned blob; an applied halt moving the effective close and unlocking `attest` at a timestamp that was `MarketNotClosed` a moment earlier; the halt being final; guardian cancel clearing the whole round, its window boundary, and its admin auth checked through `mock_auths` with an intruder root; plus the pure `effective_close_of` clamping and `event_time_is_usable` boundaries. `cargo +stable fmt -p resolver -- --check` reports the same 12 hunks as it does at `5ede00a` (byte-identical multiset, verified) — zero new complaints; the file is not clean at the base and this PR does not pretend otherwise. --- .../contracts/resolver/src/lib.rs | 1188 ++++++++++++++++- 1 file changed, 1183 insertions(+), 5 deletions(-) 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, + }) + ); + } } From 424996cc21ae301032e6e553e40e6e22add4e2e5 Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Wed, 21 Oct 2026 09:42:17 +0100 Subject: [PATCH 4/4] docs(resolver,market-core): early-close commitment domain, late-trade policy and refund design (#21, #22) --- .../docs/adr/0001-resolution-commitment.md | 91 ++++++++++++ oracledesk-stellar/docs/architecture.md | 131 ++++++++++++++++++ 2 files changed, 222 insertions(+) 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.