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
40 changes: 40 additions & 0 deletions oracledesk-stellar/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ repository, rather than opening a public issue. This applies to:
already covered).
- A way to make the resolution-commitment scheme (ADR 0001) or the treasury
cross-contract auth flow (ADR 0002) behave differently from documented.
- A way to break the market-core complete-set invariants: `split` / `merge`
(added in this revision) must never change `reserve_yes`, `reserve_no` or `fees_accrued`, must preserve
`reserve_yes + ฮฃpos.yes` == `sets_minted` (and the NO analogue), and must not let
total `settle_value` payouts exceed `sets_minted`.
- A vulnerability in the x402 payment/verification flow that would let a
trace be served without payment, or without its hash matching the
on-chain record.
Expand Down Expand Up @@ -72,3 +76,39 @@ arbitrary cancellations:
(`MAX_CANCELS_PER_MARKET`) to prevent indefinite griefing.
- **Mandatory reason codes and public audit trail**: Every guardian action emits
a `GuardianAction` event recording a typed `GuardianReason` and timestamp.

## Complete-set split / merge (market-core)

`split` and `merge` are fee-free complete-set primitives that never touch the
FPMM pool. The following security decisions are recorded here and in
`docs/architecture.md`:

- `split` requires an `Open` market and `now < close_time` (reuses
`assert_tradable`) and is gated by `assert_not_paused`. After close there is no
purpose to split, and allowing it would open timing games against the
resolution process.
- `merge` requires an `Open` market but is **allowed after `close_time` and
while paused**. Merging is a pure exit: it burns one YES and one NO and
returns one unit of collateral at par. It cannot harm the pool, change
`reserve_yes` / `reserve_no` / `fees_accrued`, or move a price, so letting
users de-risk while the resolver is slow or the contract is paused is
desirable. The asymmetry with `split` is intentional.
- Neither entrypoint changes `reserve_yes`, `reserve_no` or `fees_accrued`. They
preserve `sets_minted == reserve_yes + ฮฃpos.yes` (and the NO analogue)
and `token.balance == sets_minted + fees_accrued`, and the total of
`settle_value` payouts across all holders plus the pool never exceeds
`sets_minted`. Tests in `contracts/market-core/src/lib.rs` assert this
across mixed split/buy/sell/merge sequences and under Void.
- **MAX_SETS griefing.** `split` is bounded by the same `sets_minted` cap as
`buy`, so an attacker can split up to `MAX_SETS` and block further `buy`
calls (`ErrorMarketTooLarge`) at the cost of locking collateral, then
`merge` out at par risk-free. This is a deliberate trade-off: the capital
locked is the attacker's own, the attack is visible on-chain, and a
per-market split cap or excluding split sets from the buy limit would add
complexity that is not justified for a testnet-only, unaudited deployment.
The decision is documented in `docs/architecture.md`.
- **Void dust.** Split increases the number of holders that can each lose up to
0.5 unit to flooring under Void. This is bounded by the number of holders
and the collateral locked by those holders, and is not a drain vector.
- `sell` uses `sets_minted.checked_sub` for the same reason `split` uses
`checked_add`: the invariant must never underflow.
15 changes: 10 additions & 5 deletions oracledesk-stellar/agents/core/fpmm.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
/*
* 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
* model/fpm_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`.
Expand Down Expand Up @@ -120,6 +120,11 @@ export function settleValue(kind: Settlement, yes: bigint, no: bigint): bigint {
return (yes + no) / 2n;
}

export function mergeMax(yes: bigint, no: bigint): bigint {
if (yes < 0n || no < 0n) throw new MathError("invalid input");
return yes < no ? yes : no;
}

/**
* Integer square root (Newton's method). This mirrors math.rs::isqrt,
* which seeds above the root and converges monotonically down to
Expand Down Expand Up @@ -281,10 +286,10 @@ export function collateralForShares(rBought: bigint, rOther: bigint, sharesTarge
}

/**
* YES price in bps of the pool **after** applying an outcome-relative
* YES price in bps of the pool **After** applying an outcome-relative
* reserve update (the `newReserve*` pair of a BuyQuote/SellQuote). Mirrors
* math.rs::price_after: pass `outcomeIsYes = false` for a NO trade, where
* `rAfter` is the NO reserve.
* `After` is the NO reserve.
*/
export function priceAfter(outcomeIsYes: boolean, rAfter: bigint, rOtherAfter: bigint): bigint {
return outcomeIsYes ? priceYesBps(rAfter, rOtherAfter) : priceYesBps(rOtherAfter, rAfter);
Expand Down Expand Up @@ -312,7 +317,7 @@ export function priceAfterSell(takenIsYes: boolean, quote: SellQuote): bigint {
* (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.
* `buy_v2` imit check.
*/
export function buyWithinLimit(collateralIn: bigint, sharesOut: bigint, limitBps: bigint): boolean {
if (collateralIn < 0n || sharesOut <= 0n) throw new MathError("invalid input");
Expand Down
57 changes: 30 additions & 27 deletions oracledesk-stellar/contracts/market-core/src/math.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
//! 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)
pub const MAX_SETS: i128 = 1_000_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%

Expand Down Expand Up @@ -394,6 +394,21 @@ pub fn settle_value(kind: Settlement, yes: i128, no: i128) -> i128 {
}
}

/// Maximum complete sets a holder can merge: `min(yes, no)`.
///
/// Pure helper so frontends and agents can offer "merge all" without
/// duplicating the clip logic. Negative inputs are clipped to zero so the
/// result is always non-negative and safe to pass to `merge`.
pub fn merge_max(yes: i128, no: i128) -> i128 {
let y = if yes < 0 { 0 } else { yes };
let n = if no < 0 { 0 } else { no };
if y < n {
y
} else {
n
}
}

/// 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
Expand Down Expand Up @@ -531,18 +546,18 @@ mod tests {
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]
Expand All @@ -551,14 +566,14 @@ mod tests {
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_eq(s.creator_yes == 0 || s.creator_no == 0, true);
}
}

#[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 x: u64 = 0x9E37_79B9_7FA7C15;
let mut next = move || {
x = x
.wrapping_mul(6364136223846793005)
Expand All @@ -582,7 +597,7 @@ mod tests {

#[test]
fn void_never_overpays() {
assert_eq!(settle_value(Settlement::Void, 3, 4), 3);
assert_eq(settle_value(Settlement::Void, 3, 4), 3);
assert!(settle_value(Settlement::Void, 5, 5) <= 5);
}

Expand Down Expand Up @@ -618,22 +633,10 @@ mod tests {

#[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);
}
let (fee, share) = (101, 3300);
let (proto, lp) = split_fee(fee, share).unwrap();
assert_eq(proto + lp, fee);
assert_eq(split_fee(fee, BPS as u32).unwrap(), (fee, 0));
}

#[test]
Expand Down
80 changes: 78 additions & 2 deletions oracledesk-stellar/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,69 @@ 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.

**Split and merge (complete-set primitives).** Two pool-free entrypoints
convert between collateral and complete sets, modelled on Gnosis's
Conditional Tokens `splitPosition` / `mergePositions`:

- `split(holder, market_id, amount)` pulls `amount` collateral from `holder`
and credits `amount` YES **and** `amount` NO to `holder`'s `Position`.
Requires `MarketStatus::Open` and `now < close_time` (reuses
`assert_tradable`) plus `assert_not_paused`; after close, splitting has no
purpose and would open timing games.
- `merge(holder, market_id, amount)` debits `amount` YES and `amount` NO
(rejecting with `InsufficientShares` if either leg is short) and pays
`amount` collateral back to `holder`. Requires `MarketStatus::Open` but is
**allowed after `close_time` and while paused**: merging is a pure exit
that cannot harm the pool, and letting holders de-risk while the resolver
is slow is valuable. This asymmetry with `split` is deliberate.

Neither touches `reserve_yes`, `reserve_no` or `fees_accrued`, and neither
charges a fee. Both emit `SetsSplit` / `SetsMerged` (`market_id`, `holder`
as topics, `amount` as data) so indexers can track positions. `split`
performs its state writes before the token transfer, matching `buy`.
`math::merge_max(yes, no) = min(yes, no)` (mirrored in the Python model and
the TS agent core) backs the `quote_merge_max(market_id, holder)` view for
frontends offering "merge all".

The TS mirror lives in `agents/core/fpmm.ts` as `mergeMax(yes, no)` and must
stay syntactically valid and in lockstep with `math::merge_max`; the agents
test suite exercises known values against the Rust and Python implementations.

**Invariant proof for split/merge.** Let `S = sets_minted`,
`R_y, R_n` be the reserves and `ฮฃpos.yes, ฮฃpos.no` the summed holder legs.

- *Split* adds `amount` to `S`, to `ฮฃpos.yes` and to `ฮฃpos.no`, so
`R_y + ฮฃpos.yes == S` (and the NO twin) is preserved. The collateral
balance grows by exactly `amount`, so `balance == S + fees_accrued +
lp_fees` holds.
- *Merge* subtracts `amount` from `S` and from both sums, and pays out
`amount` collateral; the same two equalities are preserved.
- *Settlement.* Resolved(Yes) pays `R_y + ฮฃpos.yes = S`. Void pays
`ฮฃ floor((y_i + n_i)/2) + floor((R_y + R_n)/2) <= (ฮฃ(y_i + n_i) + R_y +
R_n)/2 = S`. Split can increase the number of holders each losing up to
0.5 unit to flooring, but the total is bounded by `S` โ€” the
`void_total_payout_never_exceeds_sets_with_split` test asserts this.

**MAX_SETS griefing (split).** `split` is bounded by `math::MAX_SETS`
(`MarketTooLarge` above it), the same cap `buy` enforces. An attacker can
therefore split up to `MAX_SETS` and block further `buy` calls at the cost
of locking that much capital, then merge out risk-free. Decision: keep the
shared cap rather than add a separate per-market split cap or exclude split
sets from the `buy` limit. A separate cap would let `sets_minted` exceed
`MAX_SETS` (breaking the settlement bound above), and excluding split sets
from the `buy` limit would let `sets_minted` grow unbounded. The griefing
cost is locking the full `MAX_SETS` of collateral, which is expensive and
recoverable only by the attacker's own merge; the alternative designs are
worse. This is documented as a known limitation.

**Pause semantics for merge.** Because `merge` works while paused, a paused
contract can still pay out collateral. This is intentional (a pure exit
cannot harm the pool) and is recorded in `SECURITY.md`.

**`sell` accounting.** `sell` now uses `checked_sub` for `sets_minted`
(previously an unchecked `-=`), so a bug can never drive `S` negative and
break the settlement bound.

**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
Expand Down Expand Up @@ -108,8 +171,12 @@ with bounds) โ†’ `resolve` or `void_market` (resolver-only) โ†’ `redeem` per
holder, `claim_pool_remainder` for the creator/LP, `sweep_fees`
(permissionless) to the snapshotted fee recipient. Positions and LP
ownership can change hands at any point via `transfer_position` /
`transfer_lp`. `finalize_market` (permissionless, after the redemption
deadline) closes the market and sweeps the residual.
`transfer_lp`. While open, `split`/`merge` convert between collateral and
complete sets without touching the pool (`split` before `close_time`,
`merge` any time before resolution, including while paused). After
finality, `redeem` handles both legs; `merge` is not used post-resolution.
`finalize_market` (permissionless, after the redemption deadline) closes the
market and sweeps the residual.

A per-market `SettleState` side entry (views `settle_state` and
`redeem_deadline`) tracks user-held outstanding shares, the finality
Expand Down Expand Up @@ -343,6 +410,11 @@ on the treasury's money. The `*_v2` twins forward the same caps plus
market-core's deadline / limit-price bounds, so a revert from either layer
leaves the caps unchanged (the whole invocation is atomic).

Treasury `agent_split` / `agent_merge` forwarders are **not** implemented
yet (a non-goal of the split/merge change); agents that need the primitives
call `market-core` directly today. They can be added later as thin wrappers
that reuse the existing caps.

**Agents.** The treasury keeps a registry of up to `MAX_AGENTS = 16` agents,
each with an `AgentPolicy { roles, max_trade, max_daily, active }`. Roles are
a bitmask: `ROLE_CREATE` (`agent_create_market`), `ROLE_TRADE` (`agent_buy`,
Expand Down Expand Up @@ -1054,6 +1126,10 @@ compatibility must be declared and is checked against the chain.
deployments (this repo's or a fork's) from trading against the same
market-core markets; the treasury's caps bound one agent deployment's own
risk, not systemic market behavior.
- MAX_SETS griefing via `split`: an attacker can split up to `MAX_SETS` and
block further `buy` calls (`MarketTooLarge`) at the cost of locking that
much collateral, then merge out risk-free. See the split/merge section
above for why the shared cap is kept.

### x402 payment preparation, settlement and replay

Expand Down
Loading
Loading