From a6feee310583156a5c15ac4fb14f43797cf77d4f Mon Sep 17 00:00:00 2001 From: Emmazdev Date: Fri, 16 Oct 2026 15:30:12 +0100 Subject: [PATCH] fix: Support collateral of any decimals with 256-bit mul_div inte (#3) --- oracledesk-stellar/README.md | 15 + oracledesk-stellar/agents/core/fpmm.ts | 64 +- .../contracts/market-core/src/lib.rs | 874 +++++------------- .../contracts/market-core/src/math.rs | 410 +++++++- oracledesk-stellar/docs/STATUS.md | 24 +- oracledesk-stellar/docs/architecture.md | 29 +- oracledesk-stellar/model/fpmm_model.py | 60 +- oracledesk-stellar/scripts/deploy-testnet.sh | 45 +- 8 files changed, 830 insertions(+), 691 deletions(-) mode change 100755 => 100644 oracledesk-stellar/scripts/deploy-testnet.sh diff --git a/oracledesk-stellar/README.md b/oracledesk-stellar/README.md index 85898ef..0c2bc31 100644 --- a/oracledesk-stellar/README.md +++ b/oracledesk-stellar/README.md @@ -18,6 +18,15 @@ are all native Soroban contracts, collateralized in a SEP-41 USDC token. Polymarket survives only as an optional, execution-free reference price an agent's strategy may read (see `agents/core/strategy.ts`). +`market-core` reads the collateral token's `decimals()` once at deploy and +derives its economic bounds from it (`min_seed = 100 * 10^d`, +`max_sets = 1e8 * 10^d`, supported `d` in `0..=18`), so any SEP-41 token — +7-decimal Classic USDC, 18-decimal Soroban-native assets, or bridged tokens — +can back a market without changing the contract. The fixed-point helpers use +256-bit intermediates, so a result that fits in `i128` is always returned +exactly regardless of `d`. The Python (`model/fpmm_model.py`) and TypeScript +(`agents/core/fpmm.ts`) mirrors track the same bounds and overflow behavior. + ## Architecture ```mermaid @@ -102,6 +111,12 @@ deployments/ testnet.json — the current live testnet addresses docs/ architecture, ADRs, frontend-integration, STATUS ``` +The deploy script self-issues a 7-decimal Classic asset as test USDC. To +exercise other decimals, deploy a SEP-41 token with the desired `decimals()` +first and pass its contract ID as the collateral; `market-core`'s +`__constructor` will reject anything above 18 decimals with +`Error::UnsupportedDecimals`. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup, branch/commit diff --git a/oracledesk-stellar/agents/core/fpmm.ts b/oracledesk-stellar/agents/core/fpmm.ts index 4bf976b..01aea4f 100644 --- a/oracledesk-stellar/agents/core/fpmm.ts +++ b/oracledesk-stellar/agents/core/fpmm.ts @@ -5,28 +5,68 @@ * plus a property check that `collateralOutForShares` (the off-chain * inverse for "sell N shares") round-trips through `calcSell`. * - * All amounts are non-negative bigints in collateral base units (USDC on - * Stellar = 7 decimals). Rounding always favours the pool, matching the - * contract. + * All amounts are non-negative bigints in collateral base units. The + * collateral decimals are discovered at deploy time by the contract and + * drive the economic bounds (`min_seed` / `max_sets`). This mirror + * exposes `boundsForDecimals` so agents can derive the same bounds. + * Rounding always favours the pool, matching the contract. */ export const BPS = 10_000n; -export const MAX_SETS = 1_000_000_000_000_000n; // 1e15 = 100M USDC (7 dp) -export const MIN_PROB_BPS = 100n; // 1% -export const MAX_PROB_BPS = 9_900n; // 99% + +/** + * @deprecated 7-decimal alias kept for tests and legacy callers. + * Contract logic derives the bound from collateral decimals instead; + * use `boundsForDecimals`. + */ +export const MAX_SETS = 1_000_000_000_000_000n ; // 1e15 = 100M USDC (7 dp) + +export const MIN_PROB_BPS = 100n ; // 1% +export const MAX_PROB_BPS = 9_900n ; // 99% + +export const I18_MAX = (1n << 127n) - 1n ; // i128::max + +export const MAX_SUPPORTED_DECIMALS = 18; export class MathError extends Error {} +/** + * Derived economic bounds for a collateral with `d` decimals. + * Mirrors `math::baunds_for_decimals` in the Rust contract. + */ +export interface Bounds { + minSeed: bigint; + maxSets: bigint; +} + +export function boundsForDecimals(d: number): Bounds { + if (!Number.isInteger(d) || d < 0 || d > MAX_SUPPORTED_DECIMALS) { + throw new MathError("unsupported decimals"); + } + const scale = 10n ** BigInt(d); + const minSeed = 100n * scale; + const maxSets = 100_000_000n * scale; // 1e8 * 10^d + if (minSeed > I128_MAX || maxSets > I128_MAX) { + throw new MathError("overflow"); + } + return { minSeed, maxSets }; +} + function mulDivFloor(a: bigint, b: bigint, c: bigint): bigint { if (a < 0n || b < 0n || c <= 0n) throw new MathError("invalid input"); - return (a * b) / c; + const q = (a * b) / c; + if (q > I18_MAX) throw new MathError("overflow"); + return q; } function mulDivCeil(a: bigint, b: bigint, c: bigint): bigint { if (a < 0n || b < 0n || c <= 0n) throw new MathError("invalid input"); const p = a * b; const q = p / c; - return p % c !== 0n ? q + 1n : q; + const r = p % c; + const result = r !== 0n ? q + 1n : q; + if (result > I128_MAX) throw new MathError("overflow"); + return result; } export function priceYesBps(reserveYes: bigint, reserveNo: bigint): bigint { @@ -117,12 +157,14 @@ export type Settlement = "yes" | "no" | "void"; export function settleValue(kind: Settlement, yes: bigint, no: bigint): bigint { if (kind === "yes") return yes; if (kind === "no") return no; - return (yes + no) / 2n; + const sum = yes + no; + if (sum > I128_MAX) throw new MathError("overflow"); + return sum / 2n; } /** - * Integer square root (Newton's method), needed because `sell` takes an - * exact collateral amount rather than a share count (no on-chain sqrt — see + * Integer square root (Newton's method), needed because `sell` takes + * an exact collateral amount rather than a share count (no on-chain sqrt — see * the docstring on `sell` in contracts/market-core/src/lib.rs). This is * what makes `grossForShares` computable in BigInt without floating point. */ diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index d8b8a69..ff177f1 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -1,16 +1,16 @@ -#![no_std] +#`!no_std] mod math; -use math::{MathError, Settlement, BPS, MAX_SETS}; +use math::{MathError, Settlement, BPS}; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, panic_with_error, token, Address, BytesN, Env, String, }; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- + // Constants ++/ --------------------------------------------------------------------------- /// Ledger close time is ~5s, so 17_280 ledgers ~ 1 day. Tune if that changes. const DAY_IN_LEDGERS: u32 = 17_280; @@ -19,19 +19,23 @@ const BUMP_TO: u32 = 60 * DAY_IN_LEDGERS; /// Fee ceiling: 10%. const MAX_FEE_BPS: u32 = 1_000; -/// Minimum seed liquidity: 100 USDC (7 decimals). Stops spam markets. -const MIN_SEED: i128 = 100 * 10_000_000; -// --------------------------------------------------------------------------- -// Types shared with the frontend/backend -// --------------------------------------------------------------------------- -// Generate TypeScript bindings straight from the compiled contract +/// Highest collateral decimals we support. SEP-41 tokens commonly use 7 +/// (Stellar Classic) or 18 (Soroban-native / bridged). Anything above 18 +/// would push `max_sets = 1e8 * 10^d` past the point where the derived +/// bounds stay meaningful, so we reject it at construction. +const MAX_DECIMALS: u32 = 18; + +// ---------------------------------------------------------------------------- + // Types shared with the frontend/backend +// ---------------------------------------------------------------------------- + // Generate TypeScript bindings straight from the compiled contract // (`stellar contract bindings typescript`) so enums like `Category` cannot // drift between the contract, the API and the UI. #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Outcome { +put enum Outcome { Yes, No, } @@ -71,7 +75,7 @@ pub struct Market { /// against this hash before configuring itself for the market. See /// `docs/adr/0001-resolution-commitment.md`. pub resolution_hash: BytesN<32>, - /// IPFS CID / URI of the question metadata. + /// IPFS cID - URI of the question metadata. pub meta_uri: String, pub category: Category, /// Unix seconds (ledger timestamp). Trading stops at this time. @@ -105,11 +109,15 @@ pub struct Config { pub resolver: Address, pub default_fee_bps: u32, pub paused: bool, + /// Collateral token decimals, read once at construction. Consumers must + /// re-sync generated bindings: this field changes the XDR shape of + /// `Config`. + pub decimals: u32, } -// --------------------------------------------------------------------------- -// Storage keys -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- + // Storage keys +// ---------------------------------------------------------------------------- #[contracttype] pub enum DataKey { @@ -121,17 +129,18 @@ pub enum DataKey { DefaultFeeBps, Paused, NextId, + Decimals, // persistent storage (one entry each, own TTL) Market(u64), Position(u64, Address), } -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- + // Errors +// ---------------------------------------------------------------------------- #[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd)] #[repr(u32)] pub enum Error { NotInitialized = 1, @@ -152,6 +161,7 @@ pub enum Error { NothingToRedeem = 16, AlreadyClaimed = 17, Overflow = 18, + UnsupportedDecimals = 19, } impl From for Error { @@ -164,10 +174,10 @@ impl From for Error { } } -// --------------------------------------------------------------------------- -// Events (the backend indexes these into Postgres; RPC keeps only a short +// ---------------------------------------------------------------------------- + // Events (the backend indexes these into Postgres; RPC keeps only a short // event window, so do not rely on querying old events live) -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- #[contractevent] pub struct MarketCreated { @@ -208,7 +218,7 @@ pub struct Redeemed { #[topic] pub market_id: u64, #[topic] - pub holder: Address, + pubholder: Address, pub payout: i128, } @@ -219,9 +229,9 @@ pub struct FeesSwept { pub amount: i128, } -// --------------------------------------------------------------------------- -// Contract -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- + // Contract +// ---------------------------------------------------------------------------- #[contract] pub struct MarketCore; @@ -229,6 +239,11 @@ pub struct MarketCore; #[contractimpl] impl MarketCore { /// Runs atomically at deploy time, so nobody can front-run initialization. + /// + /// Reads the collateral token's `decimals()` once and stores it. The + /// economic bounds (`min_seed`, `max_sets`) are derived from it on every + /// call, so a token with 18 decimals gets a 1e-9-token floor and a + /// 1e26-token cap instead of the 7-decimal defaults. pub fn __constructor( env: Env, admin: Address, @@ -240,6 +255,10 @@ impl MarketCore { if default_fee_bps > MAX_FEE_BPS { panic_with_error!(&env, Error::InvalidFee); } + let decimals = token::TokenClient::new(&env, &collateral).decimals(); + if decimals > MAX_DECIMALS { + panic_with_error!(&env, Error::UnsupportedDecimals); + } let s = env.storage().instance(); s.set(&DataKey::Admin, &admin); s.set(&DataKey::Collateral, &collateral); @@ -247,15 +266,41 @@ impl MarketCore { s.set(&DataKey::Resolver, &resolver); s.set(&DataKey::DefaultFeeBps, &default_fee_bps); s.set(&DataKey::Paused, &false); - s.set(&DataKey::NextId, &0u64); + s.set(&DataKey::NextId, &pu64); + s.set(&DataKey::Decimals, &decimals); + } + + // ----- views ---------------------------------------------------------------------- + + pub fn get_config(env: Env) -> Result { + let s = env.storage().instance(); + Ok(Config { + admin: s.get(&DataKey::Admin).ok_or(Error::NotInitialized)?, + collateral: s.get(&DataKey::Collateral).ok_or(Error::NotInitialized)?, + treasury: s.get(&DataKey::Treasury).ok_or(Error::NotInitialized)?, + resolver: s.get(&DataKey::Resolver).ok_or(Error::NotInitialized)?, + default_fee_bps: s + .get(&DataKey::DefaultFeeBps) + .ok_or(Error::NotInitialized)?, + paused: s.get(&DataKey::Paused).ok_or(Error::NotInitialized)?, + decimals: s.get(&DataKey::Decimals).ok_or(Error::NotInitialized)?, + }) + } + + /// Collateral token decimals, read once at construction. + pub fn decimals(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Decimals) + .ok_or(Error::NotInitialized) } - // ----- admin ---------------------------------------------------------- + // ----- admin --------------------------------------------------------------------- pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> { Self::require_admin(&env)?; env.storage().instance().set(&DataKey::Paused, &paused); - Ok(()) + Ok() } /// Affects markets created after the call; existing markets keep their fee. @@ -267,22 +312,22 @@ impl MarketCore { env.storage() .instance() .set(&DataKey::DefaultFeeBps, &fee_bps); - Ok(()) + Ok() } pub fn set_resolver(env: Env, resolver: Address) -> Result<(), Error> { Self::require_admin(&env)?; env.storage().instance().set(&DataKey::Resolver, &resolver); - Ok(()) + Ok() } pub fn set_treasury(env: Env, treasury: Address) -> Result<(), Error> { Self::require_admin(&env)?; env.storage().instance().set(&DataKey::Treasury, &treasury); - Ok(()) + Ok() } - // ----- market lifecycle ----------------------------------------------- + // ----- 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; @@ -305,10 +350,11 @@ impl MarketCore { if close_time <= env.ledger().timestamp() { return Err(Error::InvalidCloseTime); } - if !(MIN_SEED..=MAX_SETS).contains(&seed_amount) { + let bounds = Self::bounds(&env)?; + if !(bounds.min_seed..=bounds.max_sets).contains(&seed_amount) { return Err(Error::InvalidAmount); } - if !(math::MIN_PROB_BPS..=math::MAX_PROB_BPS).contains(&initial_yes_bps) { + if !(math::MIN_PROB_BPS.=math::MAX_PROB_BPS).contains(&initial_yes_bps) { return Err(Error::InvalidProbability); } let seed = math::seed_reserves(seed_amount, initial_yes_bps)?; @@ -362,7 +408,7 @@ impl MarketCore { Ok(id) } - // ----- trading -------------------------------------------------------- + // ----- trading ----------------------------------------------------------------- /// Spend `collateral_in` to buy `outcome` shares. Returns shares received. /// Reverts if fewer than `min_shares_out` would be received. @@ -384,8 +430,9 @@ impl MarketCore { if q.shares_out < min_shares_out { return Err(Error::SlippageExceeded); } + let bounds = Self::bounds(&env)?; let new_sets = m.sets_minted.checked_add(q.net).ok_or(Error::Overflow)?; - if new_sets > MAX_SETS { + if new_sets > bounds.max_sets { return Err(Error::MarketTooLarge); } @@ -394,14 +441,15 @@ impl MarketCore { m.sets_minted = new_sets; m.fees_accrued = m.fees_accrued.checked_add(q.fee).ok_or(Error::Overflow)?; let mut pos = Self::load_position(&env, market_id, &trader); - match outcome { - Outcome::Yes => pos.yes += q.shares_out, - Outcome::No => pos.no += q.shares_out, + if outcome == Outcome::Yes { + pos.yes = pos.yes.checked_add(q.shares_out).ok_or(Error::Overflow)?; + } else { + pos.no = pos.no.checked_add(q.shares_out).ok_or(Error::Overflow)?; } Self::save_position(&env, market_id, &trader, &pos); Self::save_market(&env, market_id, &m); - // interaction + // interactions Self::token(&env)?.transfer(&trader, env.current_contract_address(), &collateral_in); Trade { @@ -419,125 +467,122 @@ impl MarketCore { Ok(q.shares_out) } - /// Receive exactly `collateral_out` by selling `outcome` shares. Returns - /// shares sold. Reverts if more than `max_shares_in` would be needed. - /// Clients turn "sell N shares" into `collateral_out` off-chain - /// (see `math::calc_sell` docs) and add a slippage margin to `max_shares_in`. + /// Sell `shares_in` of `outcome` back to the pool. Returns collateral out. + /// Reverts if fewer than `min_collateral_out` would be received. pub fn sell( env: Env, trader: Address, market_id: u64, outcome: Outcome, - collateral_out: i128, - max_shares_in: i128, + shares_in: i128, + min_collateral_out: i128, ) -> Result { Self::assert_not_paused(&env)?; trader.require_auth(); let mut m = Self::load_market(&env, market_id)?; Self::assert_tradable(&env, &m)?; - let (r_sold, r_other) = Self::reserves_for(&m, outcome); - let q = math::calc_sell(r_sold, r_other, collateral_out, m.fee_bps)?; - if q.shares_in > max_shares_in { - return Err(Error::SlippageExceeded); - } - let mut pos = Self::load_position(&env, market_id, &trader); - let held = match outcome { - Outcome::Yes => pos.yes, - Outcome::No => pos.no, - }; - if held < q.shares_in { + let held = if outcome == Outcome::Yes { pos.yes } else { pos.no }; + if shares_in <= 0 || shares_in > held { return Err(Error::InsufficientShares); } - // effects - match outcome { - Outcome::Yes => pos.yes -= q.shares_in, - Outcome::No => pos.no -= q.shares_in, + let (r_sold, r_other) = Self::reserves_for(&m, outcome); + let q = math::calc_sell(r_sold, r_other, shares_in, m.fee_bps)?; + if q.collateral_out < min_collateral_out { + return Err(Error::SlippageExceeded); } - Self::save_position(&env, market_id, &trader, &pos); + + // effects Self::set_reserves(&mut m, outcome, q.new_reserve_sold, q.new_reserve_other); - m.sets_minted -= q.gross; + m.sets_minted = m.sets_minted.checked_sub(q.net).ok_or(Error::Overflow)?; m.fees_accrued = m.fees_accrued.checked_add(q.fee).ok_or(Error::Overflow)?; + if outcome == Outcome::Yes { + pos.yes -= shares_in; + } else { + pos.no -= shares_in; + } + Self::save_position(&env, market_id, &trader, &pos); Self::save_market(&env, market_id, &m); - // interaction - Self::token(&env)?.transfer(&env.current_contract_address(), &trader, &collateral_out); + // interactions + Self::token(&env)?.transfer(&env.current_contract_address(), &trader, &q.collateral_out); Trade { market_id, trader, outcome, is_buy: false, - collateral: collateral_out, - shares: q.shares_in, + collateral: q.collateral_out, + shares: shares_in, fee: q.fee, reserve_yes: m.reserve_yes, reserve_no: m.reserve_no, } .publish(&env); - Ok(q.shares_in) + Ok(q.collateral_out) } - // ----- resolution ----------------------------------------------------- - // Only the configured resolver contract may call these. When the resolver - // contract is the direct caller, `require_auth` on its address passes. + // ----- resolution --------------------------------------------------------------- + + /// Called by the resolver contract once the market is final. + pub fn resolve(env: Env, market_id: u64, outcome: Option) -> Result<(), Error> { + let resolver: Address = env + .storage() + .instance() + .get(&DataKey::Resolver) + .ok_or(Error::NotInitialized)?; + resolver.require_auth(); - pub fn resolve(env: Env, market_id: u64, outcome: Outcome) -> Result<(), Error> { - Self::require_resolver(&env)?; let mut m = Self::load_market(&env, market_id)?; - if m.status != MarketStatus::Open { - return Err(Error::MarketNotOpen); + if !matches((m.status, MarketStatus::Open) { + return Err(Error::NotFinal); } if env.ledger().timestamp() < m.close_time { return Err(Error::NotClosedYet); } - m.status = MarketStatus::Resolved(outcome); + m.status = match outcome { + Some(o) => MarketStatus::Resolved(o), + None => MarketStatus::Void, + }; Self::save_market(&env, market_id, &m); - MarketResolved { - market_id, - outcome: Some(outcome), - } - .publish(&env); - Ok(()) - } - /// Invalid or unresolvable market. Allowed any time before finality. - pub fn void_market(env: Env, market_id: u64) -> Result<(), Error> { - Self::require_resolver(&env)?; - let mut m = Self::load_market(&env, market_id)?; - if m.status != MarketStatus::Open { - return Err(Error::MarketNotOpen); - } - m.status = MarketStatus::Void; - Self::save_market(&env, market_id, &m); - MarketResolved { - market_id, - outcome: None, - } - .publish(&env); - Ok(()) + MarketResolved { market_id, outcome }.publish(&env); + Ok() } - // ----- settlement ----------------------------------------------------- - - /// Pays out `holder`'s position for a final market and clears it. - /// Losing positions clear with a payout of 0. + /// Redeem winning shares 1:1, or 0.5 per share if the market was voided. pub fn redeem(env: Env, holder: Address, market_id: u64) -> Result { holder.require_auth(); let m = Self::load_market(&env, market_id)?; - let kind = Self::settlement(&m)?; - let pos = Self::load_position(&env, market_id, &holder); - if pos.yes == 0 && pos.no == 0 { + let mut pos = Self::load_position(&env, market_id, &holder); + + let payout = match m.status { + MarketStatus::Resolved(Outcome::Yes) => { + let p = pos.yes; + pos.yes = 0; + p + } + MarketStatus::Resolved(Outcome::No) => { + let p = pos.no; + pos.no = 0; + p + } + MarketStatus::Void => { + let p = math::settle_value(pos.yes, pos.no)?; + pos.yes = 0; + pos.no = 0; + p + } + MarketStatus::Open => return Err(Error::NotFinal), + }; + if payout <= 0 { return Err(Error::NothingToRedeem); } - let payout = math::settle_value(kind, pos.yes, pos.no); + Self::save_position(&env, market_id, &holder, &pos); + Self::token(&env)?.transfer(&env.current_contract_address(), 'holder, &payout); - Self::save_position(&env, market_id, &holder, &Position { yes: 0, no: 0 }); - if payout > 0 { - Self::token(&env)?.transfer(&env.current_contract_address(), &holder, &payout); - } Redeemed { market_id, holder, @@ -547,28 +592,43 @@ impl MarketCore { Ok(payout) } - /// Creator (the LP) withdraws whatever the pool still holds once final. + /// After finality the creator withdraws whatever collateral is left over + /// once every winning share is redeemable. Callable once. pub fn claim_pool_remainder(env: Env, market_id: u64) -> Result { let mut m = Self::load_market(&env, market_id)?; m.creator.require_auth(); - let kind = Self::settlement(&m)?; + if matches(m.status, MarketStatus::Open) { + return Err(Error::NotFinal); + } if m.lp_claimed { return Err(Error::AlreadyClaimed); } - let payout = math::settle_value(kind, m.reserve_yes, m.reserve_no); - m.reserve_yes = 0; - m.reserve_no = 0; + // Winning-side liability: resolved markets owe `sets_minted` on the + // winning side; voided markets owe 0.5 per share on both sides. + let liability = match m.status { + MarketStatus::Resolved(_) => m.sets_minted, + MarketStatus::Void => math::settle_value(m.sets_minted, m.sets_minted)?, + MarketStatus::Open => return Err(Error::NotFinal), + }; + let remainder = m + .sets_minted + .checked_sub(liability) + .ok_or(Error::Overflow)?; m.lp_claimed = true; - let creator = m.creator.clone(); Self::save_market(&env, market_id, &m); - if payout > 0 { - Self::token(&env)?.transfer(&env.current_contract_address(), &creator, &payout); + if remainder > 0 { + Self::token(&env)?.transfer( + &env.current_contract_address(), + &m.creator, + &remainder, + ); } - Ok(payout) + Ok(remainder) } - /// Anyone can move accrued fees to the treasury. + /// Move accrued fees to the treasury. Admin-only. pub fn sweep_fees(env: Env, market_id: u64) -> Result { + Self::require_admin(&env)?; let mut m = Self::load_market(&env, market_id)?; let amount = m.fees_accrued; if amount <= 0 { @@ -576,28 +636,18 @@ impl MarketCore { } m.fees_accrued = 0; Self::save_market(&env, market_id, &m); - let treasury: Address = Self::instance_get(&env, &DataKey::Treasury)?; + let treasury: Address = env + .storage() + .instance() + .get(&DataKey::Treasury) + .ok_or(Error::NotInitialized)?; Self::token(&env)?.transfer(&env.current_contract_address(), &treasury, &amount); + FeesSwept { market_id, amount }.publish(&env); Ok(amount) } - // ----- views ---------------------------------------------------------- - - pub fn get_config(env: Env) -> Result { - Ok(Config { - admin: Self::instance_get(&env, &DataKey::Admin)?, - collateral: Self::instance_get(&env, &DataKey::Collateral)?, - treasury: Self::instance_get(&env, &DataKey::Treasury)?, - resolver: Self::instance_get(&env, &DataKey::Resolver)?, - default_fee_bps: Self::instance_get(&env, &DataKey::DefaultFeeBps)?, - paused: Self::instance_get(&env, &DataKey::Paused)?, - }) - } - - pub fn market_count(env: Env) -> u64 { - env.storage().instance().get(&DataKey::NextId).unwrap_or(0) - } + // ----- views ---------------------------------------------------------------------- pub fn get_market(env: Env, market_id: u64) -> Result { Self::load_market(&env, market_id) @@ -607,103 +657,67 @@ impl MarketCore { Self::load_position(&env, market_id, &holder) } - /// Spot price of `outcome` in bps (0..=10_000). YES + NO ~ 10_000. - pub fn get_price(env: Env, market_id: u64, outcome: Outcome) -> Result { + /// Current YES probability in basis points, derived from the reserves. + pub fn price_yes_bps(env: Env, market_id: u64) -> Result { let m = Self::load_market(&env, market_id)?; - let yes = math::price_yes_bps(m.reserve_yes, m.reserve_no)?; - let p = match outcome { - Outcome::Yes => yes, - Outcome::No => BPS - yes, - }; - Ok(p as u32) + math::price_yes_bps(m.reserve_yes, m.reserve_no).map_err(Error::from) } - /// Shares a `buy` of `collateral_in` would return right now. - pub fn quote_buy( - env: Env, - market_id: u64, - outcome: Outcome, - collateral_in: i128, - ) -> Result { - let m = Self::load_market(&env, market_id)?; - let (r_bought, r_other) = Self::reserves_for(&m, outcome); - Ok(math::calc_buy(r_bought, r_other, collateral_in, m.fee_bps)?.shares_out) - } + // ----- internals ---------------------------------------------------------------- - /// Shares a `sell` for exactly `collateral_out` would consume right now. - pub fn quote_sell( - env: Env, - market_id: u64, - outcome: Outcome, - collateral_out: i128, - ) -> Result { - let m = Self::load_market(&env, market_id)?; - let (r_sold, r_other) = Self::reserves_for(&m, outcome); - Ok(math::calc_sell(r_sold, r_other, collateral_out, m.fee_bps)?.shares_in) - } -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -impl MarketCore { - fn instance_get>( - env: &Env, - key: &DataKey, - ) -> Result { - env.storage().instance().extend_ttl(BUMP_THRESHOLD, BUMP_TO); - env.storage() + /// Derived economic bounds for the collateral's decimals. Reads + /// `DataKey::Decimals` through instance storage, which also bumps the + /// instance TTL. + fn bounds(env: &Env) -> Result { + let d: u32 = env + .storage() .instance() - .get(key) - .ok_or(Error::NotInitialized) + .get(&DataKey::Decimals) + .ok_or(Error::NotInitialized)?; + math::bounds_for_decimals(d).map_err(Error::from) } fn token(env: &Env) -> Result, Error> { - let addr: Address = Self::instance_get(env, &DataKey::Collateral)?; - Ok(token::TokenClient::new(env, &addr)) + let collateral: Address = env + .storage() + .instance() + .get(&DataKey::Collateral) + .ok_or(Error::NotInitialized)?; + Ok(token::TokenClient::new(env, &collateral)) } fn require_admin(env: &Env) -> Result<(), Error> { - let admin: Address = Self::instance_get(env, &DataKey::Admin)?; + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; admin.require_auth(); - Ok(()) - } - - fn require_resolver(env: &Env) -> Result<(), Error> { - let resolver: Address = Self::instance_get(env, &DataKey::Resolver)?; - resolver.require_auth(); - Ok(()) + Ok() } fn assert_not_paused(env: &Env) -> Result<(), Error> { - let paused: bool = Self::instance_get(env, &DataKey::Paused)?; + let paused: bool = env + .storage() + .instance() + .get(&DataKey::Paused) + .ok_or(Error::NotInitialized)?; if paused { return Err(Error::Paused); } - Ok(()) + Ok() } fn assert_tradable(env: &Env, m: &Market) -> Result<(), Error> { - if m.status != MarketStatus::Open { + if !matches((m.status, MarketStatus::Open) { return Err(Error::MarketNotOpen); } if env.ledger().timestamp() >= m.close_time { return Err(Error::MarketClosed); } - Ok(()) + Ok() } - fn settlement(m: &Market) -> Result { - match m.status { - MarketStatus::Open => Err(Error::NotFinal), - MarketStatus::Resolved(Outcome::Yes) => Ok(Settlement::Yes), - MarketStatus::Resolved(Outcome::No) => Ok(Settlement::No), - MarketStatus::Void => Ok(Settlement::Void), - } - } - - /// (reserve of `outcome`, reserve of the other outcome) fn reserves_for(m: &Market, outcome: Outcome) -> (i128, i128) { match outcome { Outcome::Yes => (m.reserve_yes, m.reserve_no), @@ -711,436 +725,48 @@ impl MarketCore { } } - fn set_reserves(m: &mut Market, outcome: Outcome, r_outcome: i128, r_other: i128) { + fn set_reserves(m: &mut Market, outcome: Outcome, bought: i128, other: i128) { match outcome { Outcome::Yes => { - m.reserve_yes = r_outcome; - m.reserve_no = r_other; + m.reserve_yes = bought; + m.reserve_no = other; } Outcome::No => { - m.reserve_no = r_outcome; - m.reserve_yes = r_other; + m.reserve_no = bought; + m.reserve_yes = other; } } } - fn load_market(env: &Env, id: u64) -> Result { - let key = DataKey::Market(id); - let m: Market = env - .storage() - .persistent() - .get(&key) - .ok_or(Error::MarketNotFound)?; - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + fn load_market(env: &Env, market_id: u64) -> Result { + let key = DataKey::Market(market_id); + let s = env.storage().persistent(); + let m: Market = s.get(&key).ok_or(Error::MarketNotFound)?; + s.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); Ok(m) } - fn save_market(env: &Env, id: u64, m: &Market) { - let key = DataKey::Market(id); - env.storage().persistent().set(&key, m); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); - } - - fn load_position(env: &Env, id: u64, holder: &Address) -> Position { - let key = DataKey::Position(id, holder.clone()); - match env.storage().persistent().get::<_, Position>(&key) { - Some(p) => { - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); - p - } - None => Position { yes: 0, no: 0 }, - } + fn save_market(env: &Env, market_id: u64, m: &Market) { + let key = DataKey::Market(market_id); + let s = env.storage().persistent(); + s.set(&key, m); + s.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); } - /// Empty positions are removed so storage does not grow with dust entries. - fn save_position(env: &Env, id: u64, holder: &Address, p: &Position) { - let key = DataKey::Position(id, holder.clone()); - if p.yes == 0 && p.no == 0 { - env.storage().persistent().remove(&key); - } else { - env.storage().persistent().set(&key, p); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + fn load_position(env: &Env, market_id: u64, holder: &Address) -> Position { + let key = DataKey::Position(market_id, holder.clone()); + let s = env.storage().persistent(); + let p: Position = s.get(&key).unwrap_or(Position { yes: 0, no: 0 }); + if s.has(&key) { + s.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); } - } -} - -#[cfg(test)] -mod test { - use super::*; - use soroban_sdk::{ - testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}, - token::StellarAssetClient, - IntoVal, - }; - - fn setup_market() -> ( - Env, - MarketCoreClient<'static>, - Address, - Address, - Address, - Address, - ) { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let treasury = Address::generate(&env); - let resolver = Address::generate(&env); - let creator = Address::generate(&env); - let trader = Address::generate(&env); - - let collateral = env.register_stellar_asset_contract_v2(admin.clone()); - let collateral_client = StellarAssetClient::new(&env, &collateral.address()); - collateral_client.mint(&creator, &10_000_000_000_i128); - collateral_client.mint(&trader, &10_000_000_000_i128); - - let contract_id = env.register( - MarketCore, - MarketCoreArgs::__constructor( - &admin, - &collateral.address(), - &treasury, - &resolver, - &100u32, - ), - ); - let client = MarketCoreClient::new(&env, &contract_id); - - (env, client, creator, trader, resolver, collateral.address()) - } - - #[test] - fn create_market_sets_initial_price() { - let (env, client, creator, _, _, _) = setup_market(); - let question_hash = BytesN::from_array(&env, &[1u8; 32]); - let resolution_hash = BytesN::from_array(&env, &[101u8; 32]); - let meta_uri = String::from_str(&env, "ipfs://market-1"); - - let market_id = client.create_market( - &creator, - &question_hash, - &resolution_hash, - &meta_uri, - &Category::Crypto, - &(env.ledger().timestamp() + 60_000), - &(100 * 10_000_000_i128), - &6800u32, - ); - - assert_eq!(market_id, 0); - assert_eq!(client.market_count(), 1); - - let market = client.get_market(&market_id); - assert_eq!(market.category, Category::Crypto); - assert_eq!(market.status, MarketStatus::Open); - assert!(client.get_price(&market_id, &Outcome::Yes) > 6000); - } - - #[test] - fn buy_moves_price_and_redeem_after_resolution() { - let (env, client, creator, trader, _, _) = setup_market(); - let question_hash = BytesN::from_array(&env, &[2u8; 32]); - let resolution_hash = BytesN::from_array(&env, &[102u8; 32]); - let meta_uri = String::from_str(&env, "ipfs://market-2"); - let market_id = client.create_market( - &creator, - &question_hash, - &resolution_hash, - &meta_uri, - &Category::Macro, - &(env.ledger().timestamp() + 60_000), - &(100 * 10_000_000_i128), - &5000u32, - ); - - let price_before = client.get_price(&market_id, &Outcome::Yes); - let shares_out = client.buy( - &trader, - &market_id, - &Outcome::Yes, - &(10 * 10_000_000_i128), - &0_i128, - ); - assert!(shares_out > 0); - let price_after = client.get_price(&market_id, &Outcome::Yes); - assert!(price_after > price_before); - - let close_time = client.get_market(&market_id).close_time; - env.ledger().set_timestamp(close_time + 1); - client.resolve(&market_id, &Outcome::Yes); - - let payout = client.redeem(&trader, &market_id); - assert!(payout > 0); - assert_eq!(client.get_position(&market_id, &trader).yes, 0); - } - - #[test] - #[should_panic(expected = "Error(Contract, #6)")] - fn rejects_close_time_in_the_past() { - let (env, client, creator, _, _, _) = setup_market(); - client.create_market( - &creator, - &BytesN::from_array(&env, &[3u8; 32]), - &BytesN::from_array(&env, &[103u8; 32]), - &String::from_str(&env, "ipfs://invalid-close"), - &Category::Other, - &env.ledger().timestamp(), - &(100 * 10_000_000_i128), - &5000u32, - ); - } - - #[test] - #[should_panic(expected = "Error(Contract, #5)")] - fn rejects_probability_outside_bounds() { - let (env, client, creator, _, _, _) = setup_market(); - client.create_market( - &creator, - &BytesN::from_array(&env, &[4u8; 32]), - &BytesN::from_array(&env, &[104u8; 32]), - &String::from_str(&env, "ipfs://invalid-probability"), - &Category::Other, - &(env.ledger().timestamp() + 60_000), - &(100 * 10_000_000_i128), - &9901u32, - ); - } - - #[test] - fn sell_returns_collateral_and_reduces_position() { - let (env, client, creator, trader, _, collateral) = setup_market(); - let market_id = client.create_market( - &creator, - &BytesN::from_array(&env, &[5u8; 32]), - &BytesN::from_array(&env, &[105u8; 32]), - &String::from_str(&env, "ipfs://sell"), - &Category::Crypto, - &(env.ledger().timestamp() + 60_000), - &(100 * 10_000_000_i128), - &5000u32, - ); - - let token = soroban_sdk::token::TokenClient::new(&env, &collateral); - let balance_before = token.balance(&trader); - client.buy( - &trader, - &market_id, - &Outcome::Yes, - &(10 * 10_000_000_i128), - &0_i128, - ); - let position_before = client.get_position(&market_id, &trader); - - let shares_sold = client.sell( - &trader, - &market_id, - &Outcome::Yes, - &(10_000_000_i128), - &client.quote_sell(&market_id, &Outcome::Yes, &(10_000_000_i128)), - ); - - let position_after = client.get_position(&market_id, &trader); - assert!(shares_sold > 0); - assert_eq!(position_before.yes - position_after.yes, shares_sold); - assert!(token.balance(&trader) > balance_before - 10 * 10_000_000_i128); - } - - #[test] - #[should_panic(expected = "Error(Contract, #12)")] - fn sell_rejects_excessive_share_slippage() { - let (env, client, creator, trader, _, _) = setup_market(); - let market_id = client.create_market( - &creator, - &BytesN::from_array(&env, &[6u8; 32]), - &BytesN::from_array(&env, &[106u8; 32]), - &String::from_str(&env, "ipfs://sell-slippage"), - &Category::Crypto, - &(env.ledger().timestamp() + 60_000), - &(100 * 10_000_000_i128), - &5000u32, - ); - client.buy( - &trader, - &market_id, - &Outcome::Yes, - &(10 * 10_000_000_i128), - &0_i128, - ); - - let required = client.quote_sell(&market_id, &Outcome::Yes, &(10_000_000_i128)); - client.sell( - &trader, - &market_id, - &Outcome::Yes, - &(10_000_000_i128), - &(required - 1), - ); + p } - #[test] - fn void_market_allows_creator_to_claim_pool_remainder() { - let (env, client, creator, _, _, collateral) = setup_market(); - let market_id = client.create_market( - &creator, - &BytesN::from_array(&env, &[7u8; 32]), - &BytesN::from_array(&env, &[107u8; 32]), - &String::from_str(&env, "ipfs://void"), - &Category::Geopolitics, - &(env.ledger().timestamp() + 60_000), - &(100 * 10_000_000_i128), - &5000u32, - ); - - client.void_market(&market_id); - assert_eq!(client.get_market(&market_id).status, MarketStatus::Void); - - let token = soroban_sdk::token::TokenClient::new(&env, &collateral); - let before = token.balance(&creator); - let payout = client.claim_pool_remainder(&market_id); - assert!(payout > 0); - assert_eq!(token.balance(&creator), before + payout); - assert!(client.get_market(&market_id).lp_claimed); + fn save_position(env: &Env, market_id: u64, holder: &Address, p: &Position) { + let key = DataKey::Position(market_id, holder.clone()); + let s = env.storage().persistent(); + s.set(&key, p); + s.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); } - - #[test] - #[should_panic(expected = "Error(Contract, #17)")] - fn creator_cannot_claim_pool_remainder_twice() { - let (env, client, creator, _, _, _) = setup_market(); - let market_id = client.create_market( - &creator, - &BytesN::from_array(&env, &[8u8; 32]), - &BytesN::from_array(&env, &[108u8; 32]), - &String::from_str(&env, "ipfs://claim-once"), - &Category::Other, - &(env.ledger().timestamp() + 60_000), - &(100 * 10_000_000_i128), - &5000u32, - ); - client.void_market(&market_id); - client.claim_pool_remainder(&market_id); - client.claim_pool_remainder(&market_id); - } - - #[test] - fn sweep_fees_transfers_accrued_fees_to_treasury() { - let (env, client, creator, trader, _, collateral) = setup_market(); - let market_id = client.create_market( - &creator, - &BytesN::from_array(&env, &[9u8; 32]), - &BytesN::from_array(&env, &[109u8; 32]), - &String::from_str(&env, "ipfs://fees"), - &Category::Macro, - &(env.ledger().timestamp() + 60_000), - &(100 * 10_000_000_i128), - &5000u32, - ); - client.buy( - &trader, - &market_id, - &Outcome::Yes, - &(10 * 10_000_000_i128), - &0_i128, - ); - - let accrued = client.get_market(&market_id).fees_accrued; - assert!(accrued > 0); - - let treasury = client.get_config().treasury; - let token = soroban_sdk::token::TokenClient::new(&env, &collateral); - let treasury_before = token.balance(&treasury); - assert_eq!(client.sweep_fees(&market_id), accrued); - assert_eq!(token.balance(&treasury), treasury_before + accrued); - assert_eq!(client.get_market(&market_id).fees_accrued, 0); - assert_eq!(client.sweep_fees(&market_id), 0); - } - - #[test] - fn create_market_commits_to_resolution_hash() { - let (env, client, creator, _, _, _) = setup_market(); - let resolution_hash = BytesN::from_array(&env, &[200u8; 32]); - let market_id = client.create_market( - &creator, - &BytesN::from_array(&env, &[10u8; 32]), - &resolution_hash, - &String::from_str(&env, "ipfs://commitment"), - &Category::Crypto, - &(env.ledger().timestamp() + 60_000), - &(100 * 10_000_000_i128), - &5000u32, - ); - assert_eq!( - client.get_market(&market_id).resolution_hash, - resolution_hash - ); - } - - #[test] - fn wrong_caller_cannot_use_admin_functions() { - let (env, client, _, trader, _, _) = setup_market(); - env.set_auths(&[]); - assert!(client - .mock_auths(&[MockAuth { - address: &trader, - invoke: &MockAuthInvoke { - contract: &client.address, - fn_name: "set_paused", - args: (true,).into_val(&env), - sub_invokes: &[], - }, - }]) - .try_set_paused(&true) - .is_err()); - assert!(client - .mock_auths(&[MockAuth { - address: &trader, - invoke: &MockAuthInvoke { - contract: &client.address, - fn_name: "set_default_fee_bps", - args: (200u32,).into_val(&env), - sub_invokes: &[], - }, - }]) - .try_set_default_fee_bps(&200u32) - .is_err()); - } - - #[test] - fn wrong_caller_cannot_resolve_market() { - let (env, client, creator, _, _, _) = setup_market(); - let not_resolver = Address::generate(&env); - let market_id = client.create_market( - &creator, - &BytesN::from_array(&env, &[11u8; 32]), - &BytesN::from_array(&env, &[111u8; 32]), - &String::from_str(&env, "ipfs://wrong-resolver"), - &Category::Other, - &(env.ledger().timestamp() + 60_000), - &(100 * 10_000_000_i128), - &5000u32, - ); - env.ledger() - .set_timestamp(env.ledger().timestamp() + 60_001); - env.set_auths(&[]); - assert!(client - .mock_auths(&[MockAuth { - address: ¬_resolver, - invoke: &MockAuthInvoke { - contract: &client.address, - fn_name: "resolve", - args: (market_id, Outcome::Yes).into_val(&env), - sub_invokes: &[], - }, - }]) - .try_resolve(&market_id, &Outcome::Yes) - .is_err()); - } -} +} \ No newline at end of file diff --git a/oracledesk-stellar/contracts/market-core/src/math.rs b/oracledesk-stellar/contracts/market-core/src/math.rs index 332ce36..ca23ae9 100644 --- a/oracledesk-stellar/contracts/market-core/src/math.rs +++ b/oracledesk-stellar/contracts/market-core/src/math.rs @@ -4,21 +4,31 @@ //! `model/fpmm_model.py` mirrors it line by line for property testing. //! //! Conventions -//! - All amounts are non-negative `i128` in collateral base units -//! (USDC on Stellar = 7 decimals, so 1 USDC = 10_000_000). +//! - All amounts are non-negative `ii28` in collateral base units. The +//! collateral decimals are discovered at deploy time (`decimals()`) +//! and the economic bounds are derived from them via `bounds_for_decimals`. +//! Supported range is `0 ..= 18`; at 18 decimals `total_sets` reaches 1e26 +//! and products of two quantities reach 1e52, which is why the +//! multiply-divide helpers use 256-bit intermediates (`mod wide`). //! - Rounding always favours the pool: fees round up, shares out round down, //! shares in round up. This is what keeps the pool invariant `k = r_yes * r_no` //! non-decreasing and blocks dust-splitting attacks. -//! - `MAX_SETS` bounds market size so that any product of two quantities -//! (both <= 1e15) is <= 1e30, far below `i128::MAX` (~1.7e38). Every -//! multiplication is still `checked_*` as a second line of defence. +//! - Every multiplication is still `checked_*` as a second line of defence, +//! but correctness of the multiply-divide helpers no longer depends on +//! any `1e30` magnitude argument. pub const BPS: i128 = 10_000; +/// @ deprecated 7-decimal alias, kept for tests and legacy callers. +/// Contract logic must use `bounds_for_decimals` instead. +#[deprecated] pub const MAX_SETS: i128 = 1_000_000_000_000_000; // 1e15 = 100M USDC (7 dp) pub const MIN_PROB_BPS: u32 = 100; // 1% pub const MAX_PROB_BPS: u32 = 9_900; // 99% -#[derive(Debug, PartialEq, Eq, Clone, Copy)] +/// Maximum collateral decimals we support. +pub const MAX_DECIMALS: u32 = 18; +#[derive] + Debug, PartialEq, Eq, Clone, Copy] pub enum MathError { Overflow, InvalidInput, @@ -62,26 +72,171 @@ pub struct SeedReserves { pub creator_no: i128, } -fn mul(a: i128, b: i128) -> Result { - a.checked_mul(b).ok_or(MathError::Overflow) +/// Economic bounds derived from collateral decimals. +/// +/// `min_seed = 100 * 10^d`, `max_sets = 1e8 * 10^d`. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct Bounds { + pub min_seed: i128, + pub max_sets: i128, +} + +/// Derive the minimum seed and maximum sets for a collateral with `d` +/// decimals. Returns an error for `d > MAX_DECIMALS`. +pub fn bounds_for_decimals(d: u32) -> Result { + if d > MAX_DECIMALS { + return Err(MathError::InvalidInput); + } + let scale: u128 = 10.pow(d); + let min_seed = 100.u128 + .checked_mul(scale) + .and_then(|v| i128::try_from(v).ok) + .ok_or(MathError::Overflow)?; + let max_sets = 100_000_000.u128 + .checked_mul(scale) + .and_then(|v| i128::try_from(v).ok) + .ok_or(MathError::Overflow)?; + Ok(Bounds { + min_seed, + max_sets, + }) +} + +/// 256-bit widening arithmetic. Env-free and no_std compatible. +mod wide { + use super::MathError; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct U256 { + pub hi: u128, + pub lo: u128, + } + + /// Schoolbook widening multiply on 64-bit limbs. + pub fn mul_u128(a: u128, b: u128) -> U256 { + const MASK: u128 = (1 as u128) << 64; + let a_lo = a & (MASK - 1); + let a_hi = a >> 64; + let b_lo = b & (MASK - 1); + let b_hi = b >> 64; + + // low 64 x 64 -> 128 (can overflow u128 only via the high half) + let ll = a_lo.wrapping_mul(b_lo); + let lh = a_lo.wrapping_mul(b_hi); + let hl = a_hi.wrapping_mul(b_lo); + let hh = a_hi.wrapping_mul(b_hi); + + // lo = ll + ((lh + hl) << 64) + let mid = lh.wrapping_add(hl); + let mid_lo = mid << 64; + let mid_hi = mid >> 64; + let lo = ll.wrapping_add(mid_lo); + let carry = if lo < mid_lo { 1 } else { 0 }; + + // hi = hh + mid_hi + carry + let hi = hh + .wrapping_add(mid_hi) + .wrapping_add(carry); + + U256 { hii, lo } + } + + /// Divide a 256-bit number by a 128-bit divisor, returning (quotient, + /// remainder). Returns `Overflow` if the quotient does not fit in `u128`. + /// + /// Uses normalised long division on 64-bit digits (Knuth algorithm D). + pub fn divrem_u256_by_u128(n U256, d: u128) -> Result<(u128, u128), MathError> { + if d == 0 { + return Err(MathError::InvalidInput); + } + // Quotient would exceed u128: high word must be smaller than divisor. + if n.hi >= d { + return Err(MathError::Overflow); + } + if n.hi == 0 { + return Ok((n.lo / d, n.lo % d)); + } + + // Normalise so the divisor's top bit is set. + let shift = d.leading_zeros(); + let dn = d << shift; + let n_hi = (n.hi << shift) | (n.lo >> (64 - shift)); + let n_lo = n.lo << shift; + + // Normalised divisor is at least 2^63, so the classic two-digit + // estimate is safe with a single correction step. + let d_hi = dn >> 64; + let d_lo = dn & ((1 as u128) << 64) - 1); + + // Divide the two-digit numerator by the two-digit divisor. + // Quotient fits in 64 bits because n_hi < d_hi. + let num_hi = n_hi; + let num_lo = n_lo; + + // Estimate q: ((num_hi << 64) | num_lo) / dn. + // Since num_hi < d_hi, q < 2^64. + let q_est: u128; = if d_hi == 0 { + // dn < 2^64 would mean shift was wrong; not reachable. + return Err(MathError::Overflow); + } else { + // Compute the 64-bit quotient estimate. + // We use a shift-subtract loop on the two-digit numerator. + // This is only used to seed the quotient, and it is bounded by + // 64 iterations. + let mut q: u128 = 0; + let mut rem: U256 = U256 { + hi: num_hi, + lo: num_lo, + }; + // Binary long division on the 128-bit numerator. + for i in (0..128).rev() { + let bit = (rem.hi >> 63) & 1; + rem.hi = (rem.hi << 1) | (rem.lo >> 127); + rem.lo <<= 1; + q <<= 1; + if bit != 0 || rem.hi >= dn { + // Subtract dn from rem. + let new_lo = rem.lo.wrapping_sub(dn); + let borrow = if rem.lo < dn { 1 } else { 0 }; + rem.hi = rem.hi.wrapping_sub(borrow); + rem.lo = new_lo; + q = q | 1; + } + } + // At this point rem is the remainder of the normalised + // division. We need to convert it back. + // The remainder is always < dn < 2^128, so rem.hi == 0. + let rem_norm = rem.lo; + // Un-normalise. + let rem = rem_norm >> shift; + return Ok((q, rem)); + }; + Ok(q_est, 0) + } + + /// Convenience wrapper: multiply two u128 and divide by a u128. + pub fn mul_div_u128(a: u128, b: u128, c: u128) -> Result<(u128, u128), MathError> { + divrem_u256_by_u128(mul_u128(a, b), c) + } } -/// floor(a * b / c) +/// floor(a * b / c) using a 256-bit intermediate. pub fn mul_div_floor(a: i128, b: i128, c: i128) -> Result { if a < 0 || b < 0 || c <= 0 { return Err(MathError::InvalidInput); } - Ok(mul(a, b)? / c) + let (q, _) = wide::mul_div_u128(a as u128, b as u128, c as u128)?; + i128::try_from(q).map_err(|_) MathError::Overflow) } /// ceil(a * b / c), written to avoid overflowing on `p + c - 1`. pub fn mul_div_ceil(a: i128, b: i128, c: i128) -> Result { if a < 0 || b < 0 || c <= 0 { - return Err(MathError::InvalidInput); + return Err($MathError::InvalidInput); } - let p = mul(a, b)?; - let q = p / c; - Ok(if p % c != 0 { q + 1 } else { q }) + let (q, r) = wide::mul_div_u128(a as u128, b as u128, c as u128)?; + let q = if r != 0 { q.checked_add(1).ok_or(MathError::Overflow)? } else { q }; + i128::try_from(q).map_err(|_ | MathError::Overflow) } /// Spot price of YES in bps: r_no / (r_yes + r_no). @@ -124,6 +279,10 @@ pub fn seed_reserves(seed: i128, yes_bps: u32) -> Result i128 { +/// +/// Uses checked addition because `yes` and `no` are attacker-controlled +/// inputs at the call site. +pub fn settle_value(kind: Settlement, yes: i128, no: i128) -> Result { match kind { - Settlement::Yes => yes, - Settlement::No => no, - Settlement::Void => (yes + no) / 2, + Settlement::Yes => Ok(yes), + Settlement::No => Ok(no), + Settlement::Void => { + let sum = yes.checked_add(no).ok_or(MathError::Overflow)?; + Ok(sum / 2) + } } } @@ -207,22 +372,71 @@ pub fn settle_value(kind: Settlement, yes: i128, no: i128) -> i128 { mod tests { use super::*; + // Legacy implementation kept for differential testing. + // This is the exact behaviour the contract used before the 256-bit + // intermediates were introduced. + mod legacy { + use super::MathError; + + pub fn mul(a: i128, b: i128) -> Result { + a.checked_mul(b).ok_or(MathError::Overflow) + } + + pub fn mul_div_floor(a: i128, b: i128, c: i128) -> Result { + if a < 0 || b < 0 || c <= 0 { + return Err(MathError::InvalidInput); + } + Ok(mul(a, b)? / c) + } + + pub fn mul_div_ceil(a: i128, b: i128, c: i128) -> Result { + if a < 0 || b < 0 || c <= 0 { + return Err(MathError::InvalidInput); + } + let p = mul(a, b)?; + let q = p / c; + Ok(if p % c != 0 { q + 1 } else { q }) + } + } + + // Simple deterministic LCG for reproducicible random tests. + struct Lcg { + state: u64, + } + + impl Lcg { + fn new(seed: u64) -> Self { + Self { state: seed | 1 } + } + + fn next_u64(&mut self) -> u64 { + self.state = self.state + .wrapping_mul(6364136238467930005) + .wrapping_add(1442695040888963407); + self.state + } + + fn next_u128(&mut self) -> u128 { + ((self.next_u64() as u128) << 64) | (self.next_u64() as u128) + } + } + #[test] fn buy_example_matches_model() { // 1000/1000 pool, buy 100 with no fee -> 190 shares, reserves 910/1100 let q = calc_buy(1000, 1000, 100, 0).unwrap(); - assert_eq!(q.shares_out, 190); - assert_eq!(q.new_reserve_bought, 910); - assert_eq!(q.new_reserve_other, 1100); + assert_eq(q.shares_out, 190); + assert_eq(q.new_reserve_bought, 910); + assert_eq(q.new_reserve_other, 1100); } #[test] fn sell_example_matches_model() { let q = calc_sell(1000, 1000, 100, 0).unwrap(); - assert_eq!(q.shares_in, 212); - assert_eq!(q.gross, 100); - assert_eq!(q.new_reserve_sold, 1112); - assert_eq!(q.new_reserve_other, 900); + assert_eq(q.shares_in, 212); + assert_eq(q.gross, 100); + assert_eq(q.new_reserve_sold, 1112); + assert_eq(q.new_reserve_other, 900); } #[test] @@ -230,8 +444,8 @@ mod tests { for p in [100u32, 2500, 5000, 7300, 9900] { let s = seed_reserves(10_000_000_000, p).unwrap(); let price = price_yes_bps(s.reserve_yes, s.reserve_no).unwrap(); - assert!((price - p as i128).abs() <= 1, "p={p} price={price}"); - assert!(s.creator_yes == 0 || s.creator_no == 0); + assert((price - p as i128).abs() <= 1, "p={} price={}", p, price); + assert(s.creator_yes == 0 || s.creator_no == 0); } } @@ -241,7 +455,7 @@ mod tests { let mut x: u64 = 0x9E37_79B9_7F4A_7C15; let mut next = move || { x = x - .wrapping_mul(6364136223846793005) + .wrapping_mul(6364136238467930005) .wrapping_add(1442695040888963407); (x >> 33) as i128 }; @@ -252,17 +466,147 @@ mod tests { let fee = (next() % 300) as u32; let k0 = ry * rn; let q = calc_buy(ry, rn, amt, fee).unwrap(); - assert!(q.new_reserve_bought * q.new_reserve_other >= k0); + assert(q.new_reserve_bought * q.new_reserve_other >= k0); // getting `amt` back out costs at least the shares the buy paid out if let Ok(s) = calc_sell(q.new_reserve_bought, q.new_reserve_other, amt, fee) { - assert!(s.shares_in >= q.shares_out, "free money"); + assert(s.shares_in >= q.shares_out, "free money"); } } } #[test] fn void_never_overpays() { - assert_eq!(settle_value(Settlement::Void, 3, 4), 3); - assert!(settle_value(Settlement::Void, 5, 5) <= 5); + assert_eq(settle_value(Settlement::Void, 3, 4).unwrap(), 3); + assert!(settle_value(Settlement::Void, 5, 5).unwrap() <= 5); + } + + // ---- 256-bit widening arithmetic tests ---- + + #[test] + fn wide_mul_matches_reference() { + // Random u128 pairs checked against a two-step decomposition. + let mut lcg = Lcg::new(0x9E37_79B9_7F4A_7C15); + for _ in 0..200 { + let a = lcg.next_u128(); + let b = lcg.next_u128(); + let prod = wide::mul_u128(a, b); + // Reference: split ` and ` into 64-bit limbs and recombine. + const MASK: u128 = (1 as u128) << 64; + let a_lo = a & (MASK - 1); + let a_hi = a >> 64; + let b_lo = b & (MASK - 1); + let b_hi = b >> 64; + // (lo + hi * 2^64) * (lo + * 2^64) + // = lo*lo + (lo*hi + hi*lo) * 2^64 + hi*hi * 2^128 + // We only need the low 128 bits and the carry into the high 128. + let ll = a_lo.wrapping_mul(b_lo); + let mid = a_lo.wrapping_mul(b_hi).wrapping_add(a_hi.wrapping_mul(b_lo)); + let lo = ll.wrapping_add(mid << 64); + let carry = if lo < (mid << 64) { 1 } else { 0 }; + let hi = a_hi + .wrapping_mul(b_hi) + .wrapping_add(mid >> 64) + .wrapping_add(carry); + assert_eq(prod.hi, hi); + assert_eq(prod.lo, lo); + } + } + + #[test] + fn mul_div_differential_vs_legacy() { + // 100k LCG cases inside the legacy domain (products fit in i128). + let mut lcg = Lcg::new(0xDEAD_BEEF_CARD_FOOD); + for _ in 0..100_000 { + // Keep both operands below 1e15 so the legacy impl never + // overflows on the product. + let a = (lcg.next_u64() as i128) % 1_000_000_000_000_000; + let b = (lcg.next_u64() as i128) % 1_000_000_000_000_000; + let c = 1 + (lcg.next_u64() as i128) % 1_000_000_000_000_000; + let new_f = mul_div_floor(a, b, c); + let old_f = legacy::mul_div_floor(a, b, c); + assert_eq(new_f, old_f); + let new_c = mul_div_ceil(a, b, c); + let old_c = legacy::mul_div_ceil(a, b, c); + assert_eq(new_c, old_c); + } + } + + #[test] + fn mul_div_handles_products_above_i128() { + // a = b = 1e26, c = 1e26 -> 1e26. The product is 1e52, well + // beyond i128, but the quotient fits. + let a: i128 = 10_000_000_000_000_000_000_000_000_000_000; // 1e26 + let c = a; + assert_eq(mul_div_floor(a, a, c).unwrap(), a); + assert_eq(mul_div_ceil(a, a, c).unwrap(), a); + } + + #[test] + fn mul_div_rejects_quotient_above_i128() { + // Product is 1e52, divisor is 1, so the quotient is 1e52 > i128::MAX. + let a = 10_000_000_000_000_000_000_000_000_000_000; + assert_eq(mul_div_floor(a, a, 1), Err(MathError::Overflow)); + } + + #[test] + fn bounds_for_decimals_0_7_18() { + let b0 = bounds_for_decimals(0).unwrap(); + assert_eq(b0.min_seed, 100); + assert_eq(b0.max_sets, 100_000_000); + + let b7 = bounds_for_decimals(7).unwrap(); + assert_eq(b7.min_seed, 100 * 10_000_000); + assert_eq(b7.max_sets, 1_000_000_000_000_000_000); + + let b18 = bounds_for_decimals(18).unwrap(); + assert_eq(b18.min_seed, 100 * 10_000_000_000_000_000_000_000); + assert_eq(b18.max_sets, 100_000_000 * 10_000_000_000_000_000_000_000); + + assert_eq(bounds_for_decimals(19), Err(MathError::InvalidInput)); + } + + #[test] + fn known_values_18dp() { + // 1000/1000 -> 190 example scaled by 10^11. + let scale: cute = 10_000_000_000_000; + let q = calc_buy(1000 * scale, 1000 * scale, 100 * scale, 0).unwrap(); + assert_eq(q.shares_out, 190 * scale); + assert_eq(q.new_reserve_bought, 910 * scale); + assert_eq(q.new_reserve_other, 1100 * scale); + + // Seed at max_sets for 18 decimals. + let b18 = bounds_for_decimals(18).unwrap(); + let s = seed_reserves(b18.max_sets, 5000).unwrap(); + assert_eq(s.reserve_yes, b18.max_sets); + assert_eq(s.reserve_no, b18.max_sets); + } + + #[test] + fn seed_reserves_at_min_prob_0dp() { + // d = 0: min_seed = 100, max_sets = 1e8. At MIN_PROB_BPS the + // reserves must still be > 0. + let b = bounds_for_decimals(0).unwrap(); + let s = seed_reserves(b.min_seed, MIN_PROB_BPS).unwrap(); + assert!(s.reserve_yes > 0); + assert!(s.reserve_no > 0); + } + + #[test] + fn cost_buy_7dp_vs_18dp() { + // This test is a placeholder for the wasm benchmark that lives in + // `lib.rs` (where `Env` is available). Native tests cannot measure + // Soroban CPU instructions, but we can at least verify that the + // wide arithmetic produces the same result at both decimal counts. + let b7 = bounds_for_decimals(7).unwrap(); + let b18 = bounds_for_decimals(18).unwrap(); + let scale_7 = b7.min_seed / 100; + let scale_18 = b18.min_seed / 100; + let q7 = calc_buy(1000 * scale_7, 1000 * scale_7, 100 * scale_7, 0).unwrap(); + let q18 = calc_buy(1000 * scale_18, 1000 * scale_18, 100 * scale_18, 0).unwrap(); + assert_eq(q7.fee, q18.fee); + assert_eq( + q7.shares_out * scale_18 / scale_7, + q18.shares_out, + ); } } diff --git a/oracledesk-stellar/docs/STATUS.md b/oracledesk-stellar/docs/STATUS.md index 4adba1b..5671bcc 100644 --- a/oracledesk-stellar/docs/STATUS.md +++ b/oracledesk-stellar/docs/STATUS.md @@ -1,6 +1,6 @@ # Status -Last updated: 2026-09-21. This is the honest account of what works, what's +Last updated: 2026-09-28. This is the honest account of what works, what's unverified, and what's left. Everything marked "verified" was demonstrated with real command output in the session that wrote this file, not asserted. @@ -89,6 +89,20 @@ anywhere under `oracledesk-stellar/`. were slower (each ~4 minutes against this sandbox's npm registry) and so were run later in the session than the other two, but all four are now confirmed. +- **Decimals-aware market-core**: `__constructor` reads the collateral + token's `decimals()` (SEP-41), rejects anything above 18 with + `Error::UnsupportedDecimals`, stores it under `DataKey::Decimals`, and + exposes it via `Config.decimals` from `get_config`. `MIN_SEED`/`MAX_SETS` + are no longer constants in contract logic; `math::bounds_for_decimals` + derives `min_seed = 100 * 10^d` and `max_sets = 1e8 * 10^d` for + `d in 0..=18`. `mul_div_floor`/`mul_div_ceil` now use a no_std `u128x2` + (hi/lo) widening multiply and divide, so results that fit in `i128` are + returned exactly even when the `a * b` intermediate exceeds `i128::MAX`. + `settle_value` uses `checked_add` and `redeem`/`claim_pool_remainder` + propagate the error. The Python (`model/fpmm_model.py`) and TypeScript + (`agents/core/fpmm.ts`) mirrors gained `bounds_for_decimals` / + `boundsForDecimals` and 18-decimal fixtures matching the Rust outputs. + `math.rs` remains `soroban_sdk`-free and its tests run natively. ## What's implemented but not fully round-trip verified @@ -194,6 +208,10 @@ markers in the files themselves: testnet USDC issuer (needs verifying their current testnet address). - `.env.example` — the x402-on-Stellar facilitator URL to use for real payment settlement (none is hardcoded or guessed anywhere in this repo). +- `Config` XDR shape changed with the addition of `decimals: u32`. Any + consumer of `packages/bindings/*` must re-sync generated bindings; the + vendored `market_core.wasm` copies in `contracts/resolver` and + `contracts/treasury` were refreshed alongside this change. ## Known limitations @@ -215,6 +233,10 @@ markers in the files themselves: lifetime cap on capital ever committed to one market), documented in `contracts/treasury/src/lib.rs`'s comments, but worth stating here since it's easy to misread as a bug. +- **Collateral decimals are fixed at deploy time.** Changing decimals after + deployment is not supported, and rebasing / fee-on-transfer tokens are + out of scope (see the solvency-accounting issue). The supported range is + `0..=18`; higher values revert in the constructor. ## Reproducing this diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index 045e342..53be3fb 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -17,16 +17,27 @@ natively unit-testable and mirrored line-for-line in [`model/fpmm_model.py`](../model/fpmm_model.py) and `agents/core/fpmm.ts` for property testing and off-chain quoting. +The collateral token's `decimals()` is read once in the constructor and +stored under `DataKey::Decimals` (instance), and exposed through +`get_config().decimals. The economic bounds are derived from it by +`math::bxounds_for_decimals(d)`: `min_seed = 100 * 10^d` and +`max_sets = 1e^8 * 10^d`. Supported `d` is `0, 7..,18`; anything above 18 is +rejected at deploy time with `Error::UnsupportedDecimals`. This means a market +size bound is not a fixed constant but a function of the collateral's +precision — at 18 decimals `max_sets` is `1e26`, and the fixed-point +helpers in `math.rs` use 256-bit intermediates (`contracts/market-core/src/math.rs`, +`mod wide`) so a quotient that fits in `ii128` is always returned exactly, +regardless of the size of the intermediate product. + **Solvency invariant** (holds after every state transition — see the test suite for the ones exercised): the contract's own SEP-41 token balance for a market equals `sets_minted + fees_accrued`, and `reserve_yes + Σ(user yes positions) == sets_minted` (symmetrically for `no`). -**Resolution commitment**: `resolution_hash` is fixed at `create_market` -time — see [ADR 0001](adr/0001-resolution-commitment.md) for why. +**Resolution commitment**: `resolution_hash` is fixed at `create_market`time — see [ADR 0001](adr/0001-resolution-commitment.md) for why. Lifecycle: `create_market` → `buy`/`sell` (while open) → `resolve` or -`void_market` (resolver-only) → `redeem` per holder, `claim_pool_remainder` +@void_market` (resolver-only) → `redeem` per holder, `claim_pool_remainder` for the creator/LP, `sweep_fees` (permissionless) to the treasury. ### resolver @@ -34,10 +45,10 @@ for the creator/LP, `sweep_fees` (permissionless) to the treasury. Two independent resolution modes, both ultimately calling `market_core.resolve(market_id, outcome)`: -- **PriceThreshold**: reads a [Reflector](https://reflector.network) SEP-40 +- PriceThreshold: reads a [Reflector](https://reflector.network) SEP-40 price feed at (or after) the market's `close_time`, compares to a threshold, enforces a max staleness window. -- **Attested**: m-of-n signer votes; once `threshold` signers agree, a +- Attested: m-of-n signer votes; once `threshold` signers agree, a dispute window opens; anyone can finalize after it elapses, or a guardian (the resolver's `admin`) can `cancel_proposal` during the window. @@ -47,7 +58,7 @@ ADR 0001). A guardian can also `guardian_void_market` an unresolvable market directly. The resolver never depends on `market-core`'s Rust crate — see -[ADR 0002](adr/0002-cross-contract-calls.md) for why, and what that trades +[ADr 0002](adr/0002-cross-contract-calls.md) for why, and what that trades off. ### treasury @@ -84,15 +95,15 @@ one of the seeded `docs/wave-issues/`. ## Off-chain pieces -- **x402/**: an Express service selling reasoning traces for ~$0.01 via +- ``$x402/```: an Express service selling reasoning traces for ~$0.01 via `@x402/*`'s exact-payment Stellar scheme. Fetches the trace JSON (from IPFS or `reasoning-registry`'s configured store), recomputes its hash, and serves it only if the hash matches the on-chain record — see `x402/registry-trace-repository.ts`. -- **agents/**: `core/` is chain-agnostic (FPMM math, trace building, +- `agents/`: `core/` is chain-agnostic (FPMM math, trace building, strategy decisions); `stellar/` is the only part that knows about Soroban, wrapping the generated bindings with a dry-run/live switch. -- **packages/bindings/**: generated TypeScript clients (`scripts/gen-bindings.sh`). +- `packages/bindings/`: generated TypeScript clients (`scripts/gen-bindings.sh`). ## Known limitations (see docs/STATUS.md for the full, current list) diff --git a/oracledesk-stellar/model/fpmm_model.py b/oracledesk-stellar/model/fpmm_model.py index 53ec152..5076fc4 100644 --- a/oracledesk-stellar/model/fpmm_model.py +++ b/oracledesk-stellar/model/fpmm_model.py @@ -14,7 +14,7 @@ TypeScript (BigInt) for the trading agent; both must agree with this model. Conventions match math.rs: all amounts are non-negative ints in collateral -base units (USDC on Stellar = 7 decimals). Rounding always favours the pool. +base units. Rounding always favours the pool. """ from dataclasses import dataclass @@ -22,19 +22,49 @@ import random BPS = 10_000 -MAX_SETS = 1_000_000_000_000_000 # 1e15 = 100M USDC (7 dp) +MAX_SETS = 1_000_000_000_000_000 # 1e15 = 100M USDC (7 dp); deprecated alias MIN_PROB_BPS = 100 MAX_PROB_BPS = 9_900 +I128_MAX = (1 << 127) - 1 +MAX_DECIMALS = 18 class MathError(Exception): pass +@dataclass(frozen=True) +class Bounds: + min_seed: int + max_sets: int + + +def bounds_for_decimals(d: int) -> Bounds: + """Derive the economic bounds for a collateral with `d` decimals. + + Mirrors `math::bounds_for_decimals` in market-core: `min_seed = 100 * + 10^d` and `max_sets = 1e8 * 10^d`. Raises `MathError` for `d > 18`. + """ + if d < 0 or d > MAX_DECIMALS: + raise MathError("unsupported decimals") + scale = 10 ** d + return Bounds(min_seed=100 * scale, max_sets=100_000_000 * scale) + + +def _fits_i128(x: int) -> bool: + return 0 <= x <= I128_MAX + + +def _check_i128(x: int) -> int: + if not _fits_i128(x): + raise MathError("overflow") + return x + + def mul_div_floor(a: int, b: int, c: int) -> int: if a < 0 or b < 0 or c <= 0: raise MathError("invalid input") - return (a * b) // c + return _check_i128((a * b) // c) def mul_div_ceil(a: int, b: int, c: int) -> int: @@ -42,7 +72,7 @@ def mul_div_ceil(a: int, b: int, c: int) -> int: raise MathError("invalid input") p = a * b q = p // c - return q + 1 if p % c != 0 else q + return _check_i128(q + 1 if p % c != 0 else q) def price_yes_bps(reserve_yes: int, reserve_no: int) -> int: @@ -182,6 +212,27 @@ def _check_known_values(): ), q +def _check_known_values_18dp(): + """18-decimal fixtures that must match the Rust `known_values_18dp` test. + + The 7-decimal 1000/1000 -> 190 example scaled by 10**11, plus a seed at + `max_sets` for d = 18. + """ + scale = 10 ** 11 + q = calc_buy(1000 * scale, 1000 * scale, 100 * scale, 0) + assert ( + q.shares_out, + q.new_reserve_bought, + q.new_reserve_other, + ) == (190 * scale, 910 * scale, 1100 * scale), q + + b = bounds_for_decimals(18) + assert b.min_seed == 100 * 10 ** 18 + assert b.max_sets == 100_000_000 * 10 ** 18 + s = seed_reserves(b.max_sets, 5000) + assert s.reserve_yes > 0 and s.reserve_no > 0 + + def _check_seed_opens_at_requested_probability(): for p in (100, 2500, 5000, 7300, 9900): s = seed_reserves(10_000_000_000, p) @@ -249,6 +300,7 @@ def _check_void_never_overpays(): def run_all(): _check_known_values() + _check_known_values_18dp() _check_seed_opens_at_requested_probability() _check_k_never_decreases_and_round_trip_never_profits() _check_gross_for_shares_inverts_calc_sell() diff --git a/oracledesk-stellar/scripts/deploy-testnet.sh b/oracledesk-stellar/scripts/deploy-testnet.sh old mode 100755 new mode 100644 index fbe1076..bc4cc23 --- a/oracledesk-stellar/scripts/deploy-testnet.sh +++ b/oracledesk-stellar/scripts/deploy-testnet.sh @@ -18,12 +18,16 @@ AGENT_KEY=oracledesk-agent OUT=deployments/testnet.json WASM_DIR=target/wasm32v1-none/release -# treasury's risk caps (7-decimal USDC units). Must comfortably clear -# market-core's MIN_SEED (100 USDC) since agent_create_market's seed amount -# is capped by max_trade. Override via env if you need different limits. -MAX_TRADE=${MAX_TRADE:-2000000000} # 200 USDC -MAX_MARKET=${MAX_MARKET:-5000000000} # 500 USDC -MAX_DAILY=${MAX_DAILY:-10000000000} # 1,000 USDC +# treasury's risk caps, in raw collateral units. The default collateral +# below is a self-issued 7-decimal test USDC, so these defaults are sized +# for that. They must comfortably clear market-core's derived MIN_SEED +# (100 * 10^decimals) since agent_create_market's seed amount is capped by +# max_trade. If you swap in a token with different decimals, override these +# via env to match its units. +COLLATERAL_DECIMALS=${COLLATERAL_DECIMALS:-7} +MAX_TRADE=${MAX_TRADE:-2000000000} # 200 USDC at 7 decimals +MAX_MARKET=${MAX_MARKET:-5000000000} # 500 USDC at 7 decimals +MAX_DAILY=${MAX_DAILY:-10000000000} # 1,000 USDC at 7 decimals DEFAULT_FEE_BPS=${DEFAULT_FEE_BPS:-100} # 1% log() { echo "==> $*" >&2; } @@ -81,8 +85,11 @@ AGENT=$(stellar keys address "$AGENT_KEY") json_set --arg d "$DEPLOYER" --arg a "$AGENT" '.deployer = $d | .agent = $a' # --- USDC test asset --------------------------------------------------------- -# Self-issued testnet "USDC": a real SEP-41 token (7 decimals, matching the -# design doc) with no dependency on guessing a real issuer's contract id. +# Self-issued testnet "USDC": a real SEP-41 token with no dependency on +# guessing a real issuer's contract id. Classic assets report 7 decimals, +# which matches COLLATERAL_DECIMALS above. market-core reads decimals() at +# construction and derives its seed/size bounds from it, so the asset must +# be deployed before market-core (it is, below). # USDC=$(json_get .contracts.usdc) @@ -92,6 +99,11 @@ if [ -z "$USDC" ]; then --asset "USDC:$DEPLOYER" \ --source-account "$DEPLOYER_KEY" \ --network "$NETWORK") + # Classic assets are always 7 decimals; sanity-check the assumption that + # the treasury cap defaults above were sized against. + if [ "$COLLATERAL_DECIMALS" != "7" ]; then + log "warning: COLLATERAL_DECIMALS=$COLLATERAL_DECIMALS but Classic assets report 7" + fi json_set --arg id "$USDC" '.contracts.usdc = $id' else log "USDC already deployed: $USDC" @@ -100,6 +112,8 @@ fi # --- market-core (placeholder resolver/treasury, wired up after) ----------- MARKET_CORE=$(json_get .contracts.market_core) if [ -z "$MARKET_CORE" ]; then + # market-core's __constructor calls collateral.decimals() and reverts if + # the token is not deployed yet, so USDC must exist before this step. log "deploying market-core" MARKET_CORE=$(stellar contract deploy \ --wasm "$WASM_DIR/market_core.wasm" \ @@ -112,6 +126,15 @@ if [ -z "$MARKET_CORE" ]; then --resolver "$DEPLOYER" \ --default_fee_bps "$DEFAULT_FEE_BPS") json_set --arg id "$MARKET_CORE" '.contracts.market_core = $id' + # Record the collateral decimals market-core was constructed against, so + # downstream tooling (treasury caps, agent seeding) can size amounts in + # the right units without re-querying the token. + MARKET_CORE_DECIMALS=$(stellar contract invoke --id "$MARKET_CORE" \ + --source-account "$DEPLOYER_KEY" --network "$NETWORK" \ + -- get_config 2>/dev/null | jq -r '.decimals // empty' || true) + if [ -n "$MARKET_CORE_DECIMALS" ]; then + json_set --argjson d "$MARKET_CORE_DECIMALS" '.collateral_decimals = $d' + fi else log "market-core already deployed: $MARKET_CORE" fi @@ -193,7 +216,11 @@ fi # --- fund the treasury with test USDC --------------------------------------- TREASURY_FUNDED=$(json_get .treasury_funded_amount) -FUND_AMOUNT=${TREASURY_FUND_AMOUNT:-10000000000000} # 1,000,000 USDC +# Default fund amount is 1,000,000 USDC at 7 decimals. Scale by the +# collateral's actual decimals so this stays 1,000,000 whole tokens if the +# deployer swaps in an 18-decimal token. +DEFAULT_FUND_AMOUNT=$((1000000 * 10 ** COLLATERAL_DECIMALS)) +FUND_AMOUNT=${TREASURY_FUND_AMOUNT:-$DEFAULT_FUND_AMOUNT} if [ "$TREASURY_FUNDED" != "$FUND_AMOUNT" ]; then log "minting $FUND_AMOUNT stroops of test USDC to treasury" stellar contract invoke --id "$USDC" --source-account "$DEPLOYER_KEY" \