diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 2b6c6b7..04ea440 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -114,6 +114,54 @@ pub struct BandState { pub ref_price_bps: u32, } +/// Why a market was voided (#23). Both variants are unit variants with no +/// explicit discriminants, so the XDR encoding is `ScVal::Symbol("NoFault")` +/// / `ScVal::Symbol("GuardianFault")`. That is what lets the resolver call +/// `void_market_with_reason` through name dispatch without a regenerated +/// vendored client — the encoding is decided by variant *names*, not by +/// spec, so the stale `market_core.wasm` clients simply cannot see the new +/// entrypoint (see `docs/adr/0002-cross-contract-calls.md`). +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VoidReason { + NoFault, + GuardianFault, +} + +/// Lifecycle of a market's creation bond (#23). +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BondState { + None, + Held, + Refunded, + Forfeited, +} + +/// Sidecar entry `DataKey::Bond(id)` holding a market's creation bond (#23), +/// snapshotted at creation as a side entry exactly like `FeeTerms` so +/// `Market`'s XDR shape never changes. +/// +/// Markets created while `get_creation_bond()` was 0 have no `Bond` entry +/// and no bond accounting (`claim_bond` fails with `NoBond`), which makes +/// the upgrade migration-free. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MarketBond { + /// Amount pulled from the creator on top of `seed_amount` at creation. + pub amount: i128, + pub state: BondState, + /// Recorded by the void path that finalized the market. `None` while the + /// market has not been voided; `Some(NoFault)` also on plain + /// `void_market`, which keeps its pre-#23 NoFault semantics. + pub void_reason: Option, + /// Fee recipient snapshotted when a `GuardianFault` void is recorded, so + /// a later `propose_treasury`/`accept_treasury` cannot redirect the + /// forfeit between the void and `claim_bond`. Falls back to the current + /// `DataKey::Treasury` when absent (mirrors the `sweep_fees` legacy path). + pub forfeit_to: Option
, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Config { @@ -161,6 +209,21 @@ pub enum DataKey { Breaker(u64), // temporary storage (expires on its own, one entry per market) Band(u64), + // ----- appended tails (#23/#24) --------------------------------------- + // Strictly append-only: the discriminants of every pre-existing variant + // are unchanged. + // Instance storage: creation-bond default for new markets, absent = 0. + CreationBond, + // Instance storage: integrator-fee cap default for new markets, absent = 0. + MaxIntegratorFeeBps, + // Persistent sidecars; absent = legacy default, same pattern as FeeTerms. + Bond(u64), + /// Per-market snapshot of `MaxIntegratorFeeBps` taken at creation, + /// absent (or legacy market) means 0. + MarketMaxIntegratorFee(u64), + /// Unclaimed integrator fee balances, one entry per integrator address + /// across all markets. + IntegratorFees(Address), } // --------------------------------------------------------------------------- @@ -205,6 +268,14 @@ pub enum Error { NoPendingAdmin = 25, /// A proposed admin handover was not accepted within its `expires_at`. PendingAdminExpired = 26, + /// `buy_via`/`sell_via`/`quote_buy_via`/`quote_sell_via` called with an + /// `integrator_fee_bps` above the market's creation-time snapshot cap. + /// NOTE: PRs #239/#240/#242 each claim 27 on their own branches; the + /// last PR to merge re-appends (append-only tails, collision disclosed). + IntegratorFeeTooHigh = 27, + /// `claim_bond` called on a market with no `Bond(id)` sidecar entry, + /// i.e. created before the creation bond existed (#23). + NoBond = 28, } impl From for Error { @@ -366,6 +437,53 @@ pub struct LpTransferred { pub to: Address, } +/// The global creation-bond default changed. Applies to markets created +/// after the call, never to existing snapshots (#23). +#[contractevent] +pub struct CreationBondChanged { + pub old: i128, + pub new: i128, +} + +/// A creator posted the bond on top of the seed at creation (#23). +#[contractevent] +pub struct BondPosted { + #[topic] + pub market_id: u64, + pub amount: i128, +} + +/// A held bond was settled: refunded to the creator or forfeited to the +/// fee-recipient snapshot taken at void time (#23). +#[contractevent] +pub struct BondSettled { + #[topic] + pub market_id: u64, + pub to: Address, + pub amount: i128, + pub forfeited: bool, +} + +/// A `*_via` trade credited an integrator (#24). Emitted next to (never +/// instead of) `Trade`; the `Trade.fee` field stays the protocol portion so +/// existing consumers keep their meaning. +#[contractevent] +pub struct IntegratorFeeAccrued { + #[topic] + pub market_id: u64, + #[topic] + pub integrator: Address, + pub amount: i128, +} + +/// An integrator withdrew its accrued balance across all markets (#24). +#[contractevent] +pub struct IntegratorFeesClaimed { + #[topic] + pub integrator: Address, + pub amount: i128, +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -564,11 +682,74 @@ impl MarketCore { Ok(()) } + /// Sets the creation bond pulled from every new market's creator on top + /// of the seed (#23). Affects markets created after the call; existing + /// markets keep their snapshot, exactly like `set_default_fee_bps`. + /// 0 disables the bond and is the default (absent entry). + pub fn set_creation_bond(env: Env, amount: i128) -> Result<(), Error> { + Self::require_admin(&env)?; + if !(0..=MAX_SETS).contains(&amount) { + return Err(Error::InvalidAmount); + } + let old = Self::get_creation_bond(env.clone()); + env.storage() + .instance() + .set(&DataKey::CreationBond, &amount); + CreationBondChanged { old, new: amount }.publish(&env); + Ok(()) + } + + /// The creation-bond default new markets snapshot at creation (#23). + /// Absent = 0 (pre-feature deployments and fresh instances). Treasury + /// reads this by name across the vendored-client boundary. + pub fn get_creation_bond(env: Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::CreationBond) + .unwrap_or(0) + } + + /// Sets the global cap new markets snapshot for `integrator_fee_bps` on + /// `buy_via`/`sell_via` (#24). The cap is validated against the current + /// default protocol fee so a market created at the cap can still charge + /// the default fee plus the maximum integrator cut without exceeding + /// `MAX_FEE_BPS` in aggregate. Existing markets keep their snapshot, + /// exactly like the fee/breaker defaults. Absent = 0 (integrator routing + /// disabled until an admin opts in). + pub fn set_max_integrator_fee_bps(env: Env, bps: u32) -> Result<(), Error> { + Self::require_admin(&env)?; + let default_fee: u32 = env + .storage() + .instance() + .get(&DataKey::DefaultFeeBps) + .unwrap_or(0); + if default_fee.checked_add(bps).ok_or(Error::Overflow)? > MAX_FEE_BPS { + return Err(Error::InvalidFee); + } + env.storage() + .instance() + .set(&DataKey::MaxIntegratorFeeBps, &bps); + Ok(()) + } + + /// The global integrator-fee cap new markets snapshot at creation (#24). + pub fn get_max_integrator_fee_bps(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::MaxIntegratorFeeBps) + .unwrap_or(0) + } + // ----- market lifecycle ----------------------------------------------- /// 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. + /// + /// When `get_creation_bond()` is non-zero the creator additionally posts + /// that refundable bond (#23): `seed_amount + bond` leaves the creator + /// in one transfer, and the bond is recorded in the `Bond(id)` sidecar + /// (`DataKey::Bond`), never in `Market` itself. #[allow(clippy::too_many_arguments)] pub fn create_market( env: Env, @@ -606,6 +787,8 @@ impl MarketCore { .ok_or(Error::NotInitialized)?; let treasury: Address = s.get(&DataKey::Treasury).ok_or(Error::NotInitialized)?; let breaker: Option = s.get(&DataKey::DefaultBreaker); + let creation_bond: i128 = s.get(&DataKey::CreationBond).unwrap_or(0); + let max_integrator_fee_bps: u32 = s.get(&DataKey::MaxIntegratorFeeBps).unwrap_or(0); let market = Market { creator: creator.clone(), @@ -634,6 +817,12 @@ impl MarketCore { if let Some(b) = breaker { Self::save_breaker(&env, id, &b); } + // Integrator-fee cap sidecar (#24): snapshotted so a later admin + // change cannot widen (or narrow) what this market honours. Absent + // entry means 0, so nothing is written for the default. + if max_integrator_fee_bps > 0 { + Self::save_market_max_integrator_fee(&env, id, max_integrator_fee_bps); + } if seed.creator_yes > 0 || seed.creator_no > 0 { Self::save_position( &env, @@ -646,7 +835,31 @@ impl MarketCore { ); } - Self::token(&env)?.transfer(&creator, env.current_contract_address(), &seed_amount); + // Creation bond sidecar (#23): recorded before the pull, and the + // whole `seed + bond` leaves the creator in ONE transfer so the + // treasury's nested-call pre-authorisation (which matches on the + // exact `(from, to, amount)` triple) stays a single entry. + if creation_bond > 0 { + Self::save_bond( + &env, + id, + &MarketBond { + amount: creation_bond, + state: BondState::Held, + void_reason: None, + forfeit_to: None, + }, + ); + BondPosted { + market_id: id, + amount: creation_bond, + } + .publish(&env); + } + let total_pull = seed_amount + .checked_add(creation_bond) + .ok_or(Error::Overflow)?; + Self::token(&env)?.transfer(&creator, env.current_contract_address(), &total_pull); MarketCreated { market_id: id, @@ -683,6 +896,7 @@ impl MarketCore { collateral_in, min_shares_out, None, + None, ) } @@ -721,11 +935,42 @@ impl MarketCore { } else { Some(limit_price_bps) }, + None, + ) + } + + /// `buy` with an integrator (#24): `integrator_fee_bps` of the input is + /// charged on top of the protocol fee and credited to `integrator`'s + /// unclaimed balance, retrievable via `claim_integrator_fees`. The fee + /// must fit the market's creation-time cap + /// (`get_market_max_integrator_fee_bps`) and the sum with the market's + /// protocol fee must stay within `MAX_FEE_BPS`; otherwise the trade + /// reverts, so an agent front-end can never silently overcharge. + #[allow(clippy::too_many_arguments)] + pub fn buy_via( + env: Env, + trader: Address, + market_id: u64, + outcome: Outcome, + collateral_in: i128, + min_shares_out: i128, + integrator: Address, + integrator_fee_bps: u32, + ) -> Result { + Self::do_buy( + env, + trader, + market_id, + outcome, + collateral_in, + min_shares_out, + None, + Some((integrator, integrator_fee_bps)), ) } - /// Shared implementation of `buy` / `buy_v2` (single copy of the - /// breaker, effect and interaction logic). + /// Shared implementation of `buy` / `buy_v2` / `buy_via` (single copy of + /// the breaker, effect and interaction logic). #[allow(clippy::too_many_arguments)] fn do_buy( env: Env, @@ -735,6 +980,7 @@ impl MarketCore { collateral_in: i128, min_shares_out: i128, limit_price_bps: Option, + integrator_opt: Option<(Address, u32)>, ) -> Result { Self::assert_not_paused(&env)?; if let Some(limit) = limit_price_bps { @@ -746,8 +992,11 @@ impl MarketCore { let mut m = Self::load_market(&env, market_id)?; Self::assert_tradable(&env, &m)?; + let int_fee_bps = + Self::validate_integrator_fee(&env, market_id, &m, integrator_opt.map(|(_, bps)| bps))?; + let (r_bought, r_other) = Self::reserves_for(&m, outcome); - let q = math::calc_buy(r_bought, r_other, collateral_in, m.fee_bps)?; + let q = math::calc_buy_with(r_bought, r_other, collateral_in, m.fee_bps, int_fee_bps)?; if q.shares_out < min_shares_out { return Err(Error::SlippageExceeded); } @@ -777,6 +1026,17 @@ impl MarketCore { .fees_accrued .checked_add(protocol_fee) .ok_or(Error::Overflow)?; + if q.integrator_fee > 0 { + if let Some((ref integrator, _)) = integrator_opt { + Self::accrue_integrator_fee(&env, integrator, q.integrator_fee)?; + IntegratorFeeAccrued { + market_id, + integrator: integrator.clone(), + amount: q.integrator_fee, + } + .publish(&env); + } + } let mut pos = Self::load_position(&env, market_id, &trader); match outcome { Outcome::Yes => pos.yes += q.shares_out, @@ -825,6 +1085,7 @@ impl MarketCore { collateral_out, max_shares_in, None, + None, ) } @@ -861,10 +1122,39 @@ impl MarketCore { } else { Some(limit_price_bps) }, + None, + ) + } + + /// `sell` with an integrator (#24): the gross spread is widened to cover + /// `integrator_fee_bps` and the integrator's floor-rounded share of it is + /// credited to `integrator`'s unclaimed balance. The protocol never + /// receives less than plain `sell` would have taken. Same cap validation + /// as `buy_via`. + #[allow(clippy::too_many_arguments)] + pub fn sell_via( + env: Env, + trader: Address, + market_id: u64, + outcome: Outcome, + collateral_out: i128, + max_shares_in: i128, + integrator: Address, + integrator_fee_bps: u32, + ) -> Result { + Self::do_sell( + env, + trader, + market_id, + outcome, + collateral_out, + max_shares_in, + None, + Some((integrator, integrator_fee_bps)), ) } - /// Shared implementation of `sell` / `sell_v2`. + /// Shared implementation of `sell` / `sell_v2` / `sell_via`. #[allow(clippy::too_many_arguments)] fn do_sell( env: Env, @@ -874,14 +1164,18 @@ impl MarketCore { collateral_out: i128, max_shares_in: i128, limit_price_bps: Option, + integrator_opt: Option<(Address, u32)>, ) -> Result { Self::assert_not_paused(&env)?; trader.require_auth(); let mut m = Self::load_market(&env, market_id)?; Self::assert_tradable(&env, &m)?; + let int_fee_bps = + Self::validate_integrator_fee(&env, market_id, &m, integrator_opt.map(|(_, bps)| bps))?; + let (r_sold, r_other) = Self::reserves_for(&m, outcome); - let q = math::calc_sell(r_sold, r_other, collateral_out, m.fee_bps)?; + let q = math::calc_sell_with(r_sold, r_other, collateral_out, m.fee_bps, int_fee_bps)?; if q.shares_in > max_shares_in { return Err(Error::SlippageExceeded); } @@ -921,6 +1215,17 @@ impl MarketCore { .fees_accrued .checked_add(protocol_fee) .ok_or(Error::Overflow)?; + if q.integrator_fee > 0 { + if let Some((ref integrator, _)) = integrator_opt { + Self::accrue_integrator_fee(&env, integrator, q.integrator_fee)?; + IntegratorFeeAccrued { + market_id, + integrator: integrator.clone(), + amount: q.integrator_fee, + } + .publish(&env); + } + } Self::save_market(&env, market_id, &m); // interaction @@ -941,6 +1246,27 @@ impl MarketCore { Ok(q.shares_in) } + /// Pay out `integrator`'s accrued #24 fees across all markets and zero + /// the balance. Permissionless besides the integrator's own auth: anyone + /// can submit the call, only the integrator can authorise it, and the + /// integrator itself cannot invoke trading — it just claims. Returns 0 + /// (no event, no transfer) when there is nothing to claim. + pub fn claim_integrator_fees(env: Env, integrator: Address) -> Result { + integrator.require_auth(); + let key = DataKey::IntegratorFees(integrator.clone()); + let amount: i128 = env.storage().persistent().get(&key).unwrap_or(0); + if amount <= 0 { + return Ok(0); + } + // effects before interaction (CEI): the balance is gone before the + // token transfer, so a re-entrant or failing transfer cannot + // double-pay. + env.storage().persistent().remove(&key); + Self::token(&env)?.transfer(&env.current_contract_address(), &integrator, &amount); + IntegratorFeesClaimed { integrator, amount }.publish(&env); + Ok(amount) + } + // ----- resolution ----------------------------------------------------- // Only the configured resolver contract may call these. When the resolver // contract is the direct caller, `require_auth` on its address passes. @@ -965,7 +1291,33 @@ impl MarketCore { } /// Invalid or unresolvable market. Allowed any time before finality. + /// + /// Keeps its pre-#23 semantics: this is the `NoFault` path. A market's + /// creation bond is refunded, never forfeited, when it voids through + /// here (which includes every `Verdict::Invalid` and expiry route). pub fn void_market(env: Env, market_id: u64) -> Result<(), Error> { + Self::void_inner(env, market_id, VoidReason::NoFault) + } + + /// `void_market` plus the reason (#23). Resolver-only, like `void_market`. + /// `GuardianFault` marks a guardian-initiated void for cause and forfeits + /// the market's creation bond at `claim_bond`, snapshotting the forfeit + /// recipient at void time. + /// + /// Callers outside this crate may not have a generated client that can + /// see this entrypoint (the vendored `market_core.wasm` predates it); + /// the resolver reaches it via `env.try_invoke_contract` name dispatch. + /// `VoidReason` is an all-unit-variant `#[contracttype]` enum and + /// therefore encodes as a plain `ScVal::Symbol` variant name. + pub fn void_market_with_reason( + env: Env, + market_id: u64, + reason: VoidReason, + ) -> Result<(), Error> { + Self::void_inner(env, market_id, reason) + } + + fn void_inner(env: Env, market_id: u64, reason: VoidReason) -> Result<(), Error> { Self::require_resolver(&env)?; let mut m = Self::load_market(&env, market_id)?; if m.status != MarketStatus::Open { @@ -973,6 +1325,23 @@ impl MarketCore { } m.status = MarketStatus::Void; Self::save_market(&env, market_id, &m); + + // Record the reason on the bond sidecar; markets without a bond + // entry (created pre-#23 or with a zero default) are untouched. + if let Some(mut bond) = Self::load_bond(&env, market_id) { + bond.void_reason = Some(reason); + if reason == VoidReason::GuardianFault && bond.state == BondState::Held { + // Snapshot the same destination `sweep_fees` would use, at + // void time, so a later treasury swap cannot redirect it. + let recipient = match Self::load_fee_terms(&env, market_id) { + Some(t) => t.recipient, + None => Self::instance_get(&env, &DataKey::Treasury)?, + }; + bond.forfeit_to = Some(recipient); + } + Self::save_bond(&env, market_id, &bond); + } + MarketResolved { market_id, outcome: None, @@ -981,6 +1350,51 @@ impl MarketCore { Ok(()) } + /// Settles a market's creation bond once the market is final (#23). + /// Permissionless so a keeper can drive it; the state flip is written + /// before the transfer (effects before interactions), so a second call + /// can never pay again and fails with `AlreadyClaimed`. + /// + /// Pays the creator on `Resolved` and on a `NoFault` void; pays the + /// `GuardianFault` void amount to the fee recipient snapshotted at void + /// time. Returns the settled amount. + pub fn claim_bond(env: Env, market_id: u64) -> Result { + let m = Self::load_market(&env, market_id)?; + Self::settlement(&m)?; + let mut bond = Self::load_bond(&env, market_id).ok_or(Error::NoBond)?; + if bond.state != BondState::Held { + return Err(Error::AlreadyClaimed); + } + let forfeited = matches!(m.status, MarketStatus::Void) + && bond.void_reason == Some(VoidReason::GuardianFault); + let to = if forfeited { + match bond.forfeit_to.clone() { + Some(a) => a, + None => Self::instance_get(&env, &DataKey::Treasury)?, + } + } else { + m.creator.clone() + }; + bond.state = if forfeited { + BondState::Forfeited + } else { + BondState::Refunded + }; + Self::save_bond(&env, market_id, &bond); + let amount = bond.amount; + if amount > 0 { + Self::token(&env)?.transfer(&env.current_contract_address(), &to, &amount); + } + BondSettled { + market_id, + to, + amount, + forfeited, + } + .publish(&env); + Ok(amount) + } + // ----- settlement ----------------------------------------------------- /// Pays out `holder`'s position for a final market and clears it. @@ -1241,6 +1655,60 @@ impl MarketCore { Ok(math::calc_sell(r_sold, r_other, collateral_out, m.fee_bps)?.shares_in) } + /// Shares a `buy_via` of `collateral_in` would return right now, running + /// the same cap validation as the trade itself (#24). + pub fn quote_buy_via( + env: Env, + market_id: u64, + outcome: Outcome, + collateral_in: i128, + integrator_fee_bps: u32, + ) -> Result { + let m = Self::load_market(&env, market_id)?; + let int_fee_bps = + Self::validate_integrator_fee(&env, market_id, &m, Some(integrator_fee_bps))?; + let (r_bought, r_other) = Self::reserves_for(&m, outcome); + Ok( + math::calc_buy_with(r_bought, r_other, collateral_in, m.fee_bps, int_fee_bps)? + .shares_out, + ) + } + + /// Shares a `sell_via` for exactly `collateral_out` would consume right + /// now, running the same cap validation as the trade itself (#24). + pub fn quote_sell_via( + env: Env, + market_id: u64, + outcome: Outcome, + collateral_out: i128, + integrator_fee_bps: u32, + ) -> Result { + let m = Self::load_market(&env, market_id)?; + let int_fee_bps = + Self::validate_integrator_fee(&env, market_id, &m, Some(integrator_fee_bps))?; + let (r_sold, r_other) = Self::reserves_for(&m, outcome); + Ok( + math::calc_sell_with(r_sold, r_other, collateral_out, m.fee_bps, int_fee_bps)? + .shares_in, + ) + } + + /// `integrator`'s unclaimed #24 balance across all markets. + pub fn get_integrator_fees(env: Env, integrator: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::IntegratorFees(integrator)) + .unwrap_or(0) + } + + /// The integrator-fee cap this market snapshotted at creation (#24). + /// Zero for markets created before the feature or while the global cap + /// was zero — integrator routing on such markets always reverts. + pub fn get_market_max_integrator_fee_bps(env: Env, market_id: u64) -> Result { + Self::load_market(&env, market_id)?; + Ok(Self::load_market_max_integrator_fee(&env, market_id)) + } + /// The market's snapshotted breakers (`None` for legacy markets created /// before breakers existed — those are never checked). pub fn breaker_config(env: Env, market_id: u64) -> Result, Error> { @@ -1248,6 +1716,13 @@ impl MarketCore { Ok(Self::load_breaker(&env, market_id)) } + /// The market's creation-bond sidecar (#23). `None` for markets created + /// without a bond (legacy markets or a zero default at creation). + pub fn get_bond(env: Env, market_id: u64) -> Result, Error> { + Self::load_market(&env, market_id)?; + Ok(Self::load_bond(&env, market_id)) + } + /// The current ledger's band reference, if a band check has run this /// ledger. `None` means the next trade sets the reference (to its own /// pre-trade price, which equals the ledger-open price). @@ -1479,6 +1954,85 @@ impl MarketCore { } } + fn load_bond(env: &Env, id: u64) -> Option { + let key = DataKey::Bond(id); + let bond = env.storage().persistent().get(&key); + if bond.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + bond + } + + fn save_bond(env: &Env, id: u64, bond: &MarketBond) { + let key = DataKey::Bond(id); + env.storage().persistent().set(&key, bond); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + fn load_market_max_integrator_fee(env: &Env, id: u64) -> u32 { + let key = DataKey::MarketMaxIntegratorFee(id); + let cap: Option = env.storage().persistent().get(&key); + if cap.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + cap.unwrap_or(0) + } + + fn save_market_max_integrator_fee(env: &Env, id: u64, cap: u32) { + let key = DataKey::MarketMaxIntegratorFee(id); + env.storage().persistent().set(&key, &cap); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + /// Common cap check for `*_via` trades and quotes (#24): the requested + /// integrator bps must fit the market's creation-time snapshot and the + /// combined protocol + integrator take must stay within `MAX_FEE_BPS`. + /// Returns the bps to feed into the math (0 when there is no integrator). + fn validate_integrator_fee( + env: &Env, + market_id: u64, + m: &Market, + integrator_bps: Option, + ) -> Result { + match integrator_bps { + Some(bps) => { + if bps > Self::load_market_max_integrator_fee(env, market_id) { + return Err(Error::IntegratorFeeTooHigh); + } + if bps.checked_add(m.fee_bps).ok_or(Error::Overflow)? > MAX_FEE_BPS { + return Err(Error::InvalidFee); + } + Ok(bps) + } + None => Ok(0), + } + } + + /// Credit `amount` to `integrator`'s unclaimed balance (#24). Balances + /// are per integrator across all markets, mirroring `LpFees` being + /// per market: an integrator serves many markets with one claim call. + fn accrue_integrator_fee(env: &Env, integrator: &Address, amount: i128) -> Result<(), Error> { + if amount <= 0 { + return Ok(()); + } + let key = DataKey::IntegratorFees(integrator.clone()); + let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); + let updated = current.checked_add(amount).ok_or(Error::Overflow)?; + env.storage().persistent().set(&key, &updated); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + Ok(()) + } + /// Splits a trade `fee` per the market's snapshotted `FeeTerms` (legacy /// markets: everything to the protocol), records the LP portion under /// `LpFees(id)` and returns the protocol portion for `fees_accrued`. @@ -3066,6 +3620,948 @@ mod test { .try_propose_treasury(&peer) .is_err()); } + + // ----- Issue #23: creation bond -------------------------------------- + + const BOND: i128 = 10_000_000; // 1 USDC on top of the 100 USDC seed + + /// Held-bond term of the global solvency invariant: the contract's + /// collateral balance must equal + /// `Σ (sets_minted + fees_accrued + lp_fees) + Σ held bonds + /// + Σ unclaimed integrator balances` + /// (#23/#24 extend the pre-#23 invariant `sets_minted + fees_accrued` + /// documented in `docs/architecture.md`; integrator balances are added + /// by the #24 tests via the `integrator` argument). + fn assert_global_solvency( + env: &Env, + client: &MarketCoreClient, + collateral: &Address, + integrators: &[Address], + ) { + let token = StellarAssetClient::new(env, collateral); + let core = client.address.clone(); + let mut expected = 0i128; + for id in 0..client.market_count() { + let m = client.get_market(&id); + expected += m.sets_minted + m.fees_accrued; + let lp: i128 = env.as_contract(&core, || { + env.storage() + .persistent() + .get::(&DataKey::LpFees(id)) + .unwrap_or(0) + }); + expected += lp; + if let Some(b) = client.get_bond(&id) { + if b.state == BondState::Held { + expected += b.amount; + } + } + } + for integrator in integrators { + expected += env.as_contract(&core, || { + env.storage() + .persistent() + .get::(&DataKey::IntegratorFees(integrator.clone())) + .unwrap_or(0) + }); + } + assert_eq!(token.balance(&core), expected); + } + + #[test] + fn create_market_pulls_seed_plus_bond() { + let (env, client, creator, _, _, collateral) = setup_market(); + client.set_creation_bond(&BOND); + assert_eq!(client.get_creation_bond(), BOND); + + let collateral_client = StellarAssetClient::new(&env, &collateral); + let creator_before = collateral_client.balance(&creator); + + let market_id = new_market(&env, &client, &creator, 5000); + + // One atomic pull of seed + bond, per the treasury pre-authorisation + // contract (`authorize_token_pull` matches a single exact amount). + assert_eq!( + collateral_client.balance(&creator), + creator_before - (100 * 10_000_000_i128) - BOND + ); + + let bond = client.get_bond(&market_id).unwrap(); + assert_eq!(bond.amount, BOND); + assert_eq!(bond.state, BondState::Held); + assert_eq!(bond.void_reason, None); + assert_eq!(bond.forfeit_to, None); + assert_global_solvency(&env, &client, &collateral, &[]); + } + + #[test] + fn bond_refunded_on_resolution() { + let (env, client, creator, _, _, collateral) = setup_market(); + client.set_creation_bond(&BOND); + let market_id = new_market(&env, &client, &creator, 5000); + + let close_time = client.get_market(&market_id).close_time; + env.ledger().set_timestamp(close_time + 1); + client.resolve(&market_id, &Outcome::Yes); + + let collateral_client = StellarAssetClient::new(&env, &collateral); + let before = collateral_client.balance(&creator); + let refunded = client.claim_bond(&market_id); + assert_eq!(refunded, BOND); + assert_eq!(collateral_client.balance(&creator), before + BOND); + + assert_eq!( + client.get_bond(&market_id).unwrap().state, + BondState::Refunded + ); + assert_global_solvency(&env, &client, &collateral, &[]); + } + + #[test] + fn bond_refunded_on_plain_void_keeps_no_fault_semantics() { + let (env, client, creator, _, _, collateral) = setup_market(); + client.set_creation_bond(&BOND); + let market_id = new_market(&env, &client, &creator, 5000); + + // `void_market` itself — the compatibility path — must refund. + client.void_market(&market_id); + + let collateral_client = StellarAssetClient::new(&env, &collateral); + let before = collateral_client.balance(&creator); + assert_eq!(client.claim_bond(&market_id), BOND); + assert_eq!(collateral_client.balance(&creator), before + BOND); + + let bond = client.get_bond(&market_id).unwrap(); + assert_eq!(bond.state, BondState::Refunded); + assert_eq!(bond.void_reason, Some(VoidReason::NoFault)); + } + + #[test] + fn bond_refunded_on_explicit_no_fault_void() { + let (env, client, creator, _, _, collateral) = setup_market(); + client.set_creation_bond(&BOND); + let market_id = new_market(&env, &client, &creator, 5000); + + client.void_market_with_reason(&market_id, &VoidReason::NoFault); + + let collateral_client = StellarAssetClient::new(&env, &collateral); + let before = collateral_client.balance(&creator); + assert_eq!(client.claim_bond(&market_id), BOND); + assert_eq!(collateral_client.balance(&creator), before + BOND); + assert_eq!( + client.get_bond(&market_id).unwrap().state, + BondState::Refunded + ); + } + + #[test] + fn bond_forfeited_on_guardian_fault_void() { + let (env, client, creator, _, _, collateral) = setup_market(); + let treasury = client.get_config().treasury; + client.set_creation_bond(&BOND); + let market_id = new_market(&env, &client, &creator, 5000); + + client.void_market_with_reason(&market_id, &VoidReason::GuardianFault); + + let collateral_client = StellarAssetClient::new(&env, &collateral); + let treasury_before = collateral_client.balance(&treasury); + let creator_before = collateral_client.balance(&creator); + + assert_eq!(client.claim_bond(&market_id), BOND); + assert_eq!(collateral_client.balance(&treasury), treasury_before + BOND); + assert_eq!(collateral_client.balance(&creator), creator_before); + + let bond = client.get_bond(&market_id).unwrap(); + assert_eq!(bond.state, BondState::Forfeited); + assert_eq!(bond.void_reason, Some(VoidReason::GuardianFault)); + // Recipient snapshotted at void time, not read at claim time. + assert_eq!(bond.forfeit_to, Some(treasury.clone())); + assert_global_solvency(&env, &client, &collateral, &[]); + } + + #[test] + fn bond_forfeit_survives_later_treasury_change() { + let (env, client, creator, _, _, collateral) = setup_market(); + let treasury = client.get_config().treasury; + client.set_creation_bond(&BOND); + let market_id = new_market(&env, &client, &creator, 5000); + + client.void_market_with_reason(&market_id, &VoidReason::GuardianFault); + + // Two-step treasury handover to a wired peer after the void; the + // forfeit must still pay the snapshot, not the new treasury. + let new_treasury = register_wiring_peer(&env, &client); + client.propose_treasury(&new_treasury); + client.accept_treasury(); + assert_eq!(client.get_config().treasury, new_treasury); + + assert_eq!(client.claim_bond(&market_id), BOND); + let collateral_client = StellarAssetClient::new(&env, &collateral); + assert_eq!(collateral_client.balance(&new_treasury), 0); + assert!(collateral_client.balance(&treasury) >= BOND); + } + + #[test] + #[should_panic(expected = "Error(Contract, #17)")] + fn claim_bond_twice_fails() { + let (env, client, creator, _, _, _) = setup_market(); + client.set_creation_bond(&BOND); + let market_id = new_market(&env, &client, &creator, 5000); + + let close_time = client.get_market(&market_id).close_time; + env.ledger().set_timestamp(close_time + 1); + client.resolve(&market_id, &Outcome::Yes); + client.claim_bond(&market_id); + client.claim_bond(&market_id); + } + + #[test] + fn claim_bond_before_finality_fails() { + let (env, client, creator, _, _, _) = setup_market(); + client.set_creation_bond(&BOND); + let market_id = new_market(&env, &client, &creator, 5000); + + assert_eq!(client.try_claim_bond(&market_id), Err(Ok(Error::NotFinal))); + } + + #[test] + fn claim_pool_remainder_does_not_touch_bond() { + let (env, client, creator, _, _, _) = setup_market(); + client.set_creation_bond(&BOND); + let market_id = new_market(&env, &client, &creator, 5000); + + let close_time = client.get_market(&market_id).close_time; + env.ledger().set_timestamp(close_time + 1); + client.resolve(&market_id, &Outcome::Yes); + client.claim_pool_remainder(&market_id); + + // The LP payout is computed from reserves only; the bond sidecar is + // untouched and still claimable. + let bond = client.get_bond(&market_id).unwrap(); + assert_eq!(bond.state, BondState::Held); + assert_eq!(client.claim_bond(&market_id), BOND); + } + + #[test] + fn legacy_market_has_no_bond() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + assert_eq!(client.get_bond(&market_id), Ok(None)); + + // Remove the sidecar to simulate a market created before this + // feature: `claim_bond` reports `NoBond`, not a zero refund. + remove_side_entry(&env, &client, DataKey::Bond(market_id)); + assert_eq!(client.get_bond(&market_id), Ok(None)); + client.void_market(&market_id); + assert_eq!(client.try_claim_bond(&market_id), Err(Ok(Error::NoBond))); + } + + #[test] + fn wrong_caller_cannot_set_creation_bond() { + let (env, client, _, trader, _, _) = setup_market(); + assert!(client + .mock_auths(&[MockAuth { + address: &trader, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "set_creation_bond", + args: (BOND,).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_set_creation_bond(&BOND) + .is_err()); + } + + #[test] + fn wrong_caller_cannot_void_with_reason() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + assert!(client + .mock_auths(&[MockAuth { + address: &trader, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "void_market_with_reason", + args: (market_id, VoidReason::GuardianFault).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_void_market_with_reason(&market_id, &VoidReason::GuardianFault) + .is_err()); + } + + #[test] + fn global_solvency_includes_bonds_multi_market() { + let (env, client, creator, trader, _, collateral) = setup_market(); + client.set_creation_bond(&BOND); + let m0 = new_market(&env, &client, &creator, 5000); + let m1 = new_market(&env, &client, &creator, 6000); + + client.buy(&trader, &m0, &Outcome::Yes, &(10 * 10_000_000_i128), &0); + // Give the trader NO shares in m1 first, then sell some back. + client.buy(&trader, &m1, &Outcome::No, &(5 * 10_000_000_i128), &0); + client.sell( + &trader, + &m1, + &Outcome::No, + &(1 * 10_000_000_i128), + &(100 * 10_000_000_i128), + ); + assert_global_solvency(&env, &client, &collateral, &[]); + + // One market resolves and its bond leaves the held set; the + // invariant tracks it through the payout. + let close_time = client.get_market(&m0).close_time; + env.ledger().set_timestamp(close_time + 1); + client.resolve(&m0, &Outcome::Yes); + client.claim_bond(&m0); + assert_global_solvency(&env, &client, &collateral, &[]); + } + + #[test] + fn set_creation_bond_bounds() { + let (env, client, _, _, _, _) = setup_market(); + assert_eq!( + client.try_set_creation_bond(&-1_i128), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!( + client.try_set_creation_bond(&(MAX_SETS + 1)), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!(client.try_set_creation_bond(&0_i128), Ok(Ok(()))); + assert_eq!(client.get_creation_bond(), 0); + let _ = env; + } + + // ----- Issue #24: integrator fees ------------------------------------- + + #[test] + fn buy_via_accrues_integrator_fee() { + let (env, client, creator, trader, _, collateral) = setup_market(); + client.set_max_integrator_fee_bps(&100); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + + let collateral_client = StellarAssetClient::new(&env, &collateral); + let trader_before = collateral_client.balance(&trader); + let contract_before = collateral_client.balance(&client.address); + + let collateral_in = 10_000_000_i128; + let int_fee_bps = 50u32; + let shares = client.buy_via( + &trader, + &market_id, + &Outcome::Yes, + &collateral_in, + &0_i128, + &integrator, + &int_fee_bps, + ); + assert!(shares > 0); + + // charged on top of the protocol fee, always rounded up + let expected_ifee = math::mul_div_ceil(collateral_in, int_fee_bps as i128, BPS).unwrap(); + assert_eq!(client.get_integrator_fees(&integrator), expected_ifee); + assert_eq!( + collateral_client.balance(&trader), + trader_before - collateral_in + ); + assert_eq!( + collateral_client.balance(&client.address), + contract_before + collateral_in + ); + assert_global_solvency(&env, &client, &collateral, &[integrator]); + } + + #[test] + fn sell_via_accrues_integrator_fee() { + let (env, client, creator, trader, _, collateral) = setup_market(); + client.set_max_integrator_fee_bps(&100); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(20_000_000_i128), + &0_i128, + ); + + let collateral_out = 5_000_000_i128; + let shares_sold = client.sell_via( + &trader, + &market_id, + &Outcome::Yes, + &collateral_out, + &100_000_000_i128, + &integrator, + &50u32, + ); + assert!(shares_sold > 0); + assert!(client.get_integrator_fees(&integrator) > 0); + assert_global_solvency(&env, &client, &collateral, &[integrator]); + } + + #[test] + #[should_panic(expected = "Error(Contract, #27)")] + fn buy_via_rejects_fee_above_market_cap() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_max_integrator_fee_bps(&50); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + + client.buy_via( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + &integrator, + &100u32, + ); + } + + #[test] + fn integrator_plus_market_fee_above_max_is_invalid_fee() { + // The global cap is validated against the default fee at set time; + // if an admin raises the default fee afterwards, markets created at + // the higher fee must still refuse trades whose combined take would + // exceed MAX_FEE_BPS. + let (env, client, creator, trader, _, _) = setup_market(); + client.set_max_integrator_fee_bps(&900); + client.set_default_fee_bps(&200); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + + // 850 <= cap(900) but 850 + market fee 200 > MAX_FEE_BPS + assert_eq!( + client.try_buy_via( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + &integrator, + &850u32, + ), + Err(Ok(Error::InvalidFee)) + ); + // exactly at the ceiling passes the fee check + assert!(client + .try_buy_via( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + &integrator, + &800u32, + ) + .is_ok()); + } + + #[test] + fn markets_without_a_cap_snapshot_reject_integrator_routing() { + let (env, client, creator, trader, _, _) = setup_market(); + // default global cap is absent = 0 + assert_eq!(client.get_max_integrator_fee_bps(), 0); + let market_id = new_market(&env, &client, &creator, 5000); + assert_eq!(client.get_market_max_integrator_fee_bps(&market_id), 0); + let integrator = Address::generate(&env); + + assert_eq!( + client.try_buy_via( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + &integrator, + &1u32, + ), + Err(Ok(Error::IntegratorFeeTooHigh)) + ); + // zero bps is still "no integrator cut", even on a capped market + assert_eq!( + client.quote_buy_via(&market_id, &Outcome::Yes, &10_000_000_i128, &0u32), + client.quote_buy(&market_id, &Outcome::Yes, &10_000_000_i128) + ); + } + + #[test] + fn claim_integrator_fees_pays_and_zeroes() { + let (env, client, creator, trader, _, collateral) = setup_market(); + client.set_max_integrator_fee_bps(&100); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + + client.buy_via( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + &integrator, + &50u32, + ); + + let fee = client.get_integrator_fees(&integrator); + assert!(fee > 0); + + let collateral_client = StellarAssetClient::new(&env, &collateral); + let before = collateral_client.balance(&integrator); + let claimed = client.claim_integrator_fees(&integrator); + assert_eq!(claimed, fee); + assert_eq!(collateral_client.balance(&integrator), before + fee); + assert_eq!(client.get_integrator_fees(&integrator), 0); + + // claiming again is a no-op returning 0, and never moves tokens + assert_eq!(client.claim_integrator_fees(&integrator), 0); + assert_eq!(collateral_client.balance(&integrator), before + fee); + } + + #[test] + fn wrong_caller_cannot_claim_integrator_fees() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_max_integrator_fee_bps(&100); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + let attacker = Address::generate(&env); + + client.buy_via( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + &integrator, + &50u32, + ); + + assert!(client + .mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "claim_integrator_fees", + args: (integrator.clone(),).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_claim_integrator_fees(&integrator) + .is_err()); + // the accrual is untouched + assert!(client.get_integrator_fees(&integrator) > 0); + } + + #[test] + fn solvency_includes_integrator_balances() { + let (env, client, creator, trader, _, collateral) = setup_market(); + client.set_max_integrator_fee_bps(&100); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + + client.buy_via( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + &integrator, + &50u32, + ); + client.sell_via( + &trader, + &market_id, + &Outcome::Yes, + &2_000_000_i128, + &100_000_000_i128, + &integrator, + &50u32, + ); + assert_global_solvency(&env, &client, &collateral, &[integrator.clone()]); + + // and keeps holding after the claim moves the balance out + client.claim_integrator_fees(&integrator); + assert_global_solvency(&env, &client, &collateral, &[integrator]); + } + + #[test] + fn max_integrator_fee_snapshot_not_affected_by_admin_change() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_max_integrator_fee_bps(&50); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + + client.set_max_integrator_fee_bps(&200); + assert_eq!(client.get_market_max_integrator_fee_bps(&market_id), 50); + + // the market still enforces its snapshot of 50 + assert_eq!( + client.try_buy_via( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + &integrator, + &100u32, + ), + Err(Ok(Error::IntegratorFeeTooHigh)) + ); + // 50 (its cap) is exactly allowed; market fee 100 keeps the sum + // within MAX_FEE_BPS + assert!(client + .try_buy_via( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + &integrator, + &50u32, + ) + .is_ok()); + } + + #[test] + fn quote_via_matches_execution() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_max_integrator_fee_bps(&100); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + + let collateral_in = 10_000_000_i128; + let quoted = client.quote_buy_via(&market_id, &Outcome::Yes, &collateral_in, &50u32); + let got = client.buy_via( + &trader, + &market_id, + &Outcome::Yes, + &collateral_in, + &0_i128, + &integrator, + &50u32, + ); + assert_eq!(quoted, got); + + let collateral_out = 2_000_000_i128; + let quoted = client.quote_sell_via(&market_id, &Outcome::Yes, &collateral_out, &50u32); + let got = client.sell_via( + &trader, + &market_id, + &Outcome::Yes, + &collateral_out, + &100_000_000_i128, + &integrator, + &50u32, + ); + assert_eq!(quoted, got); + + // quotes enforce the same caps as the trades + assert_eq!( + client.try_quote_buy_via(&market_id, &Outcome::Yes, &collateral_in, &101u32), + Err(Ok(Error::IntegratorFeeTooHigh)) + ); + } + + #[test] + fn set_max_integrator_fee_bps_bounds_and_auth() { + let (env, client, _, trader, _, _) = setup_market(); + // default protocol fee is 100; cap must keep fee + cap <= 1_000 + assert_eq!( + client.try_set_max_integrator_fee_bps(&901u32), + Err(Ok(Error::InvalidFee)) + ); + assert_eq!(client.try_set_max_integrator_fee_bps(&900u32), Ok(Ok(()))); + assert_eq!(client.get_max_integrator_fee_bps(), 900); + + assert!(client + .mock_auths(&[MockAuth { + address: &trader, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "set_max_integrator_fee_bps", + args: (50u32,).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_set_max_integrator_fee_bps(&50u32) + .is_err()); + } + + // ----- #23/#24 boundary and compatibility invariants ------------------- + // + // The cases above cover the rejecting side of each cap. These pin the + // boundary itself (`>` vs `>=`), the "no integrator means no change" + // compatibility guarantee that #24's acceptance criteria rest on, and the + // single wire-compatibility claim the whole resolver name-dispatch route + // of #23 rests on. + + /// `validate_integrator_fee` rejects `bps > cap`, so `bps == cap` must be + /// accepted. Off-by-one guard on the per-market cap (#24). + #[test] + fn buy_via_fee_at_exact_market_cap_is_accepted() { + let (env, client, creator, trader, _, collateral) = setup_market(); + client.set_max_integrator_fee_bps(&100); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + assert_eq!(client.get_market_max_integrator_fee_bps(&market_id), 100); + + let collateral_in = 10_000_000_i128; + let shares = client.buy_via( + &trader, + &market_id, + &Outcome::Yes, + &collateral_in, + &0_i128, + &integrator, + &100u32, // exactly the cap + ); + assert!(shares > 0); + assert_eq!( + client.get_integrator_fees(&integrator), + math::mul_div_ceil(collateral_in, 100_i128, BPS).unwrap() + ); + assert_global_solvency(&env, &client, &collateral, &[integrator]); + } + + /// The combined take is rejected at `> MAX_FEE_BPS`, so a take of exactly + /// `MAX_FEE_BPS` (integrator 900 + protocol 100 = 1_000) must go through. + /// Off-by-one guard on the global ceiling (#24). + #[test] + fn combined_take_at_exact_max_fee_bps_is_accepted() { + let (env, client, creator, trader, _, collateral) = setup_market(); + client.set_max_integrator_fee_bps(&900); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + let m = client.get_market(&market_id); + assert_eq!(m.fee_bps, 100); + assert_eq!(m.fee_bps + 900, MAX_FEE_BPS); + + let shares = client.buy_via( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + &integrator, + &900u32, + ); + // 900 bps of the input goes to the integrator and 100 bps to the + // protocol, so the trader still receives a strictly positive amount. + assert!(shares > 0); + assert_global_solvency(&env, &client, &collateral, &[integrator]); + } + + /// The cap is enforced on the sell side too, not only on buy (#24). + #[test] + #[should_panic(expected = "Error(Contract, #27)")] + fn sell_via_rejects_fee_above_market_cap() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_max_integrator_fee_bps(&50); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(20_000_000_i128), + &0_i128, + ); + client.sell_via( + &trader, + &market_id, + &Outcome::Yes, + &5_000_000_i128, + &100_000_000_i128, + &integrator, + &51u32, // one bps over the 50 cap + ); + } + + /// #24's compatibility criterion, at contract level: `buy_via` with a zero + /// integrator fee must be indistinguishable from plain `buy` — same shares, + /// same reserves, same minted sets, same accrued protocol fees, and no + /// integrator balance created. Proved against two identically seeded + /// markets so any divergence in the quote is visible. + #[test] + fn buy_via_with_zero_integrator_fee_matches_buy() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let m0 = new_market(&env, &client, &creator, 5000); + let m1 = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + let collateral_in = 20_000_000_i128; + + let plain = client.buy(&trader, &m0, &Outcome::Yes, &collateral_in, &0_i128); + let via = client.buy_via( + &trader, + &m1, + &Outcome::Yes, + &collateral_in, + &0_i128, + &integrator, + &0u32, + ); + assert_eq!(via, plain); + + let a = client.get_market(&m0); + let b = client.get_market(&m1); + assert_eq!(a.reserve_yes, b.reserve_yes); + assert_eq!(a.reserve_no, b.reserve_no); + assert_eq!(a.sets_minted, b.sets_minted); + assert_eq!(a.fees_accrued, b.fees_accrued); + // a zero fee must not open an integrator balance at all + assert_eq!(client.get_integrator_fees(&integrator), 0); + assert_global_solvency(&env, &client, &collateral, &[integrator]); + } + + /// The sell-side half of the same compatibility criterion: the + /// protocol/integrator split at `integrator_fee_bps = 0` must reproduce + /// plain `sell` exactly (see `calc_sell_with`'s floor-on-integrator rule). + #[test] + fn sell_via_with_zero_integrator_fee_matches_sell() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let m0 = new_market(&env, &client, &creator, 5000); + let m1 = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + let buy_in = 20_000_000_i128; + + client.buy(&trader, &m0, &Outcome::Yes, &buy_in, &0_i128); + client.buy_via( + &trader, + &m1, + &Outcome::Yes, + &buy_in, + &0_i128, + &integrator, + &0u32, + ); + + let collateral_out = 5_000_000_i128; + let max_shares_in = 100_000_000_i128; + let plain = client.sell(&trader, &m0, &Outcome::Yes, &collateral_out, &max_shares_in); + let via = client.sell_via( + &trader, + &m1, + &Outcome::Yes, + &collateral_out, + &max_shares_in, + &integrator, + &0u32, + ); + assert_eq!(via, plain); + + let a = client.get_market(&m0); + let b = client.get_market(&m1); + assert_eq!(a.reserve_yes, b.reserve_yes); + assert_eq!(a.reserve_no, b.reserve_no); + assert_eq!(a.sets_minted, b.sets_minted); + assert_eq!(a.fees_accrued, b.fees_accrued); + assert_eq!(client.get_integrator_fees(&integrator), 0); + assert_global_solvency(&env, &client, &collateral, &[integrator]); + } + + /// `claim_integrator_fees` is zero-before-transfer, so the second call + /// finds nothing, returns 0, emits no event and moves no collateral — it + /// must not panic (#24). + #[test] + fn claim_integrator_fees_second_call_is_a_noop() { + let (env, client, creator, trader, _, collateral) = setup_market(); + client.set_max_integrator_fee_bps(&100); + let market_id = new_market(&env, &client, &creator, 5000); + let integrator = Address::generate(&env); + + client.buy_via( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + &integrator, + &50u32, + ); + let accrued = client.get_integrator_fees(&integrator); + assert!(accrued > 0); + + let token = StellarAssetClient::new(&env, &collateral); + let first = client.claim_integrator_fees(&integrator); + assert_eq!(first, accrued); + assert_eq!(client.get_integrator_fees(&integrator), 0); + + let balance_after_first = token.balance(&integrator); + assert_eq!(client.claim_integrator_fees(&integrator), 0); + assert_eq!(token.balance(&integrator), balance_after_first); + assert_global_solvency(&env, &client, &collateral, &[integrator]); + } + + /// Integrator balances are per integrator *across all markets*, so one + /// claim must pay the sum of both accruals and clear the entry (#24). + #[test] + fn integrator_fees_accumulate_across_markets() { + let (env, client, creator, trader, _, collateral) = setup_market(); + client.set_max_integrator_fee_bps(&100); + let m0 = new_market(&env, &client, &creator, 5000); + let m1 = new_market(&env, &client, &creator, 6000); + let integrator = Address::generate(&env); + + let collateral_in = 10_000_000_i128; + for m in [m0, m1] { + client.buy_via( + &trader, + &m, + &Outcome::Yes, + &collateral_in, + &0_i128, + &integrator, + &50u32, + ); + } + let per_trade = math::mul_div_ceil(collateral_in, 50_i128, BPS).unwrap(); + assert_eq!(client.get_integrator_fees(&integrator), 2 * per_trade); + + assert_eq!(client.claim_integrator_fees(&integrator), 2 * per_trade); + assert_eq!(client.get_integrator_fees(&integrator), 0); + assert_global_solvency(&env, &client, &collateral, &[integrator]); + } + + /// The single assumption the whole #23 cross-crate route rests on: a + /// `#[contracttype]` enum whose variants are all unit and carry no explicit + /// discriminants encodes as the bare variant-name symbol, so the resolver + /// can dispatch `void_market_with_reason` by name with + /// `Symbol::new(env, "GuardianFault")` and stay wire-compatible whether or + /// not `market_core.wasm` has been regenerated. + /// + /// If this ever stops holding, the guardian-fault route silently stops + /// forfeiting, so it is asserted here rather than only in a comment. + #[test] + fn void_reason_encodes_as_plain_symbol_name() { + let env = Env::default(); + let fault: soroban_sdk::Val = VoidReason::GuardianFault.into_val(&env); + let no_fault: soroban_sdk::Val = VoidReason::NoFault.into_val(&env); + + assert_eq!( + fault, + soroban_sdk::Symbol::new(&env, "GuardianFault").into_val(&env) + ); + assert_eq!( + no_fault, + soroban_sdk::Symbol::new(&env, "NoFault").into_val(&env) + ); + assert_ne!(fault, no_fault); + + // and the argument tuple the resolver builds is exactly (u64, Symbol), + // constructed the same way `core_void_with_reason` builds it + let mut args = soroban_sdk::Vec::::new(&env); + args.push_back(7u64.into_val(&env)); + args.push_back(soroban_sdk::Symbol::new(&env, "GuardianFault").into_val(&env)); + assert_eq!(args.len(), 2); + assert_eq!(args.get_unchecked(0), 7u64.into_val(&env)); + assert_eq!(args.get_unchecked(1), fault); + } } // --------------------------------------------------------------------------- diff --git a/oracledesk-stellar/contracts/market-core/src/math.rs b/oracledesk-stellar/contracts/market-core/src/math.rs index a09574c..0f06233 100644 --- a/oracledesk-stellar/contracts/market-core/src/math.rs +++ b/oracledesk-stellar/contracts/market-core/src/math.rs @@ -38,7 +38,11 @@ pub enum Settlement { pub struct BuyQuote { pub shares_out: i128, pub fee: i128, - /// Collateral that actually mints complete sets (`collateral_in - fee`). + /// Integrator portion charged on top of the protocol fee (#24). Zero for + /// every quote produced through `calc_buy`. + pub integrator_fee: i128, + /// Collateral that actually mints complete sets + /// (`collateral_in - fee - integrator_fee`). pub net: i128, pub new_reserve_bought: i128, pub new_reserve_other: i128, @@ -48,7 +52,13 @@ pub struct BuyQuote { pub struct SellQuote { pub shares_in: i128, pub fee: i128, - /// Complete sets burned from the pool (`collateral_out + fee`). + /// Integrator portion carved out of the gross spread (#24). The protocol + /// `fee` above is the remainder, so `fee + integrator_fee` is exactly + /// what plain `calc_sell` would have reported as `fee`. Zero for every + /// quote produced through `calc_sell`. + pub integrator_fee: i128, + /// Complete sets burned from the pool + /// (`collateral_out + fee + integrator_fee`). pub gross: i128, pub new_reserve_sold: i128, pub new_reserve_other: i128, @@ -136,23 +146,37 @@ pub fn seed_reserves(seed: i128, yes_bps: u32) -> Result Result { if collateral_in <= 0 || r_bought <= 0 || r_other <= 0 { return Err(MathError::InvalidInput); } let fee = mul_div_ceil(collateral_in, fee_bps as i128, BPS)?; - let net = collateral_in - fee; + let ifee = mul_div_ceil(collateral_in, integrator_fee_bps as i128, BPS)?; + let net = collateral_in + .checked_sub(fee) + .ok_or(MathError::Overflow)? + .checked_sub(ifee) + .ok_or(MathError::Overflow)?; if net <= 0 { return Err(MathError::InvalidInput); } @@ -165,34 +189,61 @@ pub fn calc_buy( Ok(BuyQuote { shares_out, fee, + integrator_fee: ifee, net, new_reserve_bought: new_bought, new_reserve_other: new_other, }) } +pub fn calc_buy( + r_bought: i128, + r_other: i128, + collateral_in: i128, + fee_bps: u32, +) -> Result { + calc_buy_with(r_bought, r_other, collateral_in, fee_bps, 0) +} + /// Sell shares of the outcome with pool reserve `r_sold` for an exact -/// `collateral_out` (Gnosis-style, so no square root on-chain). +/// `collateral_out` (Gnosis-style, so no square root on-chain), splitting the +/// gross spread between the protocol and an integrator (#24). /// -/// 1. gross = ceil(out * BPS / (BPS - fee_bps)); fee = gross - out +/// 1. total = fee_bps + integrator_fee_bps; +/// gross = ceil(out * BPS / (BPS - total)); total_fee = gross - out; +/// ifee = floor(total_fee * integrator_fee_bps / total); fee = total_fee - ifee /// 2. burn `gross` complete sets from the pool: r_other -= gross /// 3. pool must end at r_sold' = ceil(r_sold * r_other / r_other') /// 4. seller gives r_sold' + gross - r_sold shares /// +/// The integrator portion rounds **down**, so the protocol always receives at +/// least what plain `calc_sell` would have taken and the split is exact: +/// `fee + integrator_fee == gross - collateral_out`. +/// /// To sell an exact share count, the frontend inverts this off-chain /// (quadratic, see `model/fpmm_model.py::gross_for_shares`) and passes the /// result as `collateral_out` with a `max_shares_in` slippage bound. -pub fn calc_sell( +pub fn calc_sell_with( r_sold: i128, r_other: i128, collateral_out: i128, fee_bps: u32, + integrator_fee_bps: u32, ) -> Result { - if collateral_out <= 0 || r_sold <= 0 || r_other <= 0 || (fee_bps as i128) >= BPS { + let sum_fees = (fee_bps as i128) + .checked_add(integrator_fee_bps as i128) + .ok_or(MathError::Overflow)?; + if collateral_out <= 0 || r_sold <= 0 || r_other <= 0 || sum_fees >= BPS { return Err(MathError::InvalidInput); } - let gross = mul_div_ceil(collateral_out, BPS, BPS - fee_bps as i128)?; - let fee = gross - collateral_out; + let gross = mul_div_ceil(collateral_out, BPS, BPS - sum_fees)?; + let total_fee = gross - collateral_out; + let (fee, ifee) = if sum_fees == 0 { + (0, 0) + } else { + let ifee = mul_div_floor(total_fee, integrator_fee_bps as i128, sum_fees)?; + (total_fee - ifee, ifee) + }; if gross >= r_other { return Err(MathError::InsufficientLiquidity); } @@ -205,12 +256,22 @@ pub fn calc_sell( Ok(SellQuote { shares_in, fee, + integrator_fee: ifee, gross, new_reserve_sold: new_sold, new_reserve_other: new_other, }) } +pub fn calc_sell( + r_sold: i128, + r_other: i128, + collateral_out: i128, + fee_bps: u32, +) -> Result { + calc_sell_with(r_sold, r_other, collateral_out, fee_bps, 0) +} + /// Integer square root (Newton's method), mirroring `agents/core/fpmm.ts::isqrt`. /// Returns `InvalidInput` for negative input. pub fn isqrt(n: i128) -> Result { @@ -345,6 +406,20 @@ mod tests { assert_eq!(q.new_reserve_other, 1100); } + #[test] + fn buy_with_integrator_known_values() { + // 1000/1000 pool, buy 100 with fee 100 bps + integrator 50 bps: + // fee = ceil(100*100/10000) = 1, ifee = ceil(100*50/10000) = 1, + // net = 98, new_other = 1098, new_bought = ceil(1e6/1098) = 911. + let q = calc_buy_with(1000, 1000, 100, 100, 50).unwrap(); + assert_eq!(q.fee, 1); + assert_eq!(q.integrator_fee, 1); + assert_eq!(q.net, 98); + assert_eq!(q.shares_out, 187); + assert_eq!(q.new_reserve_bought, 911); + assert_eq!(q.new_reserve_other, 1098); + } + #[test] fn sell_example_matches_model() { let q = calc_sell(1000, 1000, 100, 0).unwrap(); @@ -354,6 +429,21 @@ mod tests { assert_eq!(q.new_reserve_other, 900); } + #[test] + fn sell_with_integrator_known_values() { + // 10000/20000 pool, sell for 1000 with fee 100 + integrator 50 bps: + // gross = ceil(1000*10000/9850) = 1016, total spread 16, + // ifee = floor(16*50/150) = 5, protocol fee = 11. + let q = calc_sell_with(10_000, 20_000, 1000, 100, 50).unwrap(); + assert_eq!(q.gross, 1016); + assert_eq!(q.integrator_fee, 5); + assert_eq!(q.fee, 11); + assert_eq!(q.fee + q.integrator_fee, q.gross - 1000); + assert_eq!(q.new_reserve_other, 18_984); + assert_eq!(q.new_reserve_sold, 10_536); + assert_eq!(q.shares_in, 1552); + } + #[test] fn seed_opens_at_requested_probability() { for p in [100u32, 2500, 5000, 7300, 9900] { @@ -560,4 +650,98 @@ mod tests { price_yes_bps(1_100, 910).unwrap() ); } + + #[test] + fn calc_buy_with_zero_integrator_matches_calc_buy() { + 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 ry = 1_000_000 + next() % 1_000_000_000; + let rn = 1_000_000 + next() % 1_000_000_000; + let amt = 1 + next() % (ry.min(rn) / 2); + let fee = (next() % 300) as u32; + let q1 = calc_buy(ry, rn, amt, fee).unwrap(); + let q2 = calc_buy_with(ry, rn, amt, fee, 0).unwrap(); + assert_eq!(q1, q2); + assert_eq!(q2.integrator_fee, 0); + } + } + + #[test] + fn calc_sell_with_zero_integrator_matches_calc_sell() { + 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 ry = 1_000_000 + next() % 1_000_000_000; + let rn = 1_000_000 + next() % 1_000_000_000; + let amt = 1 + next() % (rn / 2); + let fee = (next() % 300) as u32; + let q1 = calc_sell(ry, rn, amt, fee).unwrap(); + let q2 = calc_sell_with(ry, rn, amt, fee, 0).unwrap(); + assert_eq!(q1, q2); + assert_eq!(q2.integrator_fee, 0); + } + } + + #[test] + fn calc_sell_with_split_never_shortchanges_protocol() { + 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 ry = 1_000_000 + next() % 1_000_000_000; + let rn = 1_000_000 + next() % 1_000_000_000; + let amt = 1 + next() % (rn / 4); + let fee = (next() % 200) as u32; + let ifee = (next() % 200) as u32; + if let (Ok(q_with), Ok(q_solo)) = ( + calc_sell_with(ry, rn, amt, fee, ifee), + calc_sell(ry, rn, amt, fee), + ) { + assert!(q_with.fee >= q_solo.fee, "protocol shortchanged"); + assert_eq!(q_with.fee + q_with.integrator_fee, q_with.gross - amt); + } + } + } + + #[test] + fn round_trip_with_integrator_never_profits() { + 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 ry = 1_000_000 + next() % 1_000_000_000; + let rn = 1_000_000 + next() % 1_000_000_000; + let amt = 1 + next() % (ry.min(rn) / 2); + let fee = (next() % 200) as u32; + let ifee = (next() % 100) as u32; + let k0 = ry * rn; + let q = calc_buy_with(ry, rn, amt, fee, ifee).unwrap(); + assert!(q.new_reserve_bought * q.new_reserve_other >= k0); + // getting `amt` back out costs at least the shares the buy paid + // out, integrator spread included + if let Ok(s) = calc_sell_with(q.new_reserve_bought, q.new_reserve_other, amt, fee, ifee) + { + assert!(s.shares_in >= q.shares_out, "free money with integrator"); + } + } + } } diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index edfcc9b..61403f9 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -251,6 +251,33 @@ pub enum GuardianReason { DisputeUpheld, } +/// Mirror of `market_core::VoidReason` (#23, refundable creation bond). +/// +/// Cross-contract calls from this crate must be **name dispatches** +/// (`env.try_invoke_contract` + `Symbol`), not generated-client calls, +/// because `contractimport!` compiles against the vendored +/// `market_core.wasm` whose bindings only gain `void_market_with_reason` +/// after a `make build` regeneration. A `#[contracttype]` enum whose +/// variants all carry no fields encodes as `ScVal::Symbol("VariantName")` +/// (soroban-sdk-macros `derive_enum.rs`), so passing the variant name as a +/// `Symbol` here is wire-compatible with market-core's `VoidReason` on both +/// sides of the regeneration. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VoidReason { + NoFault, + GuardianFault, +} + +impl VoidReason { + fn as_symbol_name(&self) -> &'static str { + match self { + VoidReason::NoFault => "NoFault", + VoidReason::GuardianFault => "GuardianFault", + } + } +} + /// Guardian action type emitted for audit trails. #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -335,6 +362,12 @@ pub enum Error { NoPendingAdmin = 42, /// A proposed admin handover was not accepted within its `expires_at`. PendingAdminExpired = 43, + /// A name-dispatched cross-contract call to market-core failed outright + /// (contract not present, function missing, or the call itself trapped). + /// NOTE: the sibling issue branches (#241) also claim 44 on theirs; the + /// last PR to merge re-appends this append-only tail (collision + /// disclosed in the PR body). + MarketCoreCallFailed = 44, } #[contracttype] @@ -605,6 +638,18 @@ pub struct GuardianAction { pub at: u64, } +/// Emitted alongside `GuardianAction { action: Void }` when the void was +/// routed through market-core's `void_market_with_reason` (#23): `at_fault` +/// records whether the creator's creation bond will be forfeited to the +/// fee-recipient snapshot instead of refunded. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GuardianVoided { + #[topic] + pub market_id: u64, + pub at_fault: bool, +} + #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct AdminTransferProposed { @@ -1130,6 +1175,33 @@ impl Resolver { env: Env, market_id: u64, reason: GuardianReason, + ) -> Result<(), Error> { + Self::guardian_void_inner(env, market_id, reason, None) + } + + /// `guardian_void_market` plus an explicit at-fault flag for the + /// creator's refundable bond (#23). `at_fault = true` routes the core + /// void through name-dispatched `void_market_with_reason(market_id, + /// GuardianFault)`, which marks the bond for forfeiture to the + /// fee-recipient snapshot; `false` keeps exactly the plain + /// `void_market`/NoFault semantics of the legacy entrypoint. The + /// signature of `guardian_void_market` itself is frozen — callers that + /// predate the bond keep compiling — so the flag arrives on a new + /// entrypoint instead of a new positional argument. + pub fn guardian_void_market_v2( + env: Env, + market_id: u64, + reason: GuardianReason, + at_fault: bool, + ) -> Result<(), Error> { + Self::guardian_void_inner(env, market_id, reason, Some(at_fault)) + } + + fn guardian_void_inner( + env: Env, + market_id: u64, + reason: GuardianReason, + at_fault: Option, ) -> Result<(), Error> { Self::require_admin(&env)?; if Self::resolution_state(&env, market_id).is_terminal() { @@ -1200,7 +1272,21 @@ impl Resolver { } } - core_call(core.try_void_market(&market_id))?; + match at_fault { + // #23 at-fault route: name dispatch, so this call compiles and + // runs against the vendored bindings whether or not + // `market_core.wasm` has been regenerated yet (the function is + // only *resolved* by a core that has it). + Some(true) => Self::core_void_with_reason( + &env, + &market_core, + market_id, + &VoidReason::GuardianFault, + )?, + // Legacy and explicit-NoFault routes: the unchanged generated + // client call, valid against every vendored blob. + _ => core_call(core.try_void_market(&market_id))?, + } Self::refund_open_dispute(&env, market_id); Self::set_state(&env, market_id, ResolutionState::Finalized); GuardianAction { @@ -1210,9 +1296,45 @@ impl Resolver { at: now, } .publish(&env); + if let Some(fault) = at_fault { + GuardianVoided { + market_id, + at_fault: fault, + } + .publish(&env); + } Ok(()) } + /// Name-dispatched `market_core.void_market_with_reason(market_id, + /// VoidReason)` — see the `VoidReason` docs for why this bypasses the + /// generated vendored client. `VoidReason` is an all-unit-variant + /// `#[contracttype]` enum and therefore encodes as a plain + /// `ScVal::Symbol`, which is what `Symbol::new` produces here. + /// This helper is deliberately self-contained (no `core_call`, which + /// this crate does not define). + fn core_void_with_reason( + env: &Env, + market_core: &Address, + market_id: u64, + reason: &VoidReason, + ) -> Result<(), Error> { + let mut args = Vec::new(env); + args.push_back(soroban_sdk::IntoVal::into_val(&market_id, env)); + args.push_back(soroban_sdk::IntoVal::into_val( + &Symbol::new(env, reason.as_symbol_name()), + env, + )); + match env.try_invoke_contract::<(), soroban_sdk::Error>( + market_core, + &Symbol::new(env, "void_market_with_reason"), + args, + ) { + Ok(Ok(())) => Ok(()), + _ => Err(Error::MarketCoreCallFailed), + } + } + pub fn resolve_price(env: Env, market_id: u64) -> Result { let (config, is_hybrid) = if let Ok(cfg) = Self::load::(&env, DataKey::Price(market_id)) @@ -5266,6 +5388,164 @@ mod test { model_trace_expire_blocked_by_valid_proposal, model_trace_admin_bypass_resolves_without_threshold, ); + + // ----- Issue #23: at-fault guardian void routing ----------------------- + // + // The at-fault route name-dispatches market-core's + // `void_market_with_reason`, an entrypoint the vendored + // `market_core.wasm` predates, so the two registrations of market-core + // behave differently here and BOTH are pinned: + // + // * `McImpl::Wasm` (the default, and what CI runs): the dispatch cannot + // resolve and must fail *closed* — `MarketCoreCallFailed`, with the + // market left `Open`. No half-applied void and no silently refunded + // bond. + // * `McImpl::Native` (the same source that ships the new entrypoint): + // the at-fault route completes. + // + // `guardian_void_v2_at_fault_fails_closed_on_stale_wasm` therefore passes + // against the blob as it stands today, and + // `guardian_void_v2_at_fault_forwards_guardian_fault` is asserted under an + // explicit native registration (the in-repo idiom, see + // `native_and_wasm_market_core_agree`) rather than being left as a test + // that cannot pass until `make build` refreshes the vendored blob. + // + // The two NoFault/auth cases are mode-independent — the legacy branch uses + // the unchanged generated `void_market` call, and both guards below + // return before the cross-contract dispatch — so they run in the default + // registration. + + #[test] + fn guardian_void_v2_no_fault_keeps_plain_void_semantics() { + let d = dispute_fixture(false); + open(&d); + d.f.resolver.guardian_void_market_v2( + &d.f.market_id, + &GuardianReason::DisputeUpheld, + &false, + ); + assert_eq!(status(&d), MarketStatus::Void); + let expected = soroban_sdk::Event::to_xdr( + &GuardianVoided { + market_id: d.f.market_id, + at_fault: false, + }, + &d.f.env, + &d.f.resolver_id, + ); + assert!(d + .f + .env + .events() + .all() + .filter_by_contract(&d.f.resolver_id) + .contains(&expected)); + } + + #[test] + fn guardian_void_v2_at_fault_forwards_guardian_fault() { + with_market_core(McImpl::Native, || { + let d = dispute_fixture(false); + open(&d); + d.f.resolver.guardian_void_market_v2( + &d.f.market_id, + &GuardianReason::DisputeUpheld, + &true, + ); + assert_eq!(status(&d), MarketStatus::Void); + let expected = soroban_sdk::Event::to_xdr( + &GuardianVoided { + market_id: d.f.market_id, + at_fault: true, + }, + &d.f.env, + &d.f.resolver_id, + ); + assert!(d + .f + .env + .events() + .all() + .filter_by_contract(&d.f.resolver_id) + .contains(&expected)); + // The bond sidecar itself is asserted in market-core + // (`bond_forfeited_on_guardian_fault_void`), because reading it + // back from here would need `MarketBond` mirrored into this crate. + }); + } + + /// The at-fault route must fail *closed* against the vendored blob: no + /// void, no state change, no `GuardianVoided` event. This is the guard + /// that keeps a pre-`make build` deployment from voiding a market while + /// silently skipping the forfeiture the caller asked for. + #[test] + fn guardian_void_v2_at_fault_fails_closed_on_stale_wasm() { + let d = dispute_fixture(false); + open(&d); + assert_eq!( + d.f.resolver.try_guardian_void_market_v2( + &d.f.market_id, + &GuardianReason::DisputeUpheld, + &true + ), + Err(Ok(Error::MarketCoreCallFailed)) + ); + // nothing was applied: still Open, still Disputed, no audit event + assert_eq!(status(&d), MarketStatus::Open); + assert_eq!( + d.f.resolver.state(&d.f.market_id), + ResolutionState::Disputed + ); + let not_published = soroban_sdk::Event::to_xdr( + &GuardianVoided { + market_id: d.f.market_id, + at_fault: true, + }, + &d.f.env, + &d.f.resolver_id, + ); + assert!(!d + .f + .env + .events() + .all() + .filter_by_contract(&d.f.resolver_id) + .contains(¬_published)); + } + + #[test] + fn guardian_void_v2_shares_validation_and_auth() { + // terminal-state guard applies to the new entrypoint too, and returns + // before the cross-contract dispatch is reached + let f = signer_fixture(); + f.env.ledger().set_timestamp(DEADLINE); + f.resolver.expire_market(&f.market_id); + assert_eq!( + f.resolver.try_guardian_void_market_v2( + &f.market_id, + &GuardianReason::SignersStalled, + &true + ), + Err(Ok(Error::AlreadyFinalized)) + ); + + // and only the admin may call it + let g = signer_fixture(); + let attacker = Address::generate(&g.env); + assert!(g + .resolver + .mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &g.resolver_id, + fn_name: "guardian_void_market_v2", + args: (g.market_id, GuardianReason::SignersStalled, true).into_val(&g.env), + sub_invokes: &[], + }, + }]) + .try_guardian_void_market_v2(&g.market_id, &GuardianReason::SignersStalled, &true) + .is_err()); + } } #[cfg(test)] diff --git a/oracledesk-stellar/contracts/treasury/src/lib.rs b/oracledesk-stellar/contracts/treasury/src/lib.rs index e4c475d..b620471 100644 --- a/oracledesk-stellar/contracts/treasury/src/lib.rs +++ b/oracledesk-stellar/contracts/treasury/src/lib.rs @@ -1763,12 +1763,39 @@ impl Treasury { { return Err(Error::PriceOutOfBand); } + + // #23: market-core pulls `seed_amount + creation_bond` in ONE transfer, + // so treasury has to fund and pre-authorise the larger amount or the + // nested `transfer` auth entry never matches and the creation reverts. + // Read by name — the vendored `market_core.wasm` predates the view. + let bond = Self::core_creation_bond(&env, &global.market_core); + let total_outlay = seed_amount.checked_add(bond).ok_or(Error::InvalidAmount)?; + // The bond is real treasury collateral leaving the vault, so the + // per-trade caps see the whole outlay and this fails with + // `TradeCapExceeded` *before* any market-core call (issue #23's + // "a bond pushing seed + bond over max_trade must fail first"). + // + // Deliberately only when a bond is in play: `agent_create_market` has + // never checked `max_trade` on the seed itself (it checks + // `creation_policy.max_seed`), so applying the check to a bond-free + // creation would silently tighten a pre-existing gap that is not part + // of #23 and would preempt `SeedCapExceeded` in + // `agent_create_market_rejects_seed_above_trade_cap`. With `bond == 0` + // every amount below is exactly `seed_amount`, so that path is + // unchanged. + if bond > 0 { + Self::check_trade_size(&global, &policy, total_outlay)?; + } + Self::record_creation(&env, &creation_policy)?; - Self::ensure_liquid(&env, &global, seed_amount)?; - Self::ensure_unreserved(&env, &global, seed_amount)?; + Self::ensure_liquid(&env, &global, total_outlay)?; + Self::ensure_unreserved(&env, &global, total_outlay)?; let treasury_addr = env.current_contract_address(); - Self::authorize_token_pull(&env, &global, &treasury_addr, seed_amount); + // Must be exactly `seed + bond`: `authorize_transfer` matches a single + // `(from, to, amount)` triple, so any other value leaves market-core's + // transfer unauthorised (see docs/adr/0002-treasury-nested-auth.md). + Self::authorize_token_pull(&env, &global, &treasury_addr, total_outlay); let market_id = core_call(MarketCoreClient::new(&env, &global.market_core).try_create_market( &treasury_addr, &question_hash, @@ -1779,7 +1806,14 @@ impl Treasury { &seed_amount, &initial_yes_bps, ))?; - Self::reserve_capacity(&env, &global, &agent, &policy, market_id, seed_amount)?; + // Market + daily caps count the full outlay (#23). Net position + // exposure and P&L cost basis stay on the seed: the bond is refundable + // collateral, not part of the position, so it must not be double-counted + // against `DataKey::Net(id)`/pnl. A refund (or a forfeit back to this + // same treasury, which is market-core's fee recipient by default) + // arrives in `idle()` and is therefore reflected in `nav()` exactly once, + // with no receivable tracked anywhere. + Self::reserve_capacity(&env, &global, &agent, &policy, market_id, total_outlay)?; Self::reserve_net(&env, &global, market_id, seed_amount)?; Self::record_cost(&env, market_id, seed_amount)?; Ok(market_id) @@ -2916,6 +2950,40 @@ impl Treasury { } } + /// Name-dispatched `market_core.get_creation_bond()` (#23), returning the + /// creation bond a market-core currently charges, or `0` when there is none. + /// + /// Read by name rather than through `MarketCoreClient`, for the same reason + /// `core_trade` is: `contractimport!` generates bindings from the *vendored* + /// `market_core.wasm`, which predates this entrypoint, so + /// `try_get_creation_bond` does not exist on the generated client and a + /// client-based call would not compile until `make build` regenerates the + /// blob (see docs/adr/0002-cross-contract-calls.md). `get_creation_bond` + /// takes no arguments and returns a bare `i128`, so unlike the resolver's + /// `VoidReason` dispatch there is no `#[contracttype]` to encode by variant + /// name here — just a plain `ScVal::I128`. + /// + /// **Absent means zero, on purpose.** A market-core deployed before #23 has + /// no such function, and `try_invoke_contract` reports that as an invocation + /// failure rather than a contract error. Treating every failure as `0` + /// matches market-core's own absent-means-default rule (`DataKey::CreationBond` + /// unset == no bond), so treasury keeps funding legacy cores instead of + /// failing every creation. It is also fail-closed in the dangerous + /// direction: if the read ever under-reports a bond that market-core does + /// require, treasury under-authorises the pull, market-core's nested + /// `transfer` fails its auth check, and the whole creation reverts — no + /// market is created with an unfunded bond. + fn core_creation_bond(env: &Env, core: &Address) -> i128 { + match env.try_invoke_contract::( + core, + &Symbol::new(env, "get_creation_bond"), + Vec::new(env), + ) { + Ok(Ok(amount)) if amount > 0 => amount, + _ => 0, + } + } + /// Agents may never spend collateral owed to queued withdrawals. /// The commitment an `agent_create_market_and_reveal` will both create the /// market with and reveal: `sha256(SPEC_DOMAIN || XDR(spec))`, matching the @@ -4402,6 +4470,152 @@ mod test { ); } + // ----- Issue #23: creation bond in the treasury creation path ----------- + // + // `get_creation_bond`/`set_creation_bond` do not exist in the vendored + // `market_core.wasm` the fixture's `MarketCoreClient` was generated from, + // so these run under an explicit native registration (the in-repo idiom, + // see `register_market_core` / `with_market_core`) and reach the setter by + // name. The default wasm registration, where the read fails and must + // degrade to "no bond", is pinned separately below. + + /// Name-dispatched `market_core.set_creation_bond(amount)` from the test + /// side — the generated client has no such method. + fn set_core_creation_bond(env: &Env, core: &Address, amount: i128) { + env.invoke_contract::<()>( + core, + &Symbol::new(env, "set_creation_bond"), + vec![env, amount.into_val(env)], + ); + } + + /// Reads a bare-integer view from market-core by name, for the same reason. + fn core_u64(env: &Env, core: &Address, fn_name: &str) -> u64 { + env.invoke_contract::(core, &Symbol::new(env, fn_name), vec![env]) + } + + fn core_i128(env: &Env, core: &Address, fn_name: &str) -> i128 { + env.invoke_contract::(core, &Symbol::new(env, fn_name), vec![env]) + } + + #[test] + fn agent_create_market_counts_bond_toward_caps() { + with_market_core(McImpl::Native, || { + let f = fixture_with_caps(150 * USDC, 300 * USDC, 300 * USDC); + let core = f.market_core.address.clone(); + let bond = 50 * USDC; + set_core_creation_bond(&f.env, &core, bond); + assert_eq!(core_i128(&f.env, &core, "get_creation_bond"), bond); + + let seed = 100 * USDC; + let token = StellarAssetClient::new(&f.env, &f.token); + let before = token.balance(&f.treasury_id); + let markets_before = core_u64(&f.env, &core, "market_count"); + + let market_id = f.treasury.agent_create_market( + &f.agent, + &BytesN::from_array(&f.env, &[23u8; 32]), + &BytesN::from_array(&f.env, &[123u8; 32]), + &String::from_str(&f.env, "ipfs://bonded"), + &Category::Macro, + &MARKET_CLOSE, + &seed, + &5000u32, + ); + + // The whole `seed + bond` left the treasury: proves both the + // combined pull and that `authorize_token_pull` matched it exactly + // (a mismatch would have reverted the nested transfer outright). + assert_eq!(token.balance(&f.treasury_id), before - (seed + bond)); + // Outflow caps count the bond. + assert_eq!(f.treasury.daily_usage().amount, seed + bond); + assert_eq!( + Treasury::market_exposure(&f.env, &core, market_id).amount, + seed + bond + ); + // ...and the bond is NOT double-counted as position exposure: the + // net book and the P&L cost basis are the seed alone, so a later + // refund is not silently read as a profit on the position. + assert_eq!(f.treasury.net_position(&market_id).collateral_in, seed); + assert_eq!(Treasury::load_pnl(&f.env, market_id).cost, seed); + // Exactly one market was created. + assert_eq!(core_u64(&f.env, &core, "market_count"), markets_before + 1); + }); + } + + /// Issue #23's cap edge case: a bond that pushes `seed + bond` past + /// `max_trade` must fail with `TradeCapExceeded` *before* any market-core + /// call, with nothing spent and nothing recorded. + #[test] + fn agent_create_market_rejects_seed_plus_bond_above_trade_cap() { + with_market_core(McImpl::Native, || { + let f = fixture_with_caps(150 * USDC, 300 * USDC, 300 * USDC); + let core = f.market_core.address.clone(); + let bond = 51 * USDC; + set_core_creation_bond(&f.env, &core, bond); + + let token = StellarAssetClient::new(&f.env, &f.token); + let before = token.balance(&f.treasury_id); + let markets_before = core_u64(&f.env, &core, "market_count"); + + // seed 100 passes `max_seed` (150), but 100 + 51 > max_trade (150). + assert_eq!( + f.treasury.try_agent_create_market( + &f.agent, + &BytesN::from_array(&f.env, &[24u8; 32]), + &BytesN::from_array(&f.env, &[124u8; 32]), + &String::from_str(&f.env, "ipfs://over-bonded"), + &Category::Macro, + &MARKET_CLOSE, + &(100 * USDC), + &5000u32, + ), + Err(Ok(Error::TradeCapExceeded)) + ); + + // rejected before the market-core call: no market, no spend, and + // no cap consumption (`record_creation` is also after the check). + assert_eq!(core_u64(&f.env, &core, "market_count"), markets_before); + assert_eq!(token.balance(&f.treasury_id), before); + assert_eq!(f.treasury.daily_usage().amount, 0); + }); + } + + /// The absent-entrypoint case, which is what today's vendored blob + /// actually presents: the name-dispatched read must degrade to `0`, the + /// creation must still succeed, and exactly `seed` must be authorised and + /// pulled. This is what keeps #23 from breaking treasury against a + /// pre-#23 market-core deployment, and it runs in the default (wasm) + /// registration on purpose. + #[test] + fn agent_create_market_funds_no_bond_against_pre_23_core() { + let f = fixture_with_caps(150 * USDC, 300 * USDC, 300 * USDC); + let core = f.market_core.address.clone(); + let seed = 100 * USDC; + let token = StellarAssetClient::new(&f.env, &f.token); + let before = token.balance(&f.treasury_id); + + let market_id = f.treasury.agent_create_market( + &f.agent, + &BytesN::from_array(&f.env, &[25u8; 32]), + &BytesN::from_array(&f.env, &[125u8; 32]), + &String::from_str(&f.env, "ipfs://legacy-core"), + &Category::Macro, + &MARKET_CLOSE, + &seed, + &5000u32, + ); + + // the vendored blob has no `get_creation_bond`, so the read degrades to + // 0 and the pull is exactly the seed, as before #23 + assert_eq!(token.balance(&f.treasury_id), before - seed); + assert_eq!(f.treasury.daily_usage().amount, seed); + assert_eq!( + Treasury::market_exposure(&f.env, &core, market_id).amount, + seed + ); + } + #[test] fn agent_sell_returns_capital_without_touching_caps() { let f = fixture(); diff --git a/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md b/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md new file mode 100644 index 0000000..f40db66 --- /dev/null +++ b/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md @@ -0,0 +1,185 @@ +# ADR 0010: Sidecar storage keys and name dispatch for the creation bond and integrator fees + +## Status + +Accepted. Implements issues #23 (refundable creation bond, forfeited on +guardian fault) and #24 (integrator fees on buy and sell with per-market caps). + +## Context + +Both features want to add per-market state to `market-core`, and both hit the +same two structural walls: + +1. **`Market`'s XDR shape is frozen.** `resolver`, `treasury` and `router` each + `contractimport!` a vendored `market_core.wasm` and decode `Market` from the + spec baked into that blob (ADR-0002, ADR-0009). Adding a field to `Market` + changes the decoding on three consumers at once, and we cannot regenerate + the blobs here: the box has stellar-cli 22.0.0 against the repo's expected + 27.1.0, and `rust-toolchain.toml` pins rustc 1.90.0 deliberately (issue #58) + so that two clean builds produce identical bytes. A blob we produced would + contradict its own `.sha256` pin and make `build.rs` panic. + +2. **A new entrypoint is invisible to the generated clients.** `void_market_with_reason`, + `get_creation_bond`, `buy_via` and friends do not exist in the vendored blob, + so `MarketCoreClient::try_void_market_with_reason(..)` is not a method and + does not compile. The old escape — depend on the crate natively — is exactly + what ADR-0002 rejects. + +`market-core` already has an established answer to (1): `DataKey::FeeTerms(u64)` +(ADR-0002's sibling in `lib.rs`), a per-market sidecar entry snapshotted at +creation, "so `Market`'s XDR shape never changes", with markets created before +it existed simply having no entry and keeping the legacy default. And `treasury` +already has an answer to (2): `core_trade` invokes market-core by name through +`env.try_invoke_contract` and decodes a plain integer, because "the generated +panicking methods are never used". + +## Decision + +**Put all new per-market state in appended sidecar keys, and reach every new +cross-contract entrypoint by name.** + +### The keys + +All five are appended after the existing `DataKey` tail (`Band(u64)`). No +pre-existing discriminant moves. Absent always means the legacy default, which +makes the upgrade migration-free: + +| Key | Store | Holds | Absent means | +| --- | --- | --- | --- | +| `CreationBond` | instance | `i128`, admin-set default | no bond charged | +| `MaxIntegratorFeeBps` | instance | `u32` cap default | 0 | +| `Bond(u64)` | persistent | `MarketBond { amount, state, void_reason, forfeit_to }` | market predates #23, `claim_bond` → `NoBond` | +| `MarketMaxIntegratorFee(u64)` | persistent | `u32`, cap snapshot taken at creation | 0 | +| `IntegratorFees(Address)` | persistent | `i128` unclaimed balance | 0 | + +`Market`, `Position`, `FeeTerms` and every exported signature keep their exact +field order and count. New *capability* arrives as a new entrypoint +(`void_market_with_reason`, `buy_via`, `sell_via`, `claim_bond`, +`claim_integrator_fees`, `quote_*_via`, the `get_*` views), never as a new +positional parameter on an existing public method. `void_market` keeps working +and is defined as the `NoFault` path, so every pre-#23 caller is unaffected. + +### Snapshots, not live config + +`CreationBond` and `MaxIntegratorFeeBps` are *defaults*, copied into the +per-market entry at creation. An admin changing either later never moves what +an existing market honours — the same rule `FeeTerms` and `Breaker` already +follow, and the reason `get_market_max_integrator_fee_bps` and `get_bond` exist +as separate views from the globals. + +`MarketBond.forfeit_to` extends this to a *destination*: the fee recipient is +recorded at void time, not read at claim time, so a `propose_treasury` / +`accept_treasury` between the guardian void and `claim_bond` cannot redirect the +forfeit. `sweep_fees` reads the current treasury and is left alone; the bond +path is the one where deferring the read would be an authorisation hole. + +### Why a forfeited bond is not a treasury receivable + +`treasury` funds `seed + bond` (ADR-0005's vault). The bond is refundable +collateral, not a position, so it is deliberately kept out of `DataKey::Net(id)` +and `DataKey::Pnl(id)`: counting it there would read a later refund as a profit +on the position. `nav()` is balance-derived (`idle()` plus position floors), so +the bond is reflected exactly once as it leaves and once as it returns, with no +receivable tracked in between. It *is* counted in the market and daily outflow +caps, because that collateral really did leave the vault. + +### Name dispatch, and the one encoding claim it rests on + +`resolver` reaches `void_market_with_reason` and `treasury` reaches +`get_creation_bond` through `env.try_invoke_contract` with a `Symbol`. Neither +needs a regenerated blob to compile or to run. + +For `VoidReason` this depends on a single, checkable property of the SDK: a +`#[contracttype]` enum whose variants are all unit and carry **no explicit +discriminants** is encoded as `ScVal::Symbol("VariantName")` +(`derive_enum.rs` in `soroban-sdk-macros-27.0.6`); only all-explicit-integer +enums go through `derive_enum_int` to `ScVal::U32`, and a mix of the two is a +compile error. So the variant *name* is the wire format, and a caller passing +`Symbol::new(env, "GuardianFault")` is byte-compatible with a callee expecting +the enum, whether or not the spec has been regenerated. + +Because the whole at-fault route of #23 rests on that property rather than on a +comment asserting it, `market-core`'s test suite asserts the encoding directly: +`void_reason_encodes_as_plain_symbol_name`. + +### Absent means default, across the boundary too + +`core_creation_bond` treats *every* dispatch failure as `0`, not as +`MarketCoreCallFailed`. A market-core deployed before #23 has no such function, +and treasury must keep creating markets against it. This stays fail-closed in +the dangerous direction: if the read ever under-reports a bond market-core does +require, treasury under-authorises the pull, market-core's nested `transfer` +fails its auth check, and the whole creation reverts — no market is ever created +with an unfunded bond. + +### Effects before interactions + +`claim_bond` writes the `Held → Refunded/Forfeited` flip before transferring, +and `claim_integrator_fees` removes the balance entry before transferring. A +second call finds nothing: `AlreadyClaimed`, or a no-op return of `0`. Neither +can be paid twice, and neither is protected only by `min_shares_out`-style +caller-side checks. + +## Consequences + +**Positive** + +- Migration-free. Markets, integrators and treasury configs predating either + feature need no backfill and no data migration; they simply have no entry. +- `resolver`, `treasury` and `router` compile and run against the *current* + vendored blob. Nothing in this change is blocked on `make build`. +- The bond/fee state is independently readable (`get_bond`, + `get_integrator_fees`), so indexers and the exposure dashboard do not have to + re-derive it from events. +- Storage cost is proportional to use: no `Bond` entry is written when the + default is 0, and no cap entry when the default cap is 0. + +**Negative / accepted** + +- One more read per relevant call path: a `*_via` trade reads the market, its + cap snapshot and the fee terms. Chosen over widening `Market`, which would + have broken three crates. +- Cap *validation* is duplicated by design between `validate_integrator_fee` + and the `set_*` bounds check. The trade path cannot trust config that may have + moved since the market was created, and the setter cannot be made to know + every future market's fee. Both are tested. +- `IntegratorFees(Address)` is one balance per integrator across all markets, + not per market. Deliberate — an integrator serves many markets and should claim + in one call — but it means the entry's TTL must be bumped on every accrual, as + `LpFees` does per market. +- The enum tails collide by design with sibling PRs: #239 appends + `ModeRestricted = 27`, #240 `DirectoryEntryArchived = 27`, and #242 + `AlreadyHalted = 27` + `HaltTooOld = 28` at market-core (verified by reading + each PR's patch, not inferred). The resolver side has its own collision: this + work appends `MarketCoreCallFailed = 44` to an enum that ends at 43 on + `develop`, and #241 (`QuorumNotReached = 44`) and #242 (`EventTimeMismatch = + 44`) reach for the same number. This is the agreed cost of solving each issue + standalone against `develop`; the last PR to merge re-appends. It is not + resolved by rebasing one PR onto another. Tests should assert the *named* + variant, not the numeric literal — `Err(Ok(Error::NoBond))` and + `Err(Ok(Error::MarketCoreCallFailed))` survive a renumber untouched, whereas + the two `should_panic(expected = "Error(Contract, #27)")` cases have to be + chased. + +**Required follow-up, not doable here** + +- `make build` to refresh `contracts/{resolver,treasury,router}/market_core.wasm` + and their `.sha256` pins, so the generated clients gain the new entrypoints and + the name dispatches can become typed calls. Until then the resolver's + at-fault route fails closed against the stale blob — asserted by + `guardian_void_v2_at_fault_fails_closed_on_stale_wasm` rather than hidden. +- `make bindings` + regeneration for `buy_via`, `sell_via`, `claim_bond`, + `set_creation_bond`, `set_max_integrator_fee_bps`, the `quote_*_via` views and + the `BondPosted`/`BondSettled`/`IntegratorFeeAccrued`/`IntegratorFeesClaimed`/ + `CreationBondChanged`/`GuardianVoided` events. Needs stellar-cli ≥ 27.1.0. +- The TypeScript mirror (`agents/core/fpmm.ts`) for the integrator-fee split, and + wiring `calc_buy_with`/`calc_sell_with` into `scripts/fpmm-diff/` (the Rust + oracle, `diff.py`'s dispatch table, and `vectors.jsonl` regenerated by the + differential run rather than transcribed). `model/fpmm_model.py`'s mirror is + done — its checks execute here (`python3 model/fpmm_model.py`), which is exactly + what `fpmm.ts` cannot be, since Node on this host cannot strip its types. + +`docs/architecture.md` (the global solvency form) and +`docs/frontend-integration.md` (the bond, the total pulled at creation, and how +to pass an integrator) are updated by this work rather than left as follow-ups, +per #23's step 9 and #24's front-end criterion. diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index 1f1110a..c28cd9e 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -24,6 +24,41 @@ protocol's pending share of trading fees, `lp_fees` the creator's — see the fee split below), and `reserve_yes + Σ(user yes positions) == sets_minted` (symmetrically for `no`). +Since #23/#24 the contract also holds two kinds of money that are not part of +any market's pool, so the global form of the invariant is + +``` +balance == Σ_markets (sets_minted + fees_accrued + lp_fees) + + Σ_held creation bonds + + Σ unclaimed integrator balances +``` + +Held bonds are those whose `Bond(id)` entry is still `BondState::Held`; a +`Refunded`/`Forfeited` bond has already left the contract and drops out of the +sum. Unclaimed integrator balances live in `IntegratorFees(address)` entries. +`test::assert_global_solvency` computes exactly this over `0..market_count()` +and is called by both the bond and the integrator-fee tests. + +**Creation bond (#23).** `create_market` pulls `seed_amount + bond` in one +transfer (one `transfer` keeps treasury's single `(from, to, amount)` +pre-authorisation intact) and records a `Bond(market_id)` side entry, so +`Market`'s XDR shape never changes. The bond is refunded to the creator when the +market resolves or voids through a no-fault path, and forfeited to the fee +recipient snapshotted at void time only when the void is a `GuardianFault`. +`claim_bond` is permissionless, settles each bond once (state written before the +transfer), and errors `NoBond` for markets that predate the feature. See +`docs/adr/0010-bond-and-fee-sidecars.md`. + +**Integrator fees (#24).** `buy_via`/`sell_via` charge an integrator cut on top +of the protocol fee, bounded by a per-market `MarketMaxIntegratorFee` snapshot +and by `protocol + integrator <= MAX_FEE_BPS`. On buys both fees round up, so +the pool's spread only widens; on sells the integrator's share of the gross +spread rounds down, so the protocol never receives less than plain `sell` would +have taken. Credits accumulate per integrator across markets and are withdrawn +by `claim_integrator_fees`, which clears the balance before transferring. +`buy`/`sell` are unchanged and behave exactly as before when no integrator is +given. + **Fee split and recipient snapshot.** `create_market` snapshots a `FeeTerms { recipient, protocol_share_bps }` side entry per market, so `propose_treasury` / `set_protocol_fee_share_bps` only affect markets created diff --git a/oracledesk-stellar/docs/frontend-integration.md b/oracledesk-stellar/docs/frontend-integration.md index d4ee674..a2310b0 100644 --- a/oracledesk-stellar/docs/frontend-integration.md +++ b/oracledesk-stellar/docs/frontend-integration.md @@ -107,6 +107,93 @@ const tx = await contract.buy_v2({ `buy` / `sell` remain for backward compatibility but carry no deadline or limit; the share-count bound alone does not stop a stale transaction. +## Integrator (frontend) fees + +An integrator — a UI, a wallet, a Telegram bot — can take a cut of the flow +it routes by calling `buy_via` / `sell_via` instead of `buy` / `sell`. The +fee is charged **on top of** the market's protocol fee and accrues on-chain +to `integrator`'s own balance; nothing is transferred to you at trade time. + +```ts +const quote = (await contract.quote_buy_via({ + market_id, outcome, collateral_in, integrator_fee_bps: 50, +})).result; +const tx = await contract.buy_via({ + trader: userAddress, // the user signs; you do not + market_id, outcome, collateral_in, + min_shares_out: (quote * 9_950n) / 10_000n, + integrator: yourAddress, + integrator_fee_bps: 50, // 0.50% of collateral_in +}); +``` + +Three rules the contract enforces, so do not try to compute them client-side: + +- `integrator_fee_bps` must be `<= get_market_max_integrator_fee_bps(market_id)`, + the cap the market snapshotted at creation. A later + `set_max_integrator_fee_bps` never moves it for existing markets. +- `market.fee_bps + integrator_fee_bps <= MAX_FEE_BPS` (1000 bps), checked per + trade as well as by the admin setter. +- Either violation reverts the whole trade with + `Error(Contract, #27)` (`IntegratorFeeTooHigh`). `quote_buy_via` / + `quote_sell_via` run the same validation, so a quote that succeeds is a + quote for a trade that can succeed. + +The integrator fee is part of `collateral_in` on a buy, so +`min_shares_out` derived from `quote_buy_via` (not `quote_buy`) is the +correct bound — `quote_buy` over-states the shares by the integrator's cut. +On a sell the fee is retained out of the `gross` pulled from the pool, so the +protocol's share is never reduced by the split's rounding +(`calc_sell_with` floors the integrator leg). + +Balances are per address across all markets. Read yours with +`get_integrator_fees(integrator)` and withdraw with +`claim_integrator_fees(integrator)`, which requires your own signature, +zeroes the balance *before* transferring, and emits +`IntegratorFeesClaimed { integrator, amount }`. A second call is a no-op +returning `0`, not an error. Fees accrue as `IntegratorFeeAccrued` events; +the `Trade` event's field list is unchanged so existing indexers keep +decoding it. + +`buy_via` / `sell_via` take no `deadline` or `limit_price_bps`: the two +v2 guards above and the integrator leg have not been combined into one +entrypoint. A user-facing UI that needs both must accept the weaker of the +two protections, which in practice means: keep the deadline-bounded path +`buy_v2`, and use the `*_via` path only for flows that are not +sign-and-forget. Note also that an integrator may name *itself* as the +`trader`; that is harmless economically but inflates reported volume. + +## Creation bond + +`create_market` now pulls **two** things in a single transfer: the seed +liquidity and the creation bond. Read the bond with `get_creation_bond()` +(instance config, default `0`) and show `seed_amount + bond` in any +confirmation the user signs — a UI that displays only the seed will produce +transactions that fail with an insufficient balance. + +```ts +const bond = (await contract.get_creation_bond()).result; +const total = seed_amount + bond; // this is what leaves the wallet +``` + +The bond is held separately from the market's trading collateral (its own +storage entry; the `Market` struct is unchanged) and is settled by the +permissionless `claim_bond(market_id)`, callable by anyone once the market is +final so a keeper can drive it: + +| Market outcome | Bond goes to | +| --- | --- | +| Resolved normally | the creator | +| Voided with `VoidReason::NoFault` (incl. plain `void_market`) | the creator | +| Voided with `VoidReason::GuardianFault` | the fee recipient (`forfeit_to`, snapshotted at void time) | + +Markets created before this change have no bond entry at all: `claim_bond` +reverts with `Error(Contract, #28)` (`NoBond`), and `bond = 0` is snapshotted +for them retroactively by the solvency accounting. The bond is never touched +by `claim_pool_remainder` — LP withdrawal and bond settlement are separate +rails. The admin setter `set_creation_bond(amount)` applies only to markets +created afterwards, exactly like `set_default_fee_bps`. + ## Circuit breakers Markets can carry per-market breakers (snapshotted at creation): a maximum diff --git a/oracledesk-stellar/model/fpmm_model.py b/oracledesk-stellar/model/fpmm_model.py index 4b9c9f1..1f759e2 100644 --- a/oracledesk-stellar/model/fpmm_model.py +++ b/oracledesk-stellar/model/fpmm_model.py @@ -123,6 +123,103 @@ def calc_sell(r_sold: int, r_other: int, collateral_out: int, fee_bps: int) -> S return SellQuote(shares_in, fee, gross, new_sold, new_other) +@dataclass(frozen=True) +class BuyQuoteWith: + """`BuyQuote` plus the integrator leg (#24). + + A separate dataclass rather than a new field on `BuyQuote`, on purpose: + `_check_shared_vectors` compares `out.__dict__` against the committed + `scripts/fpmm-diff/vectors.jsonl`, and adding `integrator_fee` to + `BuyQuote` would add an unexpected key to every `calc_buy` vector and + break them. `math.rs` can carry the extra field because its + `shared_vectors_match` enumerates the compared keys explicitly (verified by + reading that test). The arithmetic below is the mirror; the *shape* is not. + """ + + shares_out: int + fee: int + integrator_fee: int + net: int + new_reserve_bought: int + new_reserve_other: int + + +@dataclass(frozen=True) +class SellQuoteWith: + """`SellQuote` plus the integrator leg (#24). See `BuyQuoteWith` for why + this is a separate type.""" + + shares_in: int + fee: int + integrator_fee: int + gross: int + new_reserve_sold: int + new_reserve_other: int + + +def calc_buy_with( + r_bought: int, r_other: int, collateral_in: int, fee_bps: int, integrator_fee_bps: int +) -> BuyQuoteWith: + """Mirror of math.rs::calc_buy_with. Both fees are rounded **up**, so the + trader (who opted into the integrator) absorbs the rounding and the pool + can never be credited less than `net` mints.""" + if collateral_in <= 0 or r_bought <= 0 or r_other <= 0: + raise MathError("invalid input") + fee = mul_div_ceil(collateral_in, fee_bps, BPS) + ifee = mul_div_ceil(collateral_in, integrator_fee_bps, BPS) + net = collateral_in - fee - ifee + if net <= 0: + raise MathError("invalid input") + new_other = r_other + net + new_bought = mul_div_ceil(r_bought, r_other, new_other) + shares_out = r_bought + net - new_bought + if shares_out <= 0: + raise MathError("invalid input") + return BuyQuoteWith(shares_out, fee, ifee, net, new_bought, new_other) + + +def calc_sell_with( + r_sold: int, r_other: int, collateral_out: int, fee_bps: int, integrator_fee_bps: int +) -> SellQuoteWith: + """Mirror of math.rs::calc_sell_with. `gross` is rounded up on the + **combined** bps, then the integrator leg is rounded **down** out of the + total spread and the protocol takes the remainder, so the protocol never + receives less than a protocol-only computation would have given it.""" + sum_fees = fee_bps + integrator_fee_bps + if collateral_out <= 0 or r_sold <= 0 or r_other <= 0 or sum_fees >= BPS: + raise MathError("invalid input") + gross = mul_div_ceil(collateral_out, BPS, BPS - sum_fees) + total_fee = gross - collateral_out + if sum_fees == 0: + # The split would divide by zero; there is no fee to split. + fee, ifee = 0, 0 + else: + ifee = mul_div_floor(total_fee, integrator_fee_bps, sum_fees) + fee = total_fee - ifee + if gross >= r_other: + raise MathError("insufficient liquidity") + new_other = r_other - gross + new_sold = mul_div_ceil(r_sold, r_other, new_other) + shares_in = new_sold + gross - r_sold + if shares_in <= 0: + raise MathError("invalid input") + return SellQuoteWith(shares_in, fee, ifee, gross, new_sold, new_other) + + +def _lcg_stream(seed: int = 0x9E37_79B9_7F4A_7C15): + """The exact LCG `math.rs`'s integrator property tests use (`PCG` step + constants over `u64`, shifted right by 33), so Python sweeps the *same* + inputs as Rust rather than a fresh random sample.""" + x = seed + + def next_int() -> int: + nonlocal x + x = (x * 6_364_136_223_846_793_005 + 1_442_695_040_888_963_407) & (2**64 - 1) + return x >> 33 + + return next_int + + def split_fee(fee: int, protocol_share_bps: int) -> tuple[int, int]: """Split a trade fee into (protocol, lp) portions. Mirrors math.rs::split_fee: the protocol share is rounded up so the pool can @@ -241,6 +338,124 @@ def _check_known_values(): assert split_fee(101, 5_000) == (51, 50) +def _check_integrator_known_values(): + """The literal expectations asserted by math.rs's + `buy_with_integrator_known_values` / `sell_with_integrator_known_values`. + Transcribed from the Rust source, not recomputed, so an arithmetic + divergence here shows up as a Python failure.""" + q = calc_buy_with(1000, 1000, 100, 100, 50) + assert q.fee == 1, q + assert q.integrator_fee == 1, q + assert q.net == 98, q + assert q.shares_out == 187, q + assert q.new_reserve_bought == 911, q + assert q.new_reserve_other == 1098, q + + s = calc_sell_with(10_000, 20_000, 1000, 100, 50) + assert s.gross == 1016, s + assert s.integrator_fee == 5, s + assert s.fee == 11, s + assert s.fee + s.integrator_fee == s.gross - 1000, s + assert s.new_reserve_other == 18_984, s + assert s.new_reserve_sold == 10_536, s + assert s.shares_in == 1552, s + + +def _check_integrator_accounting_is_exact(trials: int = 2_000): + """math.rs's `do_buy` / `do_sell` assert these identities on chain: + `collateral_in == net + fee + ifee` and `gross == collateral_out + fee + + ifee`. If either broke, `sets_minted` would drift from the collateral the + contract actually holds, which is the solvency invariant. Swept over the + Rust LCG inputs so the two languages test the same domain.""" + next_int = _lcg_stream() + for _ in range(trials): + ry = 1_000_000 + next_int() % 1_000_000_000 + rn = 1_000_000 + next_int() % 1_000_000_000 + amt = 1 + next_int() % (min(ry, rn) // 2) + fee = next_int() % 200 + ifee = next_int() % 200 + q = calc_buy_with(ry, rn, amt, fee, ifee) + assert q.net + q.fee + q.integrator_fee == amt, q + try: + s = calc_sell_with(ry, rn, amt, fee, ifee) + except MathError: + continue + assert s.gross == amt + s.fee + s.integrator_fee, s + + +def _check_calc_buy_with_zero_integrator_matches_calc_buy(trials: int = 2_000): + next_int = _lcg_stream() + for _ in range(trials): + ry = 1_000_000 + next_int() % 1_000_000_000 + rn = 1_000_000 + next_int() % 1_000_000_000 + amt = 1 + next_int() % (min(ry, rn) // 2) + fee = next_int() % 300 + q1 = calc_buy(ry, rn, amt, fee) + q2 = calc_buy_with(ry, rn, amt, fee, 0) + assert (q1.shares_out, q1.fee, q1.net, q1.new_reserve_bought, q1.new_reserve_other) == ( + q2.shares_out, + q2.fee, + q2.net, + q2.new_reserve_bought, + q2.new_reserve_other, + ), (q1, q2) + assert q2.integrator_fee == 0 + + +def _check_calc_sell_with_zero_integrator_matches_calc_sell(trials: int = 2_000): + next_int = _lcg_stream() + for _ in range(trials): + ry = 1_000_000 + next_int() % 1_000_000_000 + rn = 1_000_000 + next_int() % 1_000_000_000 + amt = 1 + next_int() % (rn // 2) + fee = next_int() % 300 + q1 = calc_sell(ry, rn, amt, fee) + q2 = calc_sell_with(ry, rn, amt, fee, 0) + assert (q1.shares_in, q1.fee, q1.gross, q1.new_reserve_sold, q1.new_reserve_other) == ( + q2.shares_in, + q2.fee, + q2.gross, + q2.new_reserve_sold, + q2.new_reserve_other, + ), (q1, q2) + assert q2.integrator_fee == 0 + + +def _check_sell_split_never_shortchanges_protocol(trials: int = 2_000): + next_int = _lcg_stream() + for _ in range(trials): + ry = 1_000_000 + next_int() % 1_000_000_000 + rn = 1_000_000 + next_int() % 1_000_000_000 + amt = 1 + next_int() % (rn // 4) + fee = next_int() % 200 + ifee = next_int() % 200 + try: + q_with = calc_sell_with(ry, rn, amt, fee, ifee) + q_solo = calc_sell(ry, rn, amt, fee) + except MathError: + continue + assert q_with.fee >= q_solo.fee, "protocol shortchanged" + assert q_with.fee + q_with.integrator_fee == q_with.gross - amt + + +def _check_round_trip_with_integrator_never_profits(trials: int = 2_000): + next_int = _lcg_stream() + for _ in range(trials): + ry = 1_000_000 + next_int() % 1_000_000_000 + rn = 1_000_000 + next_int() % 1_000_000_000 + amt = 1 + next_int() % (min(ry, rn) // 2) + fee = next_int() % 200 + ifee = next_int() % 100 + k0 = ry * rn + q = calc_buy_with(ry, rn, amt, fee, ifee) + assert q.new_reserve_bought * q.new_reserve_other >= k0 + try: + s = calc_sell_with(q.new_reserve_bought, q.new_reserve_other, amt, fee, ifee) + except MathError: + continue + assert s.shares_in >= q.shares_out, "free money with integrator" + + def _check_seed_opens_at_requested_probability(): for p in (100, 2500, 5000, 7300, 9900): s = seed_reserves(10_000_000_000, p) @@ -382,6 +597,12 @@ def _check_shared_vectors(): def run_all(): _check_known_values() _check_shared_vectors() + _check_integrator_known_values() + _check_integrator_accounting_is_exact() + _check_calc_buy_with_zero_integrator_matches_calc_buy() + _check_calc_sell_with_zero_integrator_matches_calc_sell() + _check_sell_split_never_shortchanges_protocol() + _check_round_trip_with_integrator_never_profits() _check_seed_opens_at_requested_probability() _check_k_never_decreases_and_round_trip_never_profits() _check_gross_for_shares_inverts_calc_sell()