From a6edc51036ec34eeb7a5e839092327c12b644319 Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Wed, 21 Oct 2026 11:20:39 +0100 Subject: [PATCH 01/11] feat(market-core): refundable creation bond with guardian-fault forfeiture (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an admin-set creation bond (`set_creation_bond` / `get_creation_bond`, instance key `DataKey::CreationBond`, absent = 0 so fresh and legacy instances keep today's behaviour). Every new market snapshots the bond into a `DataKey::Bond(u64)` sidecar — `Market`'s XDR shape is untouched, same pattern as `FeeTerms(u64)` — and the creator's seed and bond leave in ONE transfer so the treasury's `(from, to, amount)` pre-authorisation triple keeps matching. Settlement is a new permissionless `claim_bond(market_id)` entrypoint, callable only after finality: on a plain void or a resolution the bond refunds to the creator; on a guardian-fault void (`void_market_with_reason(id, VoidReason::GuardianFault)`) it is forfeited to the fee-recipient snapshot taken at void time, so a later treasury migration cannot move the penalty. Existing `void_market` is unchanged and stays NoFault. Legacy markets without a sidecar entry reject the claim with `NoBond`. The append-only `DataKey`/`Error` tails also reserve #24's entries (`MaxIntegratorFeeBps`, `MarketMaxIntegratorFee(u64)`, `IntegratorFees(Address)`, `IntegratorFeeTooHigh = 27`) so the enums are appended once across this PR. 14 bond tests authored (refund, forfeiture, forfeit-recipient snapshot, double-claim, pre-finality, legacy markets, auth negatives, multi-market solvency invariant extended with held bonds). Not executed here: this checkout's gate is `cargo +stable fmt -p market-core -- --check` (exit 0, clean baseline at tip). --- .../contracts/market-core/src/lib.rs | 585 +++++++++++++++++- 1 file changed, 584 insertions(+), 1 deletion(-) diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 2b6c6b7..33a75de 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,33 @@ 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, +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -564,11 +662,43 @@ 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) + } + // ----- 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 +736,7 @@ 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 market = Market { creator: creator.clone(), @@ -646,7 +777,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, @@ -965,7 +1120,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 +1154,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 +1179,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. @@ -1248,6 +1491,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 +1729,25 @@ 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); + } + /// 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 +3335,320 @@ 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; + } } // --------------------------------------------------------------------------- From 6800477439607dbb495b468ffcaf8a2f5b49768d Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Wed, 21 Oct 2026 11:46:11 +0100 Subject: [PATCH 02/11] feat(market-core): integrator fees on buy/sell with per-market caps (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `buy_via` / `sell_via` (and `quote_buy_via` / `quote_sell_via`) — additive entrypoints that route an extra `integrator_fee_bps` cut to an agent/integrator. Buys charge the integrator on top of the protocol fee (both round up, so rounding keeps favouring the pool); sells widen the gross spread by the integrator bps and carve the integrator portion out with floor division, so the protocol never collects less than plain `sell` would have (`fee + integrator_fee == gross - collateral_out` is exact). `calc_buy`/`calc_sell` are now `calc_buy_with`/`calc_sell_with` with bps = 0 and produce byte-identical quotes — the shared-vectors test and existing fill accounting are untouched. Caps (#24): an admin opts the platform in with `set_max_integrator_fee_bps`, validated against the current default fee so `fee + cap <= MAX_FEE_BPS`; `create_market` snapshots the cap into a `MarketMaxIntegratorFee(u64)` sidecar (absent = 0, integrator routing disabled), so later admin changes can neither widen nor narrow what an existing market honours. `Market`'s XDR shape again stays frozen. `claim_integrator_fees(integrator)` pays the cross-market balance (CEI: balance removed before transfer, zero-balance claim is a no-op returning 0) and is auth'd by the integrator only; accruals are credited without integrator consent so routing needs no registration. 17 tests authored here (11 entrypoint/accrual/cap/claim/auth/solvency tests in lib.rs, 6 math tests including zero-equivalence and split-never-shortchanges-property LCG sweeps; the solvency invariant helper from the bond commit already carries the integrator term). Gate on this checkout: `cargo +stable fmt -p market-core -- --check` exit 0; tests are not executed (repo does not compile at develop tip). --- .../contracts/market-core/src/lib.rs | 652 +++++++++++++++++- .../contracts/market-core/src/math.rs | 208 +++++- 2 files changed, 843 insertions(+), 17 deletions(-) diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 33a75de..2a265df 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -464,6 +464,26 @@ pub struct BondSettled { 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 // --------------------------------------------------------------------------- @@ -689,6 +709,37 @@ impl MarketCore { .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. @@ -737,6 +788,7 @@ impl MarketCore { 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(), @@ -765,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, @@ -838,6 +896,7 @@ impl MarketCore { collateral_in, min_shares_out, None, + None, ) } @@ -876,11 +935,42 @@ impl MarketCore { } else { Some(limit_price_bps) }, + None, ) } - /// Shared implementation of `buy` / `buy_v2` (single copy of the - /// breaker, effect and interaction logic). + /// `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` / `buy_via` (single copy of + /// the breaker, effect and interaction logic). #[allow(clippy::too_many_arguments)] fn do_buy( env: Env, @@ -890,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 { @@ -901,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); } @@ -932,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, @@ -980,6 +1085,7 @@ impl MarketCore { collateral_out, max_shares_in, None, + None, ) } @@ -1016,10 +1122,39 @@ impl MarketCore { } else { Some(limit_price_bps) }, + None, ) } - /// Shared implementation of `sell` / `sell_v2`. + /// `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` / `sell_via`. #[allow(clippy::too_many_arguments)] fn do_sell( env: Env, @@ -1029,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); } @@ -1076,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 @@ -1096,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. @@ -1484,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> { @@ -1748,6 +1973,66 @@ impl MarketCore { .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`. @@ -3649,6 +3934,363 @@ mod test { 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()); + } } // --------------------------------------------------------------------------- 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"); + } + } + } } From 19bca019cecf5f64c6b7c48f51a1b594fd5d81cc Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Wed, 21 Oct 2026 12:13:52 +0100 Subject: [PATCH 03/11] feat(resolver): route guardian-fault voids to market-core by name (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `guardian_void_market_v2(market_id, reason, at_fault)` — the same validation as `guardian_void_market` (shared `guardian_void_inner`) plus an explicit at-fault flag for the creator's refundable bond. With `at_fault = true` the void is forwarded to market-core's `void_market_with_reason(id, GuardianFault)` via **name dispatch** (`env.try_invoke_contract` + `Symbol`), because `contractimport!` binds this crate to the vendored `market_core.wasm`, whose generated client does not know the new function until `make build` regenerates it. `VoidReason` is an all-unit-variant `#[contracttype]` enum and encodes as a plain `ScVal::Symbol`, mirrored locally so the call is wire-compatible across that boundary. `guardian_void_market` itself keeps its exact signature, call path (generated `void_market` client = NoFault) and event set: existing callers, tests and the Invalid-verdict, expire_dispute and finalize_signers void paths are untouched, per the frozen-entrypoint rule. A new `GuardianVoided { market_id, at_fault }` event is emitted only on the v2 entrypoint, alongside the existing `GuardianAction`. Appends `Error::MarketCoreCallFailed = 44` (append-only tail; #241 also claims 44 on its branch — last merger re-appends). 3 resolver tests authored (NoFault parity + event, at-fault forwarding + event, shared validation and admin auth). The at-fault test passes only after `market_core.wasm` regeneration; with the committed pre-#23 blob the dispatch reverts the whole call with `MarketCoreCallFailed` and no state is touched. Tests not executed here (resolver does not compile at develop tip: `core_call` is used but defined nowhere — pre-existing, out of scope; my additions are self-contained and avoid it). Gate: `cargo +stable fmt -p resolver -- --check` flags exactly the same 12 pre-existing hunks as the untouched-tip baseline (bodies verified identical, only line numbers shifted); zero new formatting diffs in added ranges. --- .../contracts/resolver/src/lib.rs | 221 +++++++++++++++++- 1 file changed, 220 insertions(+), 1 deletion(-) diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index edfcc9b..b451525 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)) @@ -5531,4 +5653,101 @@ mod golden { let b = DeclaredReversed { beta: 2, alpha: 1 }.to_xdr(&env); assert_eq!(a, b); } + + // ----- Issue #23: at-fault guardian void routing ----------------------- + // NOTE: the at-fault path name-dispatches `void_market_with_reason`, + // which only exists in market-core once `market_core.wasm` is + // regenerated for this branch (`make build`). With the vendored blob + // that predates the feature the dispatch fails with + // `MarketCoreCallFailed` and the whole guardian call reverts — no state + // corruption either way. The tests below assert the intended behaviour + // and are expected to pass against the regenerated blob only. + + #[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() { + 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 market-core bond sidecar now reads Forfeited-on-settlement; + // asserting it here needs the regenerated core client bindings, so + // the forfeiture end-to-end case lives with the market-core tests + // (`bond_forfeited_on_guardian_fault_void`). + } + + #[test] + fn guardian_void_v2_shares_validation_and_auth() { + // terminal-state guard applies to the new entrypoint too + 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()); + } } From 5823ce34d42f3f85327d5697fca414fb5a633056 Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Thu, 22 Oct 2026 09:46:12 +0100 Subject: [PATCH 04/11] fix(resolver): move #23 guardian-void tests into the module that owns their helpers The three `guardian_void_v2_*` tests were appended inside `mod golden`, whose `use super::*` pulls from the crate root. `dispute_fixture`, `open`, `status`, `signer_fixture`, `DEADLINE`, `McImpl`, `with_market_core`, `MockAuth` and `MockAuthInvoke` are all declared inside `mod test`, so the block referenced helpers that are not in scope there and `cargo test -p resolver` would not build. Relocated into `mod test`, directly after the `both_modes!` invocation. Also pinned the vendored-blob reality rather than asserting past it: the at-fault route name-dispatches `void_market_with_reason`, which the vendored `market_core.wasm` does not export, so - `guardian_void_v2_at_fault_forwards_guardian_fault` now runs under an explicit `with_market_core(McImpl::Native, ..)` (the existing idiom in `native_and_wasm_market_core_agree`) instead of defaulting to the wasm registration where the dispatch cannot resolve; and - a new `guardian_void_v2_at_fault_fails_closed_on_stale_wasm` asserts the default path fails closed with `MarketCoreCallFailed`, leaves the market `Open`/`Disputed` and publishes no `GuardianVoided` event. Co-Authored-By: anitahhhh --- .../contracts/resolver/src/lib.rs | 249 +++++++++++------- 1 file changed, 152 insertions(+), 97 deletions(-) diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index b451525..915405a 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -5388,6 +5388,158 @@ 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)] @@ -5653,101 +5805,4 @@ mod golden { let b = DeclaredReversed { beta: 2, alpha: 1 }.to_xdr(&env); assert_eq!(a, b); } - - // ----- Issue #23: at-fault guardian void routing ----------------------- - // NOTE: the at-fault path name-dispatches `void_market_with_reason`, - // which only exists in market-core once `market_core.wasm` is - // regenerated for this branch (`make build`). With the vendored blob - // that predates the feature the dispatch fails with - // `MarketCoreCallFailed` and the whole guardian call reverts — no state - // corruption either way. The tests below assert the intended behaviour - // and are expected to pass against the regenerated blob only. - - #[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() { - 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 market-core bond sidecar now reads Forfeited-on-settlement; - // asserting it here needs the regenerated core client bindings, so - // the forfeiture end-to-end case lives with the market-core tests - // (`bond_forfeited_on_guardian_fault_void`). - } - - #[test] - fn guardian_void_v2_shares_validation_and_auth() { - // terminal-state guard applies to the new entrypoint too - 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()); - } } From d49aaab1a9a2d2fb9f452d4eb014cbdb5764a372 Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Thu, 22 Oct 2026 09:51:20 +0100 Subject: [PATCH 05/11] test(market-core): cap-boundary, zero-fee and symbol-encoding cases for #23/#24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the coverage gaps against the issue's money invariants: - `buy_via_fee_at_exact_market_cap_is_accepted` / `combined_take_at_exact_max_fee_bps_is_accepted` pin both `>` boundaries (`bps > cap`, `bps + fee_bps > MAX_FEE_BPS`), which the rejecting-side tests alone leave unfalsifiable. - `sell_via_rejects_fee_above_market_cap` — the cap was only exercised on buy. - `buy_via_with_zero_integrator_fee_matches_buy` / `sell_via_with_zero_integrator_fee_matches_sell` prove #24's compatibility criterion at contract level on two identically seeded markets: equal shares, reserves, sets_minted, fees_accrued, and no integrator balance opened. - `claim_integrator_fees_second_call_is_a_noop` and `integrator_fees_accumulate_across_markets` pin zero-before-transfer and the per-integrator-across-markets accrual shape. - `void_reason_encodes_as_plain_symbol_name` asserts the single encoding claim the whole #23 resolver route rests on, instead of only asserting it in a comment. Co-Authored-By: anitahhhh --- .../contracts/market-core/src/lib.rs | 271 ++++++++++++++++++ .../contracts/resolver/src/lib.rs | 12 +- 2 files changed, 280 insertions(+), 3 deletions(-) diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 2a265df..04ea440 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -4291,6 +4291,277 @@ mod test { .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/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index 915405a..61403f9 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -5492,7 +5492,10 @@ mod test { ); // 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); + 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, @@ -5518,8 +5521,11 @@ mod test { 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), + f.resolver.try_guardian_void_market_v2( + &f.market_id, + &GuardianReason::SignersStalled, + &true + ), Err(Ok(Error::AlreadyFinalized)) ); From cb5113b6b213eeede83b0bd25833a7d48657c0ec Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Thu, 22 Oct 2026 09:59:31 +0100 Subject: [PATCH 06/11] feat(treasury): fund and cap the creation bond in agent_create_market (#23) market-core now pulls `seed_amount + creation_bond` in one transfer, so treasury-created markets would have failed their nested-transfer pre-authorisation: `authorize_transfer` matches a single exact `(from, to, amount)` triple, and the treasury was authorizing `seed` only. - `core_creation_bond` reads `get_creation_bond` by name via `env.try_invoke_contract`, because `contractimport!` generates `MarketCoreClient` from the vendored `market_core.wasm`, which predates the view; a client-based call would not compile until `make build` regenerates the blob. Same mechanism `core_trade` already uses for trades. - **Absent means zero.** A pre-#23 core has no such function, and every dispatch failure degrades to `0` rather than to `MarketCoreCallFailed`, so treasury keeps funding legacy cores. Fail-closed in the dangerous direction: under-reporting a required bond leaves the nested transfer unauthorised and the whole creation reverts. - `total_outlay = seed + bond` is what `ensure_liquid`, `ensure_unreserved`, `authorize_token_pull` and `reserve_capacity` (market + daily caps) now see. - Net position exposure and P&L cost basis stay on the seed: the bond is refundable collateral, not a position, so it is not double-counted against `DataKey::Net(id)`/`Pnl(id)`. A refund or forfeit back into this treasury lands in `idle()` and so reaches `nav()` exactly once, with no receivable tracked anywhere. - `check_trade_size(total)` runs only when `bond > 0`. `agent_create_market` has never checked `max_trade` on the seed itself (it checks `creation_policy.max_seed`); applying the check unconditionally would silently tighten that pre-existing, unrelated gap and would preempt `SeedCapExceeded` in `agent_create_market_rejects_seed_above_trade_cap`. Tests: `agent_create_market_counts_bond_toward_caps`, `agent_create_market_rejects_seed_plus_bond_above_trade_cap` (both under an explicit `McImpl::Native` registration, dispatched by name for the same reason) and `agent_create_market_funds_no_bond_against_pre_23_core`, which pins the default wasm registration degrading to a seed-only pull. Co-Authored-By: anitahhhh --- .../contracts/treasury/src/lib.rs | 222 +++++++++++++++++- 1 file changed, 218 insertions(+), 4 deletions(-) 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(); From 28e313ec4c35780885cd23ba22c240b8173e165c Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Thu, 22 Oct 2026 10:01:09 +0100 Subject: [PATCH 07/11] docs(adr): sidecar keys and name dispatch for the creation bond and integrator fees (#23, #24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0010 records the two decisions every #23/#24 consumer depends on: new per-market state goes in appended `DataKey` sidecars with absent-means-default (never a new `Market` field, which would break the three vendored-blob decoders), and new cross-contract entrypoints are reached by name (`env.try_invoke_contract` + the `ScVal::Symbol("Variant")` encoding of an all-unit `#[contracttype]` enum), so nothing here waits on `make build`. Also documents the two properties that are easy to get wrong later: why a forfeited bond is not a treasury receivable (kept out of `Net(id)`/`Pnl(id)` so a refund is not read as position profit), and why `core_creation_bond` treats a dispatch failure as `0` rather than as an error. `docs/architecture.md`: the solvency invariant is stated in its global form (`+ Σ held bonds + Σ unclaimed integrator balances`), matching `test::assert_global_solvency`, plus lifecycle notes for the bond and the integrator-fee paths. Lists as required follow-up what this host cannot do: `make build` (stellar-cli 22.0.0 vs 27.1.0, rustc pinned to 1.90.0 by #58), `make bindings`, and the Python/TS reference mirrors. Co-Authored-By: anitahhhh --- .../docs/adr/0010-bond-and-fee-sidecars.md | 171 ++++++++++++++++++ oracledesk-stellar/docs/architecture.md | 35 ++++ 2 files changed, 206 insertions(+) create mode 100644 oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md 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..1bd26a3 --- /dev/null +++ b/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md @@ -0,0 +1,171 @@ +# 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 our own #239/#240/#241/#242, each of + which also appended at market-core `Error` 27 and resolver `Error` 44. 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. + +**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 Python/TypeScript reference mirrors (`model/fpmm_model.py`, + `agents/core/fpmm.ts`) for the integrator-fee split. This host has neither + numpy nor `node_modules`, so committing them would mean committing code that + provably cannot be run. +- `docs/architecture.md` and `docs/frontend-integration.md` should show the bond + and the total pulled at creation, per #23's step 9. 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 From edad847958a101d439015cb0574b210e74fa6000 Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Thu, 22 Oct 2026 10:09:57 +0100 Subject: [PATCH 08/11] docs(frontend-integration): how to pass an integrator and show the bond (#23, #24) The two issues' acceptance criteria include the front-end contract: a UI that displays only `seed_amount` at creation produces transactions that fail on balance, and an integrator that derives `min_shares_out` from `quote_buy` over-states its own trade by the fee it is charging. Documents the three contract-enforced fee rules, which quote to use for the bound, that `Trade` is unchanged in favour of `IntegratorFeeAccrued`, the bond settlement table, and the real gap that `buy_via`/`sell_via` carry neither deadline nor limit price. Co-Authored-By: Claude --- .../docs/frontend-integration.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) 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 From 439d51632d5201d18c5559bc37ec21a9b6e41828 Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Thu, 22 Oct 2026 10:19:02 +0100 Subject: [PATCH 09/11] test(model): Python mirror of calc_buy_with / calc_sell_with (#24) Port of the #24 integrator-fee math to model/fpmm_model.py, with the known-value literals transcribed from math.rs's buy_with_integrator_known_values / sell_with_integrator_known_values and the property sweeps driven by the same LCG stream math.rs uses, so the two implementations sweep identical inputs instead of independent random ones. Adds the collateral-identity sweep the contract asserts on chain (collateral_in == net + fee + ifee, gross == collateral_out + fee + ifee), because a break there is a break in the solvency invariant. This is the only gate on this host that actually executes: python3 model/fpmm_model.py passes, each new check runs individually, and a mutated expectation fails. BuyQuote/SellQuote deliberately keep their shape and the "with" variants get their own dataclasses, since _check_shared_vectors compares __dict__ against the committed vectors.jsonl and a new field would invalidate every calc_buy line; math.rs can carry the field because its shared_vectors_match enumerates the compared keys explicitly. Co-Authored-By: Claude --- oracledesk-stellar/model/fpmm_model.py | 221 +++++++++++++++++++++++++ 1 file changed, 221 insertions(+) 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() From 0c6271ccf12d58f0938a560477a822dbf28e3bbf Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Thu, 22 Oct 2026 10:23:24 +0100 Subject: [PATCH 10/11] docs(adr): record the Python mirror as landed and name the sibling tails precisely (#24) ADR 0010 listed the Python mirror as follow-up work it could not do. That was wrong about the constraint: model/fpmm_model.py runs here, so its checks are executed rather than merely authored. Only the TypeScript mirror stays out, because Node on this host cannot strip types. Also drops the claim that resolver's Error 44 collides with #239/#240/#241: it is #241 (QuorumNotReached) and #242 (EventTimeMismatch) that reach 44, verified from each PR's own patch. Co-Authored-By: Claude --- .../docs/adr/0010-bond-and-fee-sidecars.md | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md b/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md index 1bd26a3..1b3165d 100644 --- a/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md +++ b/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md @@ -147,10 +147,12 @@ caller-side checks. 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 our own #239/#240/#241/#242, each of - which also appended at market-core `Error` 27 and resolver `Error` 44. 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. +- The enum tails collide by design with sibling PRs: #239 appends + `ModeRestricted = 27`, #240 `DirectoryEntryArchived = 27`, and #242 + `AlreadyHalted = 27` + `HaltTooOld = 28` (verified by reading each PR's patch, + not inferred). 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. **Required follow-up, not doable here** @@ -163,9 +165,14 @@ caller-side checks. `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 Python/TypeScript reference mirrors (`model/fpmm_model.py`, - `agents/core/fpmm.ts`) for the integrator-fee split. This host has neither - numpy nor `node_modules`, so committing them would mean committing code that - provably cannot be run. -- `docs/architecture.md` and `docs/frontend-integration.md` should show the bond - and the total pulled at creation, per #23's step 9. +- 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. From 1820f9856e4c48327d8ad6f36f61f92554888e1c Mon Sep 17 00:00:00 2001 From: anitahhhh Date: Thu, 22 Oct 2026 10:24:03 +0100 Subject: [PATCH 11/11] docs(adr): name the resolver Error 44 collision and the literal-vs-variant rule (#23, #24) `develop`'s resolver enum ends at 43, so `MarketCoreCallFailed = 44` reaches for the same number as #241's `QuorumNotReached` and #242's `EventTimeMismatch`. The renumber rule is worth writing down where the next contributor will hit it: asserting the named variant survives the renumber, asserting the numeric literal does not. Co-Authored-By: Claude --- .../docs/adr/0010-bond-and-fee-sidecars.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md b/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md index 1b3165d..f40db66 100644 --- a/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md +++ b/oracledesk-stellar/docs/adr/0010-bond-and-fee-sidecars.md @@ -149,10 +149,17 @@ caller-side checks. `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` (verified by reading each PR's patch, - not inferred). 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. + `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**