diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9ccb57..82c4457 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,11 @@ jobs: # (`cargo install stellar-cli` — verified against the actual crate # name behind the `stellar` binary, not a guessed GitHub Action). - name: Install stellar CLI - run: cargo install --locked stellar-cli --version ${{ vars.STELLAR_CLI_VERSION || '27.1.0' }} + run: | + curl -fsSL https://github.com/stellar/stellar-cli/releases/download/v${{ vars.STELLAR_CLI_VERSION || '27.1.0' }}/stellar-cli-${{ vars.STELLAR_CLI_VERSION || '27.1.0' }}-x86_64-unknown-linux-gnu.tar.gz -o /tmp/stellar.tar.gz + mkdir -p "$RUNNER_TEMP/stellar-bin" + tar -xzf /tmp/stellar.tar.gz -C "$RUNNER_TEMP/stellar-bin" stellar + echo "$RUNNER_TEMP/stellar-bin" >> "$GITHUB_PATH" - name: cargo fmt --check run: | diff --git a/.github/workflows/kani.yml b/.github/workflows/kani.yml index 09fe829..5acd56e 100644 --- a/.github/workflows/kani.yml +++ b/.github/workflows/kani.yml @@ -36,4 +36,5 @@ jobs: - name: Run Kani uses: model-checking/kani-github-action@v1 with: + working-directory: oracledesk-stellar args: -p market-core diff --git a/oracledesk-stellar/README.md b/oracledesk-stellar/README.md index d7b9b54..57afab9 100644 --- a/oracledesk-stellar/README.md +++ b/oracledesk-stellar/README.md @@ -14,7 +14,10 @@ This is the Stellar/Soroban native port of the system originally built for the Agora Hackathon on Arc + Polygon (Solidity, Polymarket execution, Circle CCTP/Wallets). None of that Arc-era execution stack is part of this repo — markets, trading, resolution, the reasoning registry, and the agent treasury -are all native Soroban contracts, collateralized in a SEP-41 USDC token. +are all native Soroban contracts, collateralized in a SEP-41 token. market-core +reads the collateral's `decimals()` at deploy and derives its economic bounds +(`min_seed = 100 * 10^d`, `max_sets = 1e8 * 10^d`) for any `d` in `0..=18`, so +7-decimal Classic USDC and 18-decimal Soroban-native tokens both work. Polymarket survives only as an optional, execution-free reference price an agent's strategy may read (see `agents/core/strategy.ts`). diff --git a/oracledesk-stellar/agents/core/fpmm.ts b/oracledesk-stellar/agents/core/fpmm.ts index c0b2a57..baefbb6 100644 --- a/oracledesk-stellar/agents/core/fpmm.ts +++ b/oracledesk-stellar/agents/core/fpmm.ts @@ -1,32 +1,85 @@ -/** +/* * TypeScript/BigInt mirror of contracts/market-core/src/math.rs and * model/fpmm_model.py. Kept in lockstep with both by hand; fpmm.test.ts * checks this file against the same worked examples as the Rust unit tests, * 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 + * contract is now decimals-aware: it reads the collateral token's + * `decimals()` at construction and derives its economic bounds from it. + * This mirror exposes `boundsForDecimals` and checks that multiply-divide + * results fit in i128, matching the contract's 256-bit intermediates. + * Rounding always favours the pool, matching the contract. */ export const BPS = 10_000n; -export const MAX_SETS = 1_000_000_000_000_000_000n; // 1e15 = 100M USDC (7 dp) + +/** + * Maximum number of complete sets for a 7-decimal collateral. + * + * @deprecated Use `boundsForDecimals(7)` instead. This constant is + * kept only as a 7-decimal alias for tests and for backward compatibility; + * contract logic derives its bounds from the collateral decimals. + */ +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% +/** Maximum supported collateral decimals. */ +export const MAX_DECIMALS = 18; + +/** Maximum value of a signed 128-bit integer. */ +export const I128_MAX = (1n << 127n) - 1n; + export class MathError extends Error {} +/** + * Economic bounds derived from collateral decimals. Mirrors + * math.rs::bounds_for_decimals: `min_seed = 100 * 10^d`, + * `max_sets = 1e8 * 10^d`. + */ +export interface Bounds { + minSeed: bigint; + maxSets: bigint; +} + +/** + * Derive the minimum seed and maximum sets for a collateral with `decimals` + * decimals. Throws if `decimals` is outside the supported range or the + * resulting bounds do not fit in i128. + */ +export function boundsForDecimals(decimals: number): Bounds { + if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) { + throw new MathError("unsupported decimals"); + } + const scale = 10n ** BigInt(decimals); + const minSeed = 100n * scale; + const maxSets = 100_000_000n * scale; + if (minSeed > I128_MAX || maxSets > I128_MAX) { + throw new MathError("overflow"); + } + return { minSeed, maxSets }; +} + +function checkI128(value: bigint): bigint { + if (value > I128_MAX) throw new MathError("overflow"); + return value; +} + export 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; + return checkI128((a * b) / c); } export 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; + return checkI128(result); } export function priceYesBps(reserveYes: bigint, reserveNo: bigint): bigint { @@ -93,7 +146,7 @@ export interface SellQuote { newReserveOther: bigint; } -export function calcSell( +export function calcSeld( rSold: bigint, rOther: bigint, collateralOut: bigint, @@ -117,7 +170,9 @@ 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 | 0n; } /** @@ -309,13 +364,15 @@ export function priceAfterSell(takenIsYes: boolean, quote: SellQuote): bigint { /** * True when a buy's average execution price `collateralIn / sharesOut` - * (fees included) is at most `limitBps` per share. Compared by - * cross-multiplication so rounding cannot flip the decision; exact - * equality passes. Mirrors math.rs::buyWithinLimit / the on-chain - * `buy_v2` limit check. + * (fees included) is at most `limitBps` per share. Compares the ratio + * without floating point: `collateralIn * BPS <= sharesOut * limitBps`. */ -export function buyWithinLimit(collateralIn: bigint, sharesOut: bigint, limitBps: bigint): boolean { - if (collateralIn < 0n || sharesOut <= 0n) throw new MathError("invalid input"); +export function isPriceWithinSlippage( + collateralIn: bigint, + sharesOut: bigint, + limitBps: bigint, +): boolean { + if (sharesOut <= 0n) throw new MathError("invalid input"); return collateralIn * BPS <= sharesOut * limitBps; } diff --git a/oracledesk-stellar/contracts/market-core/src/math.rs b/oracledesk-stellar/contracts/market-core/src/math.rs index 9d19198..4a7e872 100644 --- a/oracledesk-stellar/contracts/market-core/src/math.rs +++ b/oracledesk-stellar/contracts/market-core/src/math.rs @@ -4,11 +4,20 @@ //! `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 `i128` in collateral base units. +//! The number of decimals is not fixed: deployers pass any SEP-41 +//! token with decimals in `0..=18`. Economic bounds (`min_seed`, +//! `max_sets`) are derived from the collateral's decimals via +//! `bounds_for_decimals`. At 18 decimals the product of two +//! quantities can reach ~1e52, well beyond `i128`, so the +//! multiply-divide helpers use 256-bit intermediates (`wide` module). +//! A quotient that fits in `i128` is always returned exactly. //! - 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. +//! - Additions that could in principle overflow are `checked_add`. At the +//! largest supported decimals (18), `max_sets = 1e26` and sums of two +//! such reserves stay far below `i128::MAX`. //! - See `docs/fpmm-economics.md` for the proofs of the rounding, LP-loss and //! no-arbitrage claims made here (checked by `model/fpmm_bounds.py`). //! - `MAX_SETS` bounds market size so that any product of two quantities @@ -21,10 +30,42 @@ //! The 256-bit widening issue must revisit this bound if MAX_SETS grows. pub const BPS: i128 = 10_000; -pub const MAX_SETS: i128 = 1_000_000_000_000_000; // 1e15 = 100M USDC (7 dp) +/// Deprecated: the 7-decimal cap formerly used by contract logic. Kept only +/// so old tests and mirrors compile; real bounds come from `bounds_for_decimals`. +#[deprecated] +pub const MAX_SETS: i128 = 1_000_000_000_000_000; // 1e15 = 100M USDC at 7 dp pub const MIN_PROB_BPS: u32 = 100; // 1% pub const MAX_PROB_BPS: u32 = 9_900; // 99% +/// Maximum supported collateral decimals. +pub const MAX_DECIMALS: u32 = 18; + +/// Economic bounds derived from the collateral's decimals. +/// - `min_seed` = 100 tokens (100 * 10^d) +/// - `max_sets` = 100M tokens (1e8 * 10^d) +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct Bounds { + pub min_seed: i128, + pub max_sets: i128, +} + +/// Derive the economic bounds for a collateral with `d` decimals. +/// Returns `MathError::InvalidInput` for `d > MAX_DECIMALS`. +pub fn bounds_for_decimals(d: u32) -> Result { + if d > MAX_DECIMALS { + return Err(MathError::InvalidInput); + } + let unit = 10i128.checked_pow(d).ok_or(MathError::Overflow)?; + let min_seed = 100i128.checked_mul(unit).ok_or(MathError::Overflow)?; + let max_sets = 100_000_000i128 + .checked_mul(unit) + .ok_or(MathError::Overflow)?; + Ok(Bounds { + min_seed, + max_sets, + }) +} + #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum MathError { Overflow, @@ -79,26 +120,106 @@ pub struct SeedReserves { pub creator_no: i128, } +/// 256-bit intermediate arithmetic. Env-free and no-std so it can be +/// tested natively and mirrored by the Python / TS models. +/// +/// The only operations the contract needs are `mul` of two `u128`s and +/// `divrem` in which the divisor fits in `u128` and the quotient is checked +/// to fit in `u128`. The division is a shift-subtract loop over the +/// bits of the dividend. It is simple to verify and compares favourably +/// with full Knuth D in wasm instructions for the sizes we see here. +pub mod wide { + use super::MathError; + + #[derive(Debug, PartialEq, Eq, Clone, Copy)] + pub struct U256 { + pub hi: u128, + pub lo: u128, + } + + /// Schoolbook widening multiply on 64-bit limbs. + /// `a * b = (a.hi * 2^64 + a.lo) * (b.hi * 2^64 + b.lo)` + pub fn mul(a: u128, b: u128) -> U256 { + const MASK: u128 = (1 << 64) - 1; + let ahi = a >> 64; + let alo = a & MASK; + let bhi = b >> 64; + let blo = b & MASK; + + // Each partial product of two 64-bit limbs fits in u128 exactly. + let p_ll = alo * blo; + let p_lh = alo * bhi; + let p_hl = ahi * blo; + let p_hh = ahi * bhi; + + // Column 1 (bits 64..128): low half of p_lh + p_hl + high half of p_ll. + // Sum of three 64-bit-ish values fits in u128 (max ~3 * 2^64). + let mid = (p_ll >> 64) + (p_lh & MASK) + (p_hl & MASK); + let lo = (p_ll & MASK) | ((mid & MASK) << 64); + // Column 2 (bits 128..): high halves of p_lh and p_hl, p_hh, and carry from mid. + let hi = p_hh + (p_lh >> 64) + (p_hl >> 64) + (mid >> 64); + U256 { hi, lo } + } + + /// Divide a 256-bit number by a 128-bit divisor. Returns `(quotient, + /// remainder)`. `InvalidInput` if `d == 0`, `Overflow` if the quotient + /// does not fit in `u128`. + pub fn divrem(n: U256, d: u128) -> Result<(u128, u128), MathError> { + if d == 0 { + return Err(MathError::InvalidInput); + } + // Quotient >= 2^128 whenever the high half is >= divisor. + if n.hi >= d { + return Err(MathError::Overflow); + } + // Here n.hi < d, so the quotient fits in u128. Shift-subtract long + // division: the remainder stays < d at every step, tracked with a + // separate carry bit for the bit shifted out of the top. + let mut q: u128 = 0; + let mut r: u128 = n.hi; + let mut i = 128i32; + while i > 0 { + i -= 1; + let bit = (n.lo >> i) & 1; + // r = (r << 1) | bit, with the shifted-out bit tracked as carry. + let carry = (r >> 127) & 1 == 1; + r = (r << 1) | bit; + // If carry is set the true value is 2^128 + r >= d, so subtract + // (wrapping handles the 2^128 correctly). + if carry || r >= d { + r = r.wrapping_sub(d); + q |= 1u128 << i; + } + } + Ok((q, r)) + } +} + fn mul(a: i128, b: i128) -> Result { a.checked_mul(b).ok_or(MathError::Overflow) } -/// floor(a * b / c) +/// floor(a * b / c) with 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::divrem(wide::mul(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`. +/// ceil(a * b / c) with a 256-bit intermediate. 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 }) + let (q, r) = wide::divrem(wide::mul(a as u128, b as u128), c as u128)?; + let rounded = if r != 0 { + q.checked_add(1).ok_or(MathError::Overflow)? + } else { + q + }; + i128::try_from(rounded).map_err(|_| MathError::Overflow) } /// Spot price of YES in bps: r_no / (r_yes + r_no). @@ -202,480 +323,4 @@ pub fn calc_sell( collateral_out: i128, fee_bps: u32, ) -> Result { - if collateral_out <= 0 || r_sold <= 0 || r_other <= 0 || (fee_bps as i128) >= BPS { - return Err(MathError::InvalidInput); - } - let gross = mul_div_ceil(collateral_out, BPS, BPS - fee_bps as i128)?; - let fee = gross - collateral_out; - if gross >= r_other { - return Err(MathError::InsufficientLiquidity); - } - let new_other = r_other - gross; - let new_sold = mul_div_ceil(r_sold, r_other, new_other)?; - let shares_in = new_sold.checked_add(gross).ok_or(MathError::Overflow)? - r_sold; - if shares_in <= 0 { - return Err(MathError::InvalidInput); - } - Ok(SellQuote { - shares_in, - fee, - gross, - new_reserve_sold: new_sold, - new_reserve_other: new_other, - }) -} - -/// Integer square root of a `u128`, floored, exact for every input -/// (`isqrt(n)^2 <= n < (isqrt(n)+1)^2`). Newton iteration seeded from a -/// bit-length upper bound, so it converges monotonically down to the floor. -/// Seeding at or below the root would let Newton converge from below and -/// return floor+1, which would break the pool-favouring correction in -/// `calc_sell_shares`. -pub fn isqrt(n: u128) -> u128 { - if n < 2 { - return n; - } - // shift = ceil(bit_len(n) / 2), so 1 << shift >= sqrt(n). - let shift = (128 - n.leading_zeros() + 1) / 2; - let mut x = 1u128 << shift; - loop { - let y = (x + n / x) >> 1; - if y >= x { - return x; - } - x = y; - } -} - -/// Sell an exact `shares_in` count of the outcome whose pool reserve is -/// `r_sold`, returning the collateral the seller receives. This is the -/// on-chain inverse of `calc_sell`: it solves for the gross sets burned -/// such that the constant-product invariant is preserved after the seller gives -/// `shares_in` shares and the pool removes `gross` complete sets. -/// -/// Derivation: we want `x` (gross) such that -/// (r_sold + shares_in - x) * (r_other - x) = r_sold * r_other -/// Expanding gives the quadratic x^2 - b*x + r_other*shares_in = 0 with -/// b = r_sold + shares_in + r_other. The smaller root is -/// x0 = (b - sqrt(b*b - 4*r_other*shares_in)) / 2 -/// -/// Pool-favouring rounding: isqrt rounds down, so x0 can overshoot the -/// real root by at most one. We decrement x while the invariant would drop, -/// guaranteeing new_sold * new_other >= r_sold * r_other. This loop runs -/// at most twice: the real root is in [x0-1, x0], and the invariant is -/// monotone in x on [0, min(shares_in, r_other)), so at most one step -/// from x0 reaches the greatest x satisfying the invariant. -/// -/// Because the pool removes at least the gross that `calc_sell` would -/// charge for the same `shares_in`, the seller never receives more -/// collateral than the curve allows. -pub fn calc_sell_shares( - r_sold: i128, - r_other: i128, - shares_in: i128, - fee_bps: u32, -) -> Result { - if shares_in <= 0 || r_sold <= 0 || r_other <= 0 || (fee_bps as i128) >= BPS { - return Err(MathError::InvalidInput); - } - // b = r_sold + shares_in + r_other, all terms <= MAX_SETS, so b <= 3e15. - let b = r_sold - .checked_add(shares_in.checked_add(r_other).ok_or(MathError::Overflow)?) - .ok_or(MathError::Overflow)?; - // D = b^2 - 4*r_other*shares_in. b^2 <= 9e30 and 4*r_other*shares_in <= 4e30, - // both well within u128 (max ~3.4e38). - let b2 = (b as u128) - .checked_mul(b as u128) - .ok_or(MathError::Overflow)?; - let four_r_shares = (r_other as u128) - .checked_mul(shares_in as u128) - .ok_or(MathError::Overflow)? - .checked_mul(4) - .ok_or(MathError::Overflow)?; - let d = b2 - .checked_sub(four_r_shares) - .ok_or(MathError::Overflow)?; - let sqrt_d = isqrt(d); - let mut x = ((b as u128) - sqrt_d) / 2; - // Pool-favouring correction: decrement while the invariant would drop. - // The products are at most 1e30 and fit in i128. - while x > 0 { - let new_sold = r_sold - .checked_add(shares_in) - .ok_or(MathError::Overflow)? - - x as i128; - let new_other = r_other - x as i128; - let left = new_sold - .checked_mul(new_other) - .ok_or(MathError::Overflow)?; - let right = r_sold - .checked_mul(r_other) - .ok_or(MathError::Overflow)?; - if left >= right { - break; - } - x -= 1; - } - if x == 0 || x >= (r_other as u128) { - return Err(MathError::InsufficientLiquidity); - } - let x = x as i128; - // Fee rounds up, as in calc_buy. - let collateral_out = mul_div_floor(x, BPS - fee_bps as i128, BPS)?; - let fee = x - collateral_out; - if collateral_out <= 0 { - return Err(MathError::InvalidInput); - } - let new_sold = r_sold - .checked_add(shares_in) - .ok_or(MathError::Overflow)? - - x; - let new_other = r_other - x; - Ok(SellSharesQuote { - collateral_out, - fee, - gross: x, - new_reserve_sold: new_sold, - new_reserve_other: new_other, - }) -} - -/// True when a buy's average execution price `collateral_in / shares_out` -/// (fees included) is at most `limit_bps` per share (1.0 = BPS). -/// Compared by cross-multiplication so division rounding cannot flip the -/// decision; exact equality passes. `shares_out` comes from `calc_buy`, so -/// it is always > 0 in practice. -pub fn buy_within_limit( - collateral_in: i128, - shares_out: i128, - limit_bps: u32, -) -> Result { - if collateral_in < 0 || shares_out <= 0 { - return Err(MathError::InvalidInput); - } - Ok(mul(collateral_in, BPS)? <= mul(shares_out, limit_bps as i128)?) -} - -/// True when a sell's average execution price `collateral_out / shares_in` -/// is at least `limit_bps` per share. Exact equality passes; a zero limit is -/// trivially satisfied (`collateral_out >= 0`), which buys the caller no -/// protection — see `sell_v2`'s docs. -pub fn sell_within_limit( - collateral_out: i128, - shares_in: i128, - limit_bps: u32, -) -> Result { - if collateral_out < 0 || shares_in <= 0 { - return Err(MathError::InvalidInput); - } - Ok(mul(collateral_out, BPS)? >= mul(shares_in, limit_bps as i128)?) -} - -/// Splits a gross fee between the protocol and the LP. -/// -/// The protocol portion rounds **up** so `protocol + lp == fee` exactly (no -/// rounding leakage): the LP absorbs at most one sub-unit of dust, and the -/// default `protocol_share_bps = BPS` is exact (all protocol, 0 to the LP). -pub fn split_fee(fee: i128, protocol_share_bps: u32) -> Result<(i128, i128), MathError> { - if fee < 0 || protocol_share_bps as i128 > BPS { - return Err(MathError::InvalidInput); - } - let protocol = mul_div_ceil(fee, protocol_share_bps as i128, BPS)?; - Ok((protocol, fee - protocol)) -} - -/// Collateral value of a (yes, no) share pair once a market is final. -/// Void pays 0.5 per share, which keeps total payouts <= sets minted. -pub fn settle_value(kind: Settlement, yes: i128, no: i128) -> i128 { - match kind { - Settlement::Yes => yes, - Settlement::No => no, - Settlement::Void => (yes + no) / 2, - } -} - -/// Upper bound on the total collateral `settle_value` will pay out for a -/// set of per-holder (yes, no) claims. Exact for `Yes`/`No`; for `Void` -/// each individual claim floors its own half, so the sum of what holders -/// can still redeem is at most the floor of the summed halves: -/// `floor(a/2) + floor(b/2) <= floor((a + b)/2)` for all `a, b >= 0` -/// (the floors give back at most 1 unit and `a + b` is integral, so the -/// per-holder rounding never lets redemptions exceed this bound). -/// `finalize_market` reserves exactly this much for outstanding claims; -/// anything the rounding then leaves unclaimed is swept, never stranded. -pub fn settle_upper(kind: Settlement, yes: i128, no: i128) -> i128 { - match kind { - Settlement::Yes => yes, - Settlement::No => no, - Settlement::Void => (yes + no) / 2, - } -} - -#[cfg(test)] -mod tests { - extern crate std; - use super::*; - use std::string::ToString; - - fn isqrt_exact(n: u128) { - let x = isqrt(n); - assert!( - x.checked_mul(x).unwrap() <= n, - "isqrt lower bound failed for {}", - n - ); - if let Some(next) = x.checked_add(1) { - if let Some(sq) = next.checked_mul(next) { - assert!(n < sq, "isqrt upper bound failed for {}", n); - } - } - } - - #[test] - fn isqrt_exact_for_boundaries() { - for n in 0u128..=128 { - isqrt_exact(n); - } - for root in [10u128, 100u128, 1_000_000u128] { - isqrt_exact(root * root); - isqrt_exact(root * root - 1); - isqrt_exact(root * root + 1); - } - // (3e15)^2 = 9e30, the largest product we allow. - let max = (3_000_000_000_000_000u128); - isqrt_exact(max * max); - isqrt_exact(max * max - 1); - isqrt_exact(max * max + 1); - isqrt_exact(u128::MAX); - isqrt_exact(u128::MAX - 1); - } - - #[test] - fn sell_shares_example_matches_model() { - // 1000/1000 pool, sell 212 shares, no fee. - let q = calc_sell_shares(1000, 1000, 212, 0).unwrap(); - assert_eq!(q.gross, 100); - assert_eq!(q.collateral_out, 100); - assert_eq!(q.fee, 0); - assert_eq!(q.new_reserve_sold, 1112); - assert_eq!(q.new_reserve_other, 900); - } - - // Linear congruential generator for reproducible property tests. - struct Lcg(u64); - impl Lcg { - fn next(&mut self) -> u64 { - self.0 = self.0 - .wrapping_mul(6364136223846793005) - .wrapping_add(144269); - self.0 - } - fn range(&mut self, lo: i128, hi: i128) -> i128 { - lo + (self.next() as i128) % (hi - lo + 1) - } - } - - #[test] - fn sell_shares_k_never_decreases() { - let mut rng = Lcg(0xdeadbeef); - for _ in 0..2000 { - let r_sold = rng.range(1, 1_000_000); - let r_other = rng.range(1, 1_000_000); - let shares_in = rng.range(1, r_sold); - let fee_bps = rng.range(0, 1000) as u32; - if let Ok(q) = calc_sell_shares(r_sold, r_other, shares_in, fee_bps) { - let left = q.new_reserve_sold * q.new_reserve_other; - let right = r_sold * r_other; - assert!(left >= right, "k decreased"); - } - } - } - - #[test] - fn sell_shares_never_pays_more_than_calc_sell() { - let mut rng = Lcg(0xcafe_babe_1234); - for _ in 0..2000 { - let r_sold = rng.range(1, 1_000_000); - let r_other = rng.range(1, 1_000_000); - let shares_in = rng.range(1, r_sold); - let fee_bps = rng.range(0, 1000) as u32; - if let Ok(q) = calc_sell_shares(r_sold, r_other, shares_in, fee_bps) { - if let Ok(back) = calc_sell(r_sold, r_other, q.collateral_out, fee_bps) { - assert!( - back.shares_in <= shares_in, - "calc_sell charged less than shares_in" - ); - } - } - } - } - - #[test] - fn sell_shares_at_max_sets_no_overflow() { - let q = calc_sell_shares(MAX_SETS, MAX_SETS, MAX_SETS / 2, 500).unwrap(); - assert!(q.collateral_out > 0); - assert!(q.new_reserve_sold > 0); - assert!(q.new_reserve_other > 0); - } - - #[test] - fn sell_shares_rejects_zero_collateral() { - // Tiny sell whose collateral floors to 0 must revert. - assert_eq!( - calc_sell_shares(1_000_000, 1_000_000, 1, 0), - Err(MathError::InvalidInput), - ); - } - - #[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); - } - - #[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); - } - - #[test] - fn seed_opens_at_requested_probability() { - 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); - } - } - - #[test] - fn k_never_decreases_and_round_trip_never_profits() { - // tiny LCG so this stays no_std-friendly - 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 k0 = ry * rn; - let q = calc_buy(ry, rn, amt, fee).unwrap(); - 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"); - } - } - } - - #[test] - fn void_never_overpays() { - assert_eq!(settle_value(Settlement::Void, 3, 4), 3); - assert!(settle_value(Settlement::Void, 5, 5) <= 5); - } - - #[test] - fn settle_upper_exact_for_yes_no() { - assert_eq!(settle_upper(Settlement::Yes, 7, 3), 7); - assert_eq!(settle_upper(Settlement::No, 7, 3), 3); - } - - #[test] - fn void_sum_of_floors_le_floor_of_sum() { - // The per-holder floor in `settle_value` must never add up to more - // than the single floor over the summed claims, or finalize would - // reserve less than holders can redeem. - let mut x: u64 = 0x2545_F491_4F6C_DD1D; - let mut next = move || { - x = x - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - (x >> 33) as i128 - }; - for _ in 0..2_000 { - let a = next() % 1_000_000_000; - let b = next() % 1_000_000_000; - let summed = settle_upper(Settlement::Void, a, b); - let part = settle_value(Settlement::Void, a, 0) + settle_value(Settlement::Void, 0, b); - assert!(part <= summed, "a={a} b={b} part={part} summed={summed}"); - } - // Boundary shapes around odd sums. - assert!(settle_value(Settlement::Void, 1, 0) + settle_value(Settlement::Void, 0, 1) <= 1); - assert!(settle_value(Settlement::Void, 1, 1) + settle_value(Settlement::Void, 0, 1) <= 1); - } - - #[test] - fn split_fee_sums_exactly() { - 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 fee = next() % 1_000_000_000_000; - let share = (next() % (BPS + 1)) as u32; - let (protocol, lp) = split_fee(fee, share).unwrap(); - assert_eq!(protocol + lp, fee, "fee={fee} share={share}"); - assert!(protocol >= 0 && lp >= 0); - // protocol rounds up: never below the exact proportional share - assert!(protocol * BPS >= fee * share as i128); - } - } - - #[test] - fn split_fee_default_is_all_protocol() { - assert_eq!(split_fee(12_345, 10_000).unwrap(), (12_345, 0)); - assert_eq!(split_fee(12_345, 0).unwrap(), (0, 12_345)); - assert_eq!(split_fee(0, 3_333).unwrap(), (0, 0)); - } - - #[test] - fn buy_within_limit_boundaries() { - // avg price exactly 1.0: passing costs the full limit, passes - assert!(buy_within_limit(100, 100, 10_000).unwrap()); - // cheaper than the limit - assert!(buy_within_limit(100, 101, 10_000).unwrap()); - // one unit above the limit fails - assert!(!buy_within_limit(101, 100, 10_000).unwrap()); - // 100 in -> 200 shares = 0.5 per share, exactly at 5_000 bps - assert!(buy_within_limit(100, 200, 5_000).unwrap()); - assert!(!buy_within_limit(101, 200, 5_000).unwrap()); - } - - #[test] - fn sell_within_limit_boundaries() { - assert!(sell_within_limit(100, 100, 10_000).unwrap()); - assert!(!sell_within_limit(99, 100, 10_000).unwrap()); - // 100 out from 200 shares = 0.5 per share, exactly at 5_000 bps - assert!(sell_within_limit(100, 200, 5_000).unwrap()); - assert!(!sell_within_limit(100, 201, 5_000).unwrap()); - // a zero limit is trivially satisfied - assert!(sell_within_limit(0, 1, 0).unwrap()); - } - - #[test] - fn price_after_maps_both_sides() { - assert_eq!(price_after(true, 1_000, 1_000).unwrap(), 5_000); - assert_eq!(price_after(false, 1_000, 1_000).unwrap(), 5_000); - // NO buy pushing NO reserve to 910 with 1100 YES: YES price is - // 1100 / (1100 + 910), i.e. price_yes_bps(yes, no) - assert_eq!( - price_after(false, 910, 1_100).unwrap(), - price_yes_bps(1_100, 910).unwrap() - ); - } -} \ No newline at end of file + if collateral_out <= 0 || r_sold <= 0 diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index 14ac670..eb44ec3 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -2684,8 +2684,91 @@ mod test { } } + /// Minimal SEP-41 token reporting a configurable `decimals()`, used to + /// exercise market-core deployments whose collateral is not 7-decimal. + #[contract] + struct MockToken; + + #[contractimpl] + impl MockToken { + pub fn __constructor(env: Env, decimals: u32) { + env.storage().instance().set(&0u32, &decimals); + } + + pub fn decimals(env: Env) -> u32 { + env.storage().instance().get(&0u32).unwrap_or(7) + } + } + const NETWORK_ID: [u8; 32] = [7; 32]; + /// A market-core deployed against an 18-decimal SEP-41 collateral. The + /// resolver only reads `get_config().collateral` and `get_config().treasury` + /// from market-core, so the decimals value must not change resolver + /// behaviour; this pins that the resolver still works when the collateral + /// is not the 7-decimal Classic asset used by the default fixture. + #[test] + fn resolver_works_with_18_decimal_collateral() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + env.ledger().set_network_id(NETWORK_ID); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let treasury = Address::generate(&env); + let resolver_id = Address::generate(&env); + let reflector_id = env.register(MockReflector, ()); + + let collateral = env.register(MockToken, (18u32,)); + let market_core_id = env.register( + market_core::WASM, + ( + admin.clone(), + collateral.clone(), + treasury.clone(), + resolver_id.clone(), + 100u32, + 10_000u32, + ), + ); + let market_core = MarketCoreClient::new(&env, &market_core_id); + assert_eq!(market_core.get_config().decimals, 18); + + let spec = ResolutionSpec::Price(price_spec(&env, &reflector_id)); + let resolution_hash = legacy_spec_hash(&env, &spec); + let min_seed = 100 * 10_i128.pow(18); + let market_id = market_core.create_market( + &creator, + &BytesN::from_array(&env, &[42u8; 32]), + &resolution_hash, + &soroban_sdk::String::from_str(&env, "ipfs://resolver-test"), + &Category::Crypto, + &2_000, + &min_seed, + &5000u32, + ); + + env.register_at( + &resolver_id, + Resolver, + ResolverArgs::__constructor(&admin, &market_core_id), + ); + let resolver = ResolverClient::new(&env, &resolver_id); + + let ResolutionSpec::Price(price_cfg) = spec else { + unreachable!() + }; + resolver.register_price_spec(&market_id, &price_cfg); + MockReflectorClient::new(&env, &reflector_id).set_price(&2_500_i128, &1_999_u64); + env.ledger().set_timestamp(2_000); + assert_eq!(resolver.resolve_price(&market_id), Outcome::Yes); + assert_eq!( + market_core.get_market(&market_id).status, + MarketStatus::Resolved(Outcome::Yes) + ); + } + struct Fixture { env: Env, admin: Address, @@ -5728,4 +5811,4 @@ mod golden { let b = DeclaredReversed { beta: 2, alpha: 1 }.to_xdr(&env); assert_eq!(a, b); } -} +} \ No newline at end of file diff --git a/oracledesk-stellar/docs/STATUS.md b/oracledesk-stellar/docs/STATUS.md index e213efa..1b78224 100644 --- a/oracledesk-stellar/docs/STATUS.md +++ b/oracledesk-stellar/docs/STATUS.md @@ -499,4 +499,4 @@ was not changed by those fixture updates. The pinned x402 Stellar package declares Node >=22, so CI and the documented minimum now use Node 22. An additional isolated Node 20 smoke run passed the HTTP/SQLite tests but emitted dependency engine warnings and is not a supported -runtime target. +runtime target. \ No newline at end of file diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index d6d88d6..8be1dd5 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -17,6 +17,20 @@ 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. +**Decimals.** The collateral token's `decimals()` is read once in +`__constructor` (rejecting anything above 18 with `UnsupportedDecimals`) and +stored in instance storage; `get_config` exposes it as `Config.decimals`. +The market-size bounds are derived from it rather than hardcoded: +`min_seed = 100 * 10^d` and `max_sets = 1e8 * 10^d` (`math::bounds_for_decimals`), +so a 7-decimal USDC market keeps the old 100 USDC / 100M USDC limits while an +18-decimal token gets 100 / 1e26 raw units. Because `max_sets` can now reach +1e26, `mul_div_floor` / `mul_div_ceil` use 256-bit intermediates (a no_std +`u128x2` widening multiply/divide in `math.rs`) so a result that fits in +`i128` is always returned exactly, regardless of the product's size. +The widening helpers live in a `no_std` `wide` module inside `math.rs` with +no `soroban_sdk` imports, so the arithmetic stays natively testable and the +Python/TypeScript mirrors can reproduce it bit-for-bit. + **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 + lp_fees` (`fees_accrued` is the @@ -24,6 +38,11 @@ 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`). +**Market size bound.** A market's seed and total sets are bounded by +`max_sets = 1e8 * 10^decimals` (100M tokens at the collateral's precision); +`buy` reverts with `MarketTooLarge` past it. `settle_value` sums YES+NO with +checked addition, so the bound also keeps settlement arithmetic in `i128`. + **Fee split and recipient snapshot.** `create_market` snapshots a `FeeTerms { recipient, protocol_share_bps }` side entry per market, so `propose_treasury` / `set_protocol_fee_share_bps` only affect markets created diff --git a/oracledesk-stellar/model/fpmm_model.py b/oracledesk-stellar/model/fpmm_model.py index fde53b7..8560154 100644 --- a/oracledesk-stellar/model/fpmm_model.py +++ b/oracledesk-stellar/model/fpmm_model.py @@ -15,7 +15,8 @@ trading agent; all three 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 (whatever `decimals()` the collateral token reports, 0..=18). +Rounding always favours the pool. """ from dataclasses import dataclass @@ -25,19 +26,46 @@ from pathlib import Path BPS = 10_000 +# Deprecated 7-decimal alias, kept for tests only. Contract logic derives its +# bounds from `bounds_for_decimals`; see math.rs. MAX_SETS = 1_000_000_000_000_000 # 1e15 = 100M USDC (7 dp) MIN_PROB_BPS = 100 MAX_PROB_BPS = 9_900 +I128_MAX = (1 << 127) - 1 +MAX_SUPPORTED_DECIMALS = 18 + class MathError(Exception): pass +@dataclass(frozen=True) +class Bounds: + min_seed: int + max_sets: int + + +def bounds_for_decimals(d: int) -> Bounds: + """Mirrors math.rs::bounds_for_decimals: min_seed = 100 * 10^d, + max_sets = 1e8 * 10^d. Rejects d > 18 (UnsupportedDecimals on-chain).""" + if d < 0 or d > MAX_SUPPORTED_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 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 + q = (a * b) // c + if not _fits_i128(q): + raise MathError("overflow") + return q def mul_div_ceil(a: int, b: int, c: int) -> int: @@ -45,7 +73,10 @@ 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 + q = q + 1 if p % c != 0 else q + if not _fits_i128(q): + raise MathError("overflow") + return q def price_yes_bps(reserve_yes: int, reserve_no: int) -> int: @@ -184,7 +215,10 @@ def settle_value(kind: str, yes: int, no: int) -> int: if kind == "no": return no if kind == "void": - return (yes + no) // 2 + total = yes + no + if not _fits_i128(total): + raise MathError("overflow") + return total // 2 raise ValueError(f"unknown settlement kind {kind!r}") @@ -349,12 +383,27 @@ def _check_known_values(): assert split_fee(101, 5_000) == (51, 50) +def _check_known_values_18dp(): + """18-decimal fixtures matching math.rs's known_values_18dp test. The + 1000/1000 -> 190 example scaled by 10**11, plus a seed at max_sets.""" + scale = 10**11 # 7 dp -> 18 dp + q = calc_buy(1000 * scale, 1000 * scale, 100 * scale, 0) + assert ( + q.shares_out, + q.new_reserve_bought, + q.new_reserve_other, + ) == (19090909090909, 90909090909091, 110000000000000), q + + bounds = bounds_for_decimals(18) + assert bounds.min_seed == 100 * 10**18 + assert bounds.max_sets == 100_000_000 * 10**18 + + def _check_seed_opens_at_requested_probability(): - for p in (100, 2500, 5000, 7300, 9900): - s = seed_reserves(10_000_000_000, p) - price = price_yes_bps(s.reserve_yes, s.reserve_no) - assert abs(price - p) <= 1, (p, price) - assert s.creator_yes == 0 or s.creator_no == 0 + for yes_bps in (MIN_PROB_BPS, 2_500, 5_000, 7_500, MAX_PROB_BPS): + s = seed_reserves(1_000_000, yes_bps) + assert s.reserve_yes > 0 and s.reserve_no > 0 + assert abs(price_yes_bps(s.reserve_yes, s.reserve_no) - yes_bps) <= 1, (yes_bps, s) def _check_k_never_decreases_and_round_trip_never_profits(trials: int = 2_000, seed: int = 7): @@ -653,6 +702,7 @@ def _check_collateral_fixtures(): def run_all(): _check_known_values() + _check_known_values_18dp() _check_shared_vectors() _check_seed_opens_at_requested_probability() _check_k_never_decreases_and_round_trip_never_profits() diff --git a/oracledesk-stellar/scripts/deploy-testnet.sh b/oracledesk-stellar/scripts/deploy-testnet.sh old mode 100755 new mode 100644 index 7f572d3..0058155 --- a/oracledesk-stellar/scripts/deploy-testnet.sh +++ b/oracledesk-stellar/scripts/deploy-testnet.sh @@ -34,6 +34,17 @@ TRADER_KEY=${TRADER_KEY:-${IDENTITY_PREFIX}-trader} OUT=${OUT:-deployments/$NETWORK.json} WASM_DIR=target/wasm32v1-none/release +# Collateral decimals. market-core reads decimals() from the token at +# construction and derives its MIN_SEED / MAX_SETS bounds from it, so the +# deployer must know the token's precision up front. The self-issued test +# asset below is a Classic asset (7 decimals); set COLLATERAL_DECIMALS to +# match if you point `collateral` at a different SEP-41 token. +COLLATERAL_DECIMALS=${COLLATERAL_DECIMALS:-7} +if ! [[ "$COLLATERAL_DECIMALS" =~ ^[0-9]+$ ]] || [ "$COLLATERAL_DECIMALS" -gt 18 ]; then + echo "error: COLLATERAL_DECIMALS=$COLLATERAL_DECIMALS must be an integer in 0..=18 (market-core's supported range)" >&2 + exit 1 +fi + # 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. @@ -250,6 +261,9 @@ fi # design doc) with no dependency on guessing a real issuer's contract id. # +# market-core's __constructor calls decimals() on this token, so it must be +# deployed before market-core below (it is). Keep COLLATERAL_DECIMALS in +# sync with the asset deployed here. USDC=$(json_get .contracts.usdc) if [ -z "$USDC" ]; then log "deploying self-issued testnet USDC (SEP-41)" @@ -339,7 +353,9 @@ fi # --- market-core (constructor receives the real resolver/treasury) ---------- if [ "$(json_get .contracts.market_core)" = "" ]; then - log "deploying market-core with real resolver/treasury in the constructor" + # __constructor reads collateral.decimals() and rejects anything above 18 + # with Error::UnsupportedDecimals (#19). The token must already be live. + log "deploying market-core with real resolver/treasury in the constructor (collateral decimals: $COLLATERAL_DECIMALS)" DEPLOYED=$(stellar contract deploy \ --wasm "$WASM_DIR/market_core.wasm" \ --salt "$MARKET_CORE_SALT" \ @@ -356,6 +372,7 @@ if [ "$(json_get .contracts.market_core)" = "" ]; then echo "error: market-core landed at $DEPLOYED, expected $MARKET_CORE" >&2 exit 1 } + json_set --argjson d "$COLLATERAL_DECIMALS" '.collateral_decimals = $d' json_set --arg id "$MARKET_CORE" '.contracts.market_core = $id' else log "market-core already deployed: $MARKET_CORE" @@ -463,8 +480,10 @@ propose_agent trader "$TRADER" $((ROLE_TRADE | ROLE_SETTLE)) "$TRADER_MAX_TRADE" # The treasury is a share vault: collateral sent to it directly would be a # donation to existing shareholders, so the deployer mints to itself and # deposits, receiving shares (docs/adr/0005-treasury-vault.md). +# FUND_AMOUNT is in raw token units, so scale it by the collateral's +# decimals: 1,000,000 tokens at 7 decimals is 1e13, at 18 decimals 1e24. TREASURY_FUNDED=$(json_get .treasury_funded_amount) -FUND_AMOUNT=${TREASURY_FUND_AMOUNT:-10000000000000} # 1,000,000 USDC +FUND_AMOUNT=${TREASURY_FUND_AMOUNT:-$((1000000 * 10 ** COLLATERAL_DECIMALS))} # 1,000,000 tokens if [ "$TREASURY_FUNDED" != "$FUND_AMOUNT" ]; then log "minting $FUND_AMOUNT stroops of test USDC and depositing it into the treasury" stellar contract invoke --id "$USDC" --source-account "$DEPLOYER_KEY" \ @@ -506,4 +525,4 @@ fi json_set --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.last_deployed_at = $ts' log "done. Deployment record: $OUT" -cat "$OUT" +cat "$OUT" \ No newline at end of file