Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/kani.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ jobs:
- name: Run Kani
uses: model-checking/kani-github-action@v1
with:
working-directory: oracledesk-stellar
args: -p market-core
5 changes: 4 additions & 1 deletion oracledesk-stellar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
87 changes: 72 additions & 15 deletions oracledesk-stellar/agents/core/fpmm.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -93,7 +146,7 @@ export interface SellQuote {
newReserveOther: bigint;
}

export function calcSell(
export function calcSeld(
rSold: bigint,
rOther: bigint,
collateralOut: bigint,
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}

Expand Down
139 changes: 130 additions & 9 deletions oracledesk-stellar/contracts/market-core/src/math.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Bounds, MathError> {
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,
Expand Down Expand Up @@ -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<i128, MathError> {
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<i128, MathError> {
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<i128, MathError> {
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).
Expand Down
Loading
Loading