diff --git a/oracledesk-stellar/.env.example b/oracledesk-stellar/.env.example index 786a131..7125f8a 100644 --- a/oracledesk-stellar/.env.example +++ b/oracledesk-stellar/.env.example @@ -63,6 +63,14 @@ TESTNET_FACILITATOR_API_KEY= # --- Agents (agents/) ------------------------------------------------------- # "dry-run" (default, never signs or submits) or "live" (real testnet txs). AGENT_MODE=dry-run +# Market the trader agent reads and trades (see agents/stellar/adapter.ts). +MARKET_ID=0 +# Which price the trader compares to its fair-value estimate: +# spot = market-core's instantaneous `get_price` (moves with any trade) +# twap = the manipulation-resistant time-weighted average over +# TWAP_WINDOW_SECS, read via market-core's `twap` view +PRICE_SOURCE=spot +TWAP_WINDOW_SECS=900 # Signing keys (S...). Each CLI prefers its own role key and falls back to # the shared AGENT_SECRET_KEY (the constructor agent, which has every role): # bin/market-maker.ts reads MAKER_SECRET_KEY, bin/trader.ts TRADER_SECRET_KEY. diff --git a/oracledesk-stellar/agents/core/strategy.test.ts b/oracledesk-stellar/agents/core/strategy.test.ts index 59db18f..6016f0e 100644 --- a/oracledesk-stellar/agents/core/strategy.test.ts +++ b/oracledesk-stellar/agents/core/strategy.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { calcBuy, priceAfterBuy } from "./fpmm.js"; -import { decideMarketProposal, decideTrade } from "./strategy.js"; +import { decideMarketProposal, decideTrade, twapFromObservations } from "./strategy.js"; test("decideMarketProposal skips a signal with no actionable estimate", () => { const d = decideMarketProposal( @@ -70,6 +70,72 @@ test("decideTrade buys No when the market overprices Yes", () => { assert.equal(d.outcome, "No"); }); +test("decideTrade uses twapBps when priceSource is twap", () => { + // Spot sits right on the fair value (no edge), but the TWAP reveals the + // real mispricing the spot price was dragged away from. + const d = decideTrade( + { marketId: 7, question: "q", yesBps: 6000, twapBps: 4000, closeTime: 0 }, + 6000, + { minEdgeBps: 100, tradeSizeStroops: 10_000_000n, priceSource: "twap" }, + ); + assert.equal(d.action, "buy"); + assert.equal(d.outcome, "Yes"); + assert.match(d.reasoning, /4000bps \(twap\)/); + + // The same market read as spot skips. + const spot = decideTrade( + { marketId: 7, question: "q", yesBps: 6000, twapBps: 4000, closeTime: 0 }, + 6000, + { minEdgeBps: 100, tradeSizeStroops: 10_000_000n, priceSource: "spot" }, + ); + assert.equal(spot.action, "skip"); +}); + +test("decideTrade with priceSource twap requires a twap price", () => { + assert.throws( + () => + decideTrade( + { marketId: 7, question: "q", yesBps: 6000, closeTime: 0 }, + 6000, + { minEdgeBps: 100, tradeSizeStroops: 10_000_000n, priceSource: "twap" }, + ), + /twapBps/, + ); +}); + +test("twapFromObservations matches contract example", () => { + // Mirrors market-core's `twap_weights_by_time` arithmetic with round + // numbers: seeded at 6800bps, trade at t=1000 leaving 5000bps, trade at + // t=1100 leaving 6000bps, then the live edge counterfactually extended to + // t=1400. The 400s window from t=1000 to t=1400 averages + // (5000*100 + 6000*300) / 400 = 5750. + const observations = [ + { ts: 0, cumulative: 0n }, + { ts: 1000, cumulative: 6_800_000n }, + { ts: 1100, cumulative: 6_800_000n + 100n * 5_000n }, + { ts: 1400, cumulative: 6_800_000n + 100n * 5_000n + 300n * 6_000n }, + ]; + assert.equal(twapFromObservations(observations, 400), 5750); + // The last 300s were held at 6000bps exactly. + assert.equal(twapFromObservations(observations, 300), 6000); + // Windows may start strictly between observations (interpolated chord): + // t = 950 sits in the seeded-price stretch, so cum(950) = 6800 * 950. + assert.equal( + twapFromObservations(observations, 450), + Math.floor((9_100_000 - (6_800_000 * 950) / 1000) / 450), + ); +}); + +test("twapFromObservations throws when the window predates the ring", () => { + const observations = [ + { ts: 1000, cumulative: 0n }, + { ts: 2000, cumulative: 5_000_000n }, + ]; + assert.throws(() => twapFromObservations(observations, 1001), /ObservationTooOld/); + assert.throws(() => twapFromObservations([], 100), /no observations/); + assert.throws(() => twapFromObservations(observations, 0), /positive integer/); +}); + // The 100-sets pool at 50% with a 1% fee used by the calcBuy reference // example: 10 USDC moves the price 470bps, so a 200bps cap must clamp well // below the requested size. diff --git a/oracledesk-stellar/agents/core/strategy.ts b/oracledesk-stellar/agents/core/strategy.ts index 3800114..880b67e 100644 --- a/oracledesk-stellar/agents/core/strategy.ts +++ b/oracledesk-stellar/agents/core/strategy.ts @@ -92,7 +92,11 @@ export function decideMarketProposal( export interface MarketState { marketId: number; question: string; - yesBps: number; // current market-implied probability + yesBps: number; // current market-implied probability (spot) + /** Time-weighted average YES price in bps, when the caller read it + * (market-core's `twap`). Required by `decideTrade` when `priceSource` is + * `"twap"`; spot reads may leave it undefined. */ + twapBps?: number; closeTime: number; /** Pool reserves in stroops, if known. Required when the caller sets * `maxImpactBps` on `decideTrade`. */ @@ -160,6 +164,12 @@ function clampSizeToImpact( * decides whether the mispricing is worth trading. Trades toward whichever * side the market is *underpricing* relative to the estimate. * + * `priceSource` picks which of the market's prices is compared: `"spot"` + * (default) uses `MarketState.yesBps`, which a single trade can move + * arbitrarily within one ledger; `"twap"` uses `MarketState.twapBps`, the + * manipulation-resistant time-weighted average. Prefer `"twap"` for + * automated trading loops. + * * With `maxImpactBps` set to a positive value, the trade size is clamped * so the estimated price impact stays below the market's on-chain breaker * (the market state must then carry reserves and `feeBps`); if even the @@ -170,13 +180,31 @@ function clampSizeToImpact( export function decideTrade( market: MarketState, fairValueBps: number, - opts: { minEdgeBps: number; tradeSizeStroops: bigint; maxImpactBps?: number }, + opts: { + minEdgeBps: number; + tradeSizeStroops: bigint; + priceSource?: "spot" | "twap"; + maxImpactBps?: number; + }, ): TraderDecision { - const edge = fairValueBps - market.yesBps; // positive => YES underpriced + const priceSource = opts.priceSource ?? "spot"; + let marketBps: number; + if (priceSource === "twap") { + if (market.twapBps === undefined) { + throw new Error( + `priceSource "twap" requires market.twapBps on market ${market.marketId} — read it with readMarket({ useTwapSecs })`, + ); + } + marketBps = market.twapBps; + } else { + marketBps = market.yesBps; + } + + const edge = fairValueBps - marketBps; // positive => YES underpriced if (Math.abs(edge) < opts.minEdgeBps) { return { action: "skip", - reasoning: `edge ${edge}bps on market ${market.marketId} is below the ${opts.minEdgeBps}bps threshold`, + reasoning: `edge ${edge}bps (${priceSource}) on market ${market.marketId} is below the ${opts.minEdgeBps}bps threshold`, }; } const outcome = edge > 0 ? "Yes" : "No"; @@ -196,8 +224,76 @@ export function decideTrade( } return { action: "buy", - reasoning: `market ${market.marketId} prices YES at ${market.yesBps}bps vs fair value ${fairValueBps}bps (edge ${edge}bps) — buying ${outcome}${clampNote}`, + reasoning: `market ${market.marketId} prices YES at ${marketBps}bps (${priceSource}) vs fair value ${fairValueBps}bps (edge ${edge}bps) — buying ${outcome}${clampNote}`, outcome, collateralInStroops, }; } + +/** One point of market-core's TWAP ring, as returned by the `Obs` entries + * (or the `observe` view): the cumulative YES-price-seconds at `ts`. */ +export interface PriceObservation { + /** unix seconds */ + ts: number; + /** `price_cumulative` (bps × seconds) as of `ts` */ + cumulative: bigint; +} + +/** + * BigInt mirror of market-core's `observe`/`twap` math, for consumers that + * cannot simulate the contract (e.g. rendering historical charts from + * indexed observations). Linearly interpolates the cumulative between + * observations — exact there, because the accumulator is linear on each + * interval (its slope is the price that held) — evaluates the window ending + * at the newest observation's timestamp, and floors, exactly like the + * contract. + * + * The contract counterfactually extends the accumulator from the last trade + * to the current time (the last price holds). To reproduce that, append the + * live edge — `(last_ts, price_cumulative)` is what `observe(marketId, 0)` + * returns — as the final observation. + * + * Throws a `RangeError` when the window reaches before the first + * observation (the contract's `ObservationTooOld`), or when the inputs are + * degenerate. `observations` must be ordered by non-decreasing `ts`; exact + * duplicates (same ts and cumulative) are tolerated. + */ +export function twapFromObservations( + observations: readonly PriceObservation[], + windowSecs: number, +): number { + if (observations.length === 0) { + throw new RangeError("twapFromObservations: no observations"); + } + if (!Number.isInteger(windowSecs) || windowSecs <= 0) { + throw new RangeError(`twapFromObservations: window must be a positive integer, got ${windowSecs}`); + } + const now = observations[observations.length - 1].ts; + const t = now - windowSecs; + if (t < observations[0].ts) { + throw new RangeError("twapFromObservations: window reaches before the oldest observation (ObservationTooOld)"); + } + const delta = cumulativeAt(observations, now) - cumulativeAt(observations, t); + return Number(delta / BigInt(windowSecs)); +} + +/** Newest observation with `ts <= t`, interpolated along the next chord if + * `t` falls strictly between two observations. */ +function cumulativeAt(observations: readonly PriceObservation[], t: number): bigint { + let lo = 0; + let hi = observations.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (observations[mid].ts <= t) { + lo = mid; + } else { + hi = mid - 1; + } + } + const a = observations[lo]; + if (a.ts === t) { + return a.cumulative; + } + const b = observations[lo + 1]; + return a.cumulative + ((b.cumulative - a.cumulative) * BigInt(t - a.ts)) / BigInt(b.ts - a.ts); +} diff --git a/oracledesk-stellar/agents/stellar/adapter.ts b/oracledesk-stellar/agents/stellar/adapter.ts index f348953..60d002c 100644 --- a/oracledesk-stellar/agents/stellar/adapter.ts +++ b/oracledesk-stellar/agents/stellar/adapter.ts @@ -91,6 +91,22 @@ export type LiveResult = { dryRun: false; value: T }; export type AdapterResult = DryRunResult | LiveResult; +/** Snapshot of a market's read-only state, from `readMarket`. */ +export interface MarketRead { + marketId: number; + /** IPFS CID / URI of the question metadata (the question text itself is + * stored off-chain; see docs/frontend-integration.md). */ + metaUri: string; + closeTime: number; + reserveYes: bigint; + reserveNo: bigint; + /** YES spot price in bps (0..=10_000), from `get_price`. */ + yesBps: number; + /** YES time-weighted average over the requested `useTwapSecs` window, + * from `twap`. Present only when `useTwapSecs` was passed. */ + twapBps?: number; +} + /** * The call failed before a transaction was submitted (building or * simulating it — e.g. a treasury cap rejection), so it certainly has no @@ -428,6 +444,51 @@ export class StellarAdapter { (client) => client.publish_trace_signed(call), ); } + + /** + * Reads a market's price state. Unlike the write wrappers above, a read + * always queries the network — through the bindings' simulation, which + * never signs or submits — so it works identically in dry-run and live + * mode and there is no offline stand-in to return. + * + * `useTwapSecs` additionally reads `twap(market_id, useTwapSecs)`: the + * time-weighted average over that trailing window, which a single + * transaction cannot move (see market-core's accumulator, issue #11). + * Prefer it over spot for any automated trading loop. + */ + async readMarket( + marketId: bigint, + opts: { useTwapSecs?: number } = {}, + ): Promise { + const client = await this.client("market_core", "@oracledesk/bindings-market-core"); + const marketTx = await client.get_market({ market_id: marketId }); + const market = marketTx.result.unwrap() as { + meta_uri: string; + close_time: bigint; + reserve_yes: bigint; + reserve_no: bigint; + }; + const priceTx = await client.get_price({ + market_id: marketId, + outcome: { tag: "Yes", values: undefined }, + }); + const read: MarketRead = { + marketId: Number(marketId), + metaUri: market.meta_uri, + closeTime: Number(market.close_time), // unix seconds, safe as number + reserveYes: market.reserve_yes, + reserveNo: market.reserve_no, + yesBps: priceTx.result.unwrap() as number, + }; + if (opts.useTwapSecs !== undefined) { + const twapTx = await client.twap({ + market_id: marketId, + window_secs: BigInt(opts.useTwapSecs), + }); + read.twapBps = twapTx.result.unwrap() as number; + } + return read; + } } /** diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 2b6c6b7..af6a1c0 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -3,27 +3,82 @@ mod math; #[cfg(kani)] mod math_proofs; +pub mod ttl; use math::{MathError, Settlement, BPS, MAX_SETS}; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, panic_with_error, token, - Address, BytesN, Env, String, + Address, BytesN, Env, String, Vec, }; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -/// Ledger close time is ~5s, so 17_280 ledgers ~ 1 day. Tune if that changes. -const DAY_IN_LEDGERS: u32 = 17_280; -const BUMP_THRESHOLD: u32 = 30 * DAY_IN_LEDGERS; -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; +/// Redemption-window bounds, in seconds. A holder cannot be forced to redeem +/// within weeks of resolution (180-day floor), and a market cannot sit +/// waitable-forever (5-year ceiling). The 365-day default is snapshotted +/// into each market at creation, so an admin change never moves an +/// existing deadline. +const DEFAULT_REDEEM_HORIZON_SECS: u64 = 365 * ttl::DAY_SECS; +const MIN_REDEEM_HORIZON_SECS: u64 = 180 * ttl::DAY_SECS; +const MAX_REDEEM_HORIZON_SECS: u64 = 1_825 * ttl::DAY_SECS; + +/// Observations kept per market for the TWAP ring buffer (issue #11). One +/// observation is written per distinct trade timestamp; a consumer asking +/// for a time older than the oldest surviving observation gets +/// `ObservationTooOld` instead of a wrong answer. +const ORACLE_RING: u32 = 16; + +/// Markets one `redeem_many` / `claim_pool_remainders` call may settle. +/// +/// Measured with `measure_redeem_many_resources` (see the tests; run it +/// explicitly with `--ignored --nocapture` to regenerate) and checked +/// against the network per-transaction limits below, keeping every figure +/// under 75% of its limit for headroom. Each market adds 3 read entries +/// (Market, Position, Settle) and 2 write entries (Position removed, Settle +/// updated); the aggregated transfer and the instance entries are shared +/// across the batch. +/// +/// LIMITS (mainnet snapshot 2026-07-10, enforced by the test env — see +/// `InvocationResourceLimits::mainnet()` in the SDK): +/// instructions 400,000,000 | memory 41,943,040 B | disk read entries 200 +/// | write entries 200 | ledger entries 400 (footprint total) | read bytes +/// 200,000 | write bytes 132,096 | contract events 16,384 B +/// MEASURED, batch of N (direct market-core call, one holder, every +/// position redeemable; live entries, so `footprint` = reads + writes): +/// N | insns | mem B | reads | writes | footprint | write B | events B +/// 1 | 409,515 | 55,861 | 10 | 5 | 15 | 840 | 404 +/// 5 |1,258,619 | 163,347 | 22 | 13 | 35 | 2,120 | 1,076 +/// 10 |2,586,602 | 355,282 | 37 | 23 | 60 | 3,720 | 1,916 +/// 20 |6,133,201 | 931,077 | 67 | 43 | 110 | 6,920 | 3,596 +/// 30 |10,687,968|1,762,772 | 97 | 63 | 160 | 10,120 | 5,276 +/// 40 |16,308,578|2,850,367 | 127 | 83 | 210 | 13,320 | 6,956 +/// 45 |19,448,071|3,490,127 | 142 | 93 | 235 | 14,920 | 7,796 +/// 46 |20,093,044|3,625,756 | 145 | 95 | 240 | 15,240 | 7,964 +/// Rows are the direct market-core call; the treasury wrappers add a +/// constant two read entries (their own config and instance entries) and no +/// extra writes — measured at this cap by +/// `measure_treasury_redeem_many_resources` in `contracts/treasury`: +/// N=46 treasury path = 147 reads, 95 writes, 242 footprint, 33.1M insns, +/// 15,240 write B, 7,964 event B. +/// At N=46 the live-state binding axis (footprint, 5N+10) sits at 240/400 = +/// 60% of the ledger-entry limit; the other entry axes are write entries +/// 95/200 = 47.5% and events 7,964/16,384 = 49%, while instructions (5-8%), +/// memory (9-13%) and write bytes (12%) stay far below. +/// Worst case, if the touched entries had to be restored from disk (archived +/// state), the 3N+7 reads would hit the 200-entry disk-read limit at +/// 147/200 = 73.5% on the treasury path (145/200 = 72.5% direct) — under +/// the 75% headroom line with room left for the transaction overhead this +/// harness cannot see (auth entries, source account). N=47 would measure +/// exactly 150/200 = 75.0% with zero margin, so 46 is the largest +/// defensible cap. +const MAX_BATCH: u32 = 46; /// A proposed admin handover must be accepted (or cancelled) within this many /// seconds, so a proposal forgotten by the outgoing admin cannot be used to /// seize control months later. 7 days. @@ -76,6 +131,124 @@ pub struct Position { pub no: i128, } +/// Per-market settlement bookkeeping (issue #14). Lives in its own entry so +/// the `Market` XDR stays unchanged. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SettleState { + /// Outcome shares held outside the pool (user positions), still + /// redeemable. `redeem` decrements the redeeming holder's shares on both + /// sides, so a losing-side redemption (payout 0) still clears here. + pub outstanding_yes: i128, + pub outstanding_no: i128, + /// Timestamp of `resolve` / `void_market`; 0 while the market is Open. + pub finalized_at: u64, + /// Redemption window, snapshotted at creation from the admin default. + /// `redeem_deadline = finalized_at + horizon_secs`. + pub horizon_secs: u64, + /// Collateral already paid to holders and the LP (`redeem` + + /// `claim_pool_remainder` payouts); tracks what has left the market. + pub paid_out: i128, + /// `finalize_market` has swept the market's remaining balance; no + /// redemption or claim can ever pay out again. + pub closed: bool, +} + +/// Per-market TWAP accumulator (issue #11). Lives in its own entry so the +/// `Market` XDR stays unchanged. +/// +/// Semantics: at the start of every trade the price that held since the last +/// trade is folded into `price_cumulative` over the elapsed interval; the +/// post-trade price then becomes `last_price_bps` until the next trade. That +/// is the Uniswap-v2 model, and it is what makes manipulation expensive — +/// moving the average requires holding the price away from fair value across +/// one or more ledger boundaries, not just within a single transaction. +/// +/// Overflow analysis: `last_price_bps <= 10_000` and each interval adds +/// `price * dt` seconds. A 100-year market lifetime accumulates at most +/// `10_000 * 3.2e9 ~ 3.2e13`; even a pathological `dt = u64::MAX` gives +/// `1.8e19 * 1e4 = 1.8e23`. `i128::MAX` is ~1.7e38, so there is no feasible +/// lifetime that overflows — and every step is `checked_*` anyway, so a +/// violation rejects the transaction instead of corrupting the accumulator. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleState { + /// Running sum of `last_price_bps * seconds_elapsed` since creation. + pub price_cumulative: i128, + /// Timestamp the accumulator was last advanced to (the timestamp of the + /// most recent trade). The last traded price holds from here onward. + pub last_ts: u64, + /// YES price in bps as of the most recent trade. + pub last_price_bps: u32, + /// Ring slot of the newest observation, in `0..ORACLE_RING`. + pub obs_index: u32, + /// Observations written so far, capped at `ORACLE_RING`. + pub obs_count: u32, +} + +/// One point in the TWAP ring buffer. At most one per distinct timestamp. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Observation { + pub ts: u64, + /// `OracleState::price_cumulative` as of `ts`. + pub cumulative: i128, +} + +/// Why a market in a batch call did not settle. `Duplicate` is reserved: +/// ids are validated strictly ascending first, so a duplicate is rejected +/// as `InvalidAmount` before the batch runs and never reaches a skip. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SkipReason { + NotFound, + NotFinal, + NothingToRedeem, + AlreadyClaimed, + NotCreator, + Duplicate, +} + +/// Per-market result of a batch settlement. +/// +/// This union stands in for the `Option` sketched in the issue: +/// a `#[contracttype]` struct field of type `Option` does not +/// compile under the `testutils` feature (on for every `cargo test` build). +/// The derive emits an xdr conversion gated on `CustomType: Into`, +/// but `#[contracttype]` enums only derive `TryFrom`, and the missing impls +/// cannot be supplied from this crate: `From for ScVal` collides with +/// the derive's own `TryFrom` through core's blanket impl (E0119), and +/// `TryFrom<&Option> for ScVal` violates the orphan rule (E0117). +/// The union carries the same information and surfaces as a discriminated +/// union in the generated TS bindings. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BatchStatus { + /// The market settled and paid `BatchItem::payout`. + Settled, + /// The market was skipped; `BatchItem::payout` is 0. + Skipped(SkipReason), +} + +/// One market's outcome inside a `BatchResult`, in request order. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchItem { + pub market_id: u64, + pub payout: i128, + pub status: BatchStatus, +} + +/// Result of `redeem_many` / `claim_pool_remainders`: `total` is the single +/// aggregated transfer, `items` has one entry per requested market in the +/// same order. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchResult { + pub total: i128, + pub items: Vec, +} + /// Fee destination and split, snapshotted per market at creation as a side /// entry (`DataKey::FeeTerms`) so `Market`'s XDR shape never changes. /// @@ -153,9 +326,13 @@ pub enum DataKey { DefaultBreaker, Paused, NextId, + DefaultRedeemHorizon, // persistent storage (one entry each, own TTL) Market(u64), Position(u64, Address), + Settle(u64), + Oracle(u64), + Obs(u64, u32), FeeTerms(u64), LpFees(u64), Breaker(u64), @@ -205,6 +382,19 @@ pub enum Error { NoPendingAdmin = 25, /// A proposed admin handover was not accepted within its `expires_at`. PendingAdminExpired = 26, + /// `observe` / `twap` asked for a time older than every surviving + /// observation of the market (the ring keeps the most recent 16). + ObservationTooOld = 27, + /// `finalize_market` called before the redemption deadline. + RedemptionWindowOpen = 28, + /// Market already closed by `finalize_market`; redemption is over. + RedemptionClosed = 29, + /// Legacy market created before `SettleState` existed: outstanding shares + /// cannot be reconstructed on-chain, so it has no deadline and cannot be + /// finalized (still fully redeemable). + NotSupported = 30, + /// A batch call carried no ids or more than `MAX_BATCH` of them. + BatchTooLarge = 31, } impl From for Error { @@ -272,6 +462,19 @@ pub struct FeesSwept { pub amount: i128, } +#[contractevent] +pub struct MarketFinalized { + #[topic] + pub market_id: u64, + /// Everything swept to the treasury: expired user claims, an unclaimed + /// LP remainder, rounding dust and accrued fees. + pub swept: i128, + /// The part of `swept` that was reserved for shares never redeemed. + pub unclaimed_user: i128, + /// Rounding left over after per-holder void floors were accounted for. + pub dust: i128, +} + /// Same sweep as `FeesSwept` plus the actual recipient. New event rather /// than new fields so the existing `FeesSwept` XDR stays stable for /// indexers; both are emitted on every sweep. @@ -403,6 +606,7 @@ impl MarketCore { s.set(&DataKey::ProtocolFeeShareBps, &protocol_fee_share_bps); s.set(&DataKey::Paused, &false); s.set(&DataKey::NextId, &0u64); + s.set(&DataKey::DefaultRedeemHorizon, &DEFAULT_REDEEM_HORIZON_SECS); } // ----- admin ---------------------------------------------------------- @@ -564,6 +768,20 @@ impl MarketCore { Ok(()) } + /// Affects markets created after the call; existing markets keep the + /// horizon snapshotted at their creation. Must be within + /// [180 days, 1825 days]. + pub fn set_default_redeem_horizon(env: Env, secs: u64) -> Result<(), Error> { + Self::require_admin(&env)?; + if !(MIN_REDEEM_HORIZON_SECS..=MAX_REDEEM_HORIZON_SECS).contains(&secs) { + return Err(Error::InvalidAmount); + } + env.storage() + .instance() + .set(&DataKey::DefaultRedeemHorizon, &secs); + Ok(()) + } + // ----- market lifecycle ----------------------------------------------- /// Creates a market and seeds the pool. `creator` becomes the sole LP. @@ -601,6 +819,9 @@ impl MarketCore { let fee_bps: u32 = s .get(&DataKey::DefaultFeeBps) .ok_or(Error::NotInitialized)?; + let horizon_secs: u64 = s + .get(&DataKey::DefaultRedeemHorizon) + .ok_or(Error::NotInitialized)?; let protocol_share_bps: u32 = s .get(&DataKey::ProtocolFeeShareBps) .ok_or(Error::NotInitialized)?; @@ -643,8 +864,42 @@ impl MarketCore { yes: seed.creator_yes, no: seed.creator_no, }, + market.close_time, ); } + // The creator's seed shares count as *user* outstanding, not pool. + let settle = SettleState { + outstanding_yes: seed.creator_yes, + outstanding_no: seed.creator_no, + finalized_at: 0, + horizon_secs, + paid_out: 0, + closed: false, + }; + Self::debug_assert_share_invariant(&market, &settle); + Self::save_settle(&env, id, &settle); + + // TWAP accumulator: cumulative 0 at creation; the seeded price holds + // from here until the first trade (slot 0 is the creation point). + let now = env.ledger().timestamp(); + let oracle = OracleState { + price_cumulative: 0, + last_ts: now, + last_price_bps: math::price_yes_bps(market.reserve_yes, market.reserve_no)? as u32, + obs_index: 0, + obs_count: 1, + }; + Self::save_oracle(&env, id, &oracle, market.close_time); + Self::save_obs( + &env, + id, + 0, + &Observation { + ts: now, + cumulative: 0, + }, + market.close_time, + ); Self::token(&env)?.transfer(&creator, env.current_contract_address(), &seed_amount); @@ -745,6 +1000,13 @@ impl MarketCore { trader.require_auth(); let mut m = Self::load_market(&env, market_id)?; Self::assert_tradable(&env, &m)?; + // TWAP: fold the pre-trade price over the elapsed interval before + // reserves move; `last_price_bps` is refreshed from the post-trade + // reserves below and the state is persisted once (issue #11). + let mut oracle = Self::load_oracle_opt(&env, market_id, m.close_time); + if let Some(st) = oracle.as_mut() { + Self::accumulate(&env, market_id, &m, st)?; + } let (r_bought, r_other) = Self::reserves_for(&m, outcome); let q = math::calc_buy(r_bought, r_other, collateral_in, m.fee_bps)?; @@ -777,13 +1039,36 @@ impl MarketCore { .fees_accrued .checked_add(protocol_fee) .ok_or(Error::Overflow)?; - let mut pos = Self::load_position(&env, market_id, &trader); + let mut pos = Self::load_position(&env, market_id, &trader, m.close_time); match outcome { Outcome::Yes => pos.yes += q.shares_out, Outcome::No => pos.no += q.shares_out, } - Self::save_position(&env, market_id, &trader, &pos); + Self::save_position(&env, market_id, &trader, &pos, m.close_time); Self::save_market(&env, market_id, &m); + if let Some(mut st) = oracle { + st.last_price_bps = math::price_yes_bps(m.reserve_yes, m.reserve_no)? as u32; + Self::save_oracle(&env, market_id, &st, m.close_time); + } + // Legacy markets (no `Settle` entry) predate this bookkeeping. + if let Some(mut settle) = Self::load_settle_opt(&env, market_id) { + match outcome { + Outcome::Yes => { + settle.outstanding_yes = settle + .outstanding_yes + .checked_add(q.shares_out) + .ok_or(Error::Overflow)? + } + Outcome::No => { + settle.outstanding_no = settle + .outstanding_no + .checked_add(q.shares_out) + .ok_or(Error::Overflow)? + } + } + Self::debug_assert_share_invariant(&m, &settle); + Self::save_settle(&env, market_id, &settle); + } // interaction Self::token(&env)?.transfer(&trader, env.current_contract_address(), &collateral_in); @@ -879,6 +1164,12 @@ impl MarketCore { trader.require_auth(); let mut m = Self::load_market(&env, market_id)?; Self::assert_tradable(&env, &m)?; + // TWAP: same rule as `buy` — fold the pre-trade price in first, then + // refresh `last_price_bps` from the post-trade reserves below. + let mut oracle = Self::load_oracle_opt(&env, market_id, m.close_time); + if let Some(st) = oracle.as_mut() { + Self::accumulate(&env, market_id, &m, st)?; + } let (r_sold, r_other) = Self::reserves_for(&m, outcome); let q = math::calc_sell(r_sold, r_other, collateral_out, m.fee_bps)?; @@ -899,7 +1190,7 @@ impl MarketCore { q.new_reserve_other, )?; - let mut pos = Self::load_position(&env, market_id, &trader); + let mut pos = Self::load_position(&env, market_id, &trader, m.close_time); let held = match outcome { Outcome::Yes => pos.yes, Outcome::No => pos.no, @@ -913,15 +1204,37 @@ impl MarketCore { Outcome::Yes => pos.yes -= q.shares_in, Outcome::No => pos.no -= q.shares_in, } - Self::save_position(&env, market_id, &trader, &pos); + Self::save_position(&env, market_id, &trader, &pos, m.close_time); 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.gross).ok_or(Error::Overflow)?; let protocol_fee = Self::accrue_fees(&env, market_id, q.fee)?; m.fees_accrued = m .fees_accrued .checked_add(protocol_fee) .ok_or(Error::Overflow)?; Self::save_market(&env, market_id, &m); + if let Some(mut st) = oracle { + st.last_price_bps = math::price_yes_bps(m.reserve_yes, m.reserve_no)? as u32; + Self::save_oracle(&env, market_id, &st, m.close_time); + } + if let Some(mut settle) = Self::load_settle_opt(&env, market_id) { + match outcome { + Outcome::Yes => { + settle.outstanding_yes = settle + .outstanding_yes + .checked_sub(q.shares_in) + .ok_or(Error::Overflow)? + } + Outcome::No => { + settle.outstanding_no = settle + .outstanding_no + .checked_sub(q.shares_in) + .ok_or(Error::Overflow)? + } + } + Self::debug_assert_share_invariant(&m, &settle); + Self::save_settle(&env, market_id, &settle); + } // interaction Self::token(&env)?.transfer(&env.current_contract_address(), &trader, &collateral_out); @@ -956,6 +1269,7 @@ impl MarketCore { } m.status = MarketStatus::Resolved(outcome); Self::save_market(&env, market_id, &m); + Self::mark_finalized(&env, market_id); MarketResolved { market_id, outcome: Some(outcome), @@ -973,6 +1287,7 @@ impl MarketCore { } m.status = MarketStatus::Void; Self::save_market(&env, market_id, &m); + Self::mark_finalized(&env, market_id); MarketResolved { market_id, outcome: None, @@ -989,13 +1304,38 @@ impl MarketCore { 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 let Some(settle) = Self::load_settle_opt(&env, market_id) { + if settle.closed { + return Err(Error::RedemptionClosed); + } + } + let pos = Self::load_position(&env, market_id, &holder, m.close_time); if pos.yes == 0 && pos.no == 0 { return Err(Error::NothingToRedeem); } let payout = math::settle_value(kind, pos.yes, pos.no); - Self::save_position(&env, market_id, &holder, &Position { yes: 0, no: 0 }); + Self::save_position( + &env, + market_id, + &holder, + &Position { yes: 0, no: 0 }, + m.close_time, + ); + // Both sides decrement even when only one pays: the shares are no + // longer outstanding, so a losing-side redemption still clears here. + if let Some(mut settle) = Self::load_settle_opt(&env, market_id) { + settle.outstanding_yes = settle + .outstanding_yes + .checked_sub(pos.yes) + .ok_or(Error::Overflow)?; + settle.outstanding_no = settle + .outstanding_no + .checked_sub(pos.no) + .ok_or(Error::Overflow)?; + settle.paid_out = settle.paid_out.checked_add(payout).ok_or(Error::Overflow)?; + Self::save_settle(&env, market_id, &settle); + } if payout > 0 { Self::token(&env)?.transfer(&env.current_contract_address(), &holder, &payout); } @@ -1014,6 +1354,11 @@ impl MarketCore { let mut m = Self::load_market(&env, market_id)?; m.creator.require_auth(); let kind = Self::settlement(&m)?; + if let Some(settle) = Self::load_settle_opt(&env, market_id) { + if settle.closed { + return Err(Error::RedemptionClosed); + } + } if m.lp_claimed { return Err(Error::AlreadyClaimed); } @@ -1027,6 +1372,10 @@ impl MarketCore { m.lp_claimed = true; let creator = m.creator.clone(); Self::save_market(&env, market_id, &m); + if let Some(mut settle) = Self::load_settle_opt(&env, market_id) { + settle.paid_out = settle.paid_out.checked_add(payout).ok_or(Error::Overflow)?; + Self::save_settle(&env, market_id, &settle); + } if payout > 0 { Self::token(&env)?.transfer(&env.current_contract_address(), &creator, &payout); } @@ -1039,6 +1388,239 @@ impl MarketCore { Ok(payout) } + /// Redeems `holder`'s positions across up to `MAX_BATCH` final markets + /// with one collateral transfer. `market_ids` must be strictly ascending + /// (`InvalidAmount` otherwise; empty or over-cap is `BatchTooLarge`) so + /// the footprint is deterministic and duplicates are impossible. + /// + /// Markets that cannot pay do not abort the batch; they come back in + /// `items` with a `SkipReason`. In particular an archived `Market` entry + /// is NOT `NotFound` — the transaction fails at footprint time and the + /// market must be restored first (simulation shows this before + /// submission). `holder.require_auth()` covers the whole batch: the + /// signed payload is the `market_ids` vector itself. + pub fn redeem_many( + env: Env, + holder: Address, + market_ids: Vec, + ) -> Result { + holder.require_auth(); + Self::validate_batch(&market_ids)?; + let mut total: i128 = 0; + let mut items: Vec = Vec::new(&env); + for id in market_ids.iter() { + let key = DataKey::Market(id); + // Raw read: a market that ends up skipped must not pay a TTL + // bump, and a missing entry must skip rather than revert. + let m = match env.storage().persistent().get::<_, Market>(&key) { + Some(m) => m, + None => { + items.push_back(Self::batch_skip(id, SkipReason::NotFound)); + continue; + } + }; + let kind = match Self::settlement(&m) { + Ok(kind) => kind, + Err(_) => { + items.push_back(Self::batch_skip(id, SkipReason::NotFinal)); + continue; + } + }; + if Self::settle_closed(&env, id) { + // `finalize_market` swept this market: redemption is over. + items.push_back(Self::batch_skip(id, SkipReason::NothingToRedeem)); + continue; + } + let pos = Self::load_position(&env, id, &holder, m.close_time); + if pos.yes == 0 && pos.no == 0 { + items.push_back(Self::batch_skip(id, SkipReason::NothingToRedeem)); + continue; + } + ttl::bump_persistent(&env, &key, ttl::market_target(&env, m.close_time)); + let payout = math::settle_value(kind, pos.yes, pos.no); + Self::save_position(&env, id, &holder, &Position { yes: 0, no: 0 }, m.close_time); + // Both sides decrement even when only one pays (same as the + // single-market path). + if let Some(mut settle) = Self::load_settle_opt(&env, id) { + settle.outstanding_yes = settle + .outstanding_yes + .checked_sub(pos.yes) + .ok_or(Error::Overflow)?; + settle.outstanding_no = settle + .outstanding_no + .checked_sub(pos.no) + .ok_or(Error::Overflow)?; + settle.paid_out = settle.paid_out.checked_add(payout).ok_or(Error::Overflow)?; + Self::save_settle(&env, id, &settle); + } + total = total.checked_add(payout).ok_or(Error::Overflow)?; + Redeemed { + market_id: id, + holder: holder.clone(), + payout, + } + .publish(&env); + items.push_back(BatchItem { + market_id: id, + payout, + status: BatchStatus::Settled, + }); + } + if total > 0 { + Self::token(&env)?.transfer(&env.current_contract_address(), &holder, &total); + } + Ok(BatchResult { total, items }) + } + + /// Claims the LP pool remainder for up to `MAX_BATCH` markets the + /// `creator` owns, with one collateral transfer. Same ordering and skip + /// semantics as `redeem_many`. A market closed by `finalize_market` + /// reports `AlreadyClaimed` — the sweep already took the remainder. + pub fn claim_pool_remainders( + env: Env, + creator: Address, + market_ids: Vec, + ) -> Result { + creator.require_auth(); + Self::validate_batch(&market_ids)?; + let mut total: i128 = 0; + let mut items: Vec = Vec::new(&env); + for id in market_ids.iter() { + let mut m = match env + .storage() + .persistent() + .get::<_, Market>(&DataKey::Market(id)) + { + Some(m) => m, + None => { + items.push_back(Self::batch_skip(id, SkipReason::NotFound)); + continue; + } + }; + if m.creator != creator { + items.push_back(Self::batch_skip(id, SkipReason::NotCreator)); + continue; + } + let kind = match Self::settlement(&m) { + Ok(kind) => kind, + Err(_) => { + items.push_back(Self::batch_skip(id, SkipReason::NotFinal)); + continue; + } + }; + if m.lp_claimed || Self::settle_closed(&env, id) { + items.push_back(Self::batch_skip(id, SkipReason::AlreadyClaimed)); + continue; + } + let pool_value = math::settle_value(kind, m.reserve_yes, m.reserve_no); + let payout = pool_value + .checked_add(Self::load_lp_fees(&env, id)) + .ok_or(Error::Overflow)?; + Self::save_lp_fees(&env, id, 0); + m.reserve_yes = 0; + m.reserve_no = 0; + m.lp_claimed = true; + Self::save_market(&env, id, &m); + if let Some(mut settle) = Self::load_settle_opt(&env, id) { + settle.paid_out = settle.paid_out.checked_add(payout).ok_or(Error::Overflow)?; + Self::save_settle(&env, id, &settle); + } + total = total.checked_add(payout).ok_or(Error::Overflow)?; + PoolRemainderClaimed { + market_id: id, + creator: creator.clone(), + payout, + } + .publish(&env); + items.push_back(BatchItem { + market_id: id, + payout, + status: BatchStatus::Settled, + }); + } + if total > 0 { + Self::token(&env)?.transfer(&env.current_contract_address(), &creator, &total); + } + Ok(BatchResult { total, items }) + } + + /// Permissionless close-out of a final market after its redemption + /// deadline. Sweeps the market's entire remaining balance — value + /// reserved for shares never redeemed, an unclaimed LP remainder, + /// per-holder rounding dust and accrued fees — to the treasury, and + /// marks the market closed so nothing can ever pay out again. + /// + /// The deadline is `redeem_deadline(market_id)` = `finalized_at + + /// horizon_secs`, the horizon being snapshotted at creation from the + /// admin default. Anyone who redeems before the deadline is paid the + /// exact `settle_value` they would have received without this call; + /// only shares still unredeemed at the deadline are forfeit, and their + /// value goes to the treasury rather than the LP so the LP cannot + /// profit from suppressing redemptions. + /// + /// Returns the amount swept. The swept total is exactly the market's + /// contract balance: sets plus fees minus everything already paid out. + pub fn finalize_market(env: Env, market_id: u64) -> Result { + let mut m = Self::load_market(&env, market_id)?; + let kind = Self::settlement(&m)?; + let mut settle = Self::load_settle(&env, market_id)?; + if settle.closed { + return Err(Error::RedemptionClosed); + } + if env.ledger().timestamp() < settle.finalized_at.saturating_add(settle.horizon_secs) { + return Err(Error::RedemptionWindowOpen); + } + // Upper bound on what the outstanding shares can still claim. For + // Yes/No this is exact; for Void, `floor((oy + on) / 2)` is at least + // the sum of per-holder floors (`settle_value` floors each pair), so + // reserving the aggregate floor never under-reserves. + let unclaimed_user = + math::settle_upper(kind, settle.outstanding_yes, settle.outstanding_no); + let lp_claim = if m.lp_claimed { + 0 + } else { + math::settle_value(kind, m.reserve_yes, m.reserve_no) + }; + // Sets are collateralized 1:1 and `paid_out` tracks what already + // left the market, so remaining claims minus those two figures is + // non-negative: it is exactly the per-holder void rounding dust + // (zero for Yes/No markets). checked_sub makes that an assertion. + let dust = m + .sets_minted + .checked_sub(settle.paid_out) + .and_then(|v| v.checked_sub(unclaimed_user)) + .and_then(|v| v.checked_sub(lp_claim)) + .ok_or(Error::Overflow)?; + let lp_fees = Self::load_lp_fees(&env, market_id); + let swept = dust + .checked_add(unclaimed_user) + .and_then(|v| v.checked_add(lp_claim)) + .and_then(|v| v.checked_add(m.fees_accrued)) + .and_then(|v| v.checked_add(lp_fees)) + .ok_or(Error::Overflow)?; + + m.reserve_yes = 0; + m.reserve_no = 0; + m.lp_claimed = true; + m.fees_accrued = 0; + settle.outstanding_yes = 0; + settle.outstanding_no = 0; + settle.closed = true; + Self::save_market(&env, market_id, &m); + Self::save_settle(&env, market_id, &settle); + Self::save_lp_fees(&env, market_id, 0); + let treasury: Address = Self::instance_get(&env, &DataKey::Treasury)?; + Self::token(&env)?.transfer(&env.current_contract_address(), &treasury, &swept); + MarketFinalized { + market_id, + swept, + unclaimed_user, + dust, + } + .publish(&env); + Ok(swept) + } + /// The creator (the LP) claims their share of accrued trade fees at any /// time, including before finality. Returns the amount claimed (0 when /// nothing is pending, mirroring `sweep_fees`). @@ -1115,9 +1697,9 @@ impl MarketCore { // (`redeem` requires the holder's auth), so this would burn them. return Err(Error::InvalidAmount); } - Self::load_market(&env, market_id)?; - let mut a = Self::load_position(&env, market_id, &from); - let mut b = Self::load_position(&env, market_id, &to); + let m = Self::load_market(&env, market_id)?; + let mut a = Self::load_position(&env, market_id, &from, m.close_time); + let mut b = Self::load_position(&env, market_id, &to, m.close_time); match outcome { Outcome::Yes => { if a.yes < amount { @@ -1134,8 +1716,8 @@ impl MarketCore { b.no = b.no.checked_add(amount).ok_or(Error::Overflow)?; } } - Self::save_position(&env, market_id, &from, &a); - Self::save_position(&env, market_id, &to, &b); + Self::save_position(&env, market_id, &from, &a, m.close_time); + Self::save_position(&env, market_id, &to, &b, m.close_time); PositionTransferred { market_id, from, @@ -1203,7 +1785,40 @@ impl MarketCore { } pub fn get_position(env: Env, market_id: u64, holder: Address) -> Position { - Self::load_position(&env, market_id, &holder) + // The TTL of the position entry tracks the market's close time. With + // no market to anchor to (a stale id), fall back to the settlement + // horizon from now so the entry at least keeps that much cover. + let close_time = match Self::load_market(&env, market_id) { + Ok(m) => m.close_time, + Err(_) => env.ledger().timestamp(), + }; + Self::load_position(&env, market_id, &holder, close_time) + } + + /// Outstanding-share accounting for a market (issue #14). + /// `NotSupported` for markets created before this bookkeeping existed. + pub fn settle_state(env: Env, market_id: u64) -> Result { + Self::load_market(&env, market_id)?; + Self::load_settle(&env, market_id) + } + + /// Timestamp after which unredeemed shares are forfeit (see + /// `finalize_market`). `None` while the market is still Open — its + /// deadline is not known yet — and for legacy markets, which have no + /// deadline and cannot be finalized. + pub fn redeem_deadline(env: Env, market_id: u64) -> Option { + match Self::load_settle_opt(&env, market_id) { + Some(s) if s.finalized_at > 0 => Some(s.finalized_at.saturating_add(s.horizon_secs)), + _ => None, + } + } + + /// Largest accepted `market_ids` length for `redeem_many` / + /// `claim_pool_remainders`. Frontends should chunk work into strictly + /// ascending batches of at most this size (see + /// `docs/frontend-integration.md`). + pub fn max_batch(_env: Env) -> u32 { + MAX_BATCH } /// Spot price of `outcome` in bps (0..=10_000). YES + NO ~ 10_000. @@ -1217,6 +1832,51 @@ impl MarketCore { Ok(p as u32) } + /// Cumulative `price_bps * seconds` for YES since creation, extrapolated + /// to now: `observe(market_id, 0)`. Ready-made for consumers that store + /// their own checkpoints and compute TWAPs off-chain. + pub fn price_cumulative(env: Env, market_id: u64) -> Result { + let m = Self::load_market(&env, market_id)?; + let st = Self::load_oracle(&env, market_id, m.close_time)?; + Self::observe_at(&env, market_id, &st, env.ledger().timestamp()) + } + + /// Cumulative YES-price-seconds as of `now - seconds_ago`. + /// + /// The accumulator is piecewise linear in time (the price only moves when + /// a trade lands), so between observations the value is interpolated + /// along the chord, and times after the last trade extrapolate at the + /// last traded price. Fails with `ObservationTooOld` when `seconds_ago` + /// reaches before the oldest surviving observation (the ring keeps the + /// most recent 16), and with `NotSupported` for markets created before + /// the accumulator existed. A missing (archived) ring slot is skipped, + /// never a panic. + pub fn observe(env: Env, market_id: u64, seconds_ago: u64) -> Result { + let m = Self::load_market(&env, market_id)?; + let st = Self::load_oracle(&env, market_id, m.close_time)?; + let now = env.ledger().timestamp(); + Self::observe_at(&env, market_id, &st, now.saturating_sub(seconds_ago)) + } + + /// Time-weighted average YES price in bps over the trailing + /// `window_secs`, floored. Rejects a zero window (`InvalidAmount`). + /// A window reaching before the oldest observation fails with + /// `ObservationTooOld`; a window extending past `close_time` keeps + /// averaging the final price, because the accumulator only moves when + /// trades are possible. + pub fn twap(env: Env, market_id: u64, window_secs: u64) -> Result { + if window_secs == 0 { + return Err(Error::InvalidAmount); + } + let m = Self::load_market(&env, market_id)?; + let st = Self::load_oracle(&env, market_id, m.close_time)?; + let now = env.ledger().timestamp(); + let cum_now = Self::observe_at(&env, market_id, &st, now)?; + let cum_then = Self::observe_at(&env, market_id, &st, now.saturating_sub(window_secs))?; + let delta = cum_now.checked_sub(cum_then).ok_or(Error::Overflow)?; + Ok((delta / window_secs as i128) as u32) + } + /// Shares a `buy` of `collateral_in` would return right now. pub fn quote_buy( env: Env, @@ -1317,7 +1977,7 @@ impl MarketCore { env: &Env, key: &DataKey, ) -> Result { - env.storage().instance().extend_ttl(BUMP_THRESHOLD, BUMP_TO); + ttl::bump_instance(env); env.storage() .instance() .get(key) @@ -1400,27 +2060,21 @@ impl MarketCore { .persistent() .get(&key) .ok_or(Error::MarketNotFound)?; - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(env, &key, ttl::market_target(env, m.close_time)); 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); + ttl::bump_persistent(env, &key, ttl::market_target(env, m.close_time)); } - fn load_position(env: &Env, id: u64, holder: &Address) -> Position { + fn load_position(env: &Env, id: u64, holder: &Address, close_time: u64) -> 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); + ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); p } None => Position { yes: 0, no: 0 }, @@ -1428,15 +2082,227 @@ impl MarketCore { } /// Empty positions are removed so storage does not grow with dust entries. - fn save_position(env: &Env, id: u64, holder: &Address, p: &Position) { + fn save_position(env: &Env, id: u64, holder: &Address, p: &Position, close_time: u64) { 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() + ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); + } + } + + /// Records the finality timestamp on `resolve` / `void_market` (both + /// already require status == Open, so it is never overwritten). No-op + /// for legacy markets without a `Settle` entry. + fn mark_finalized(env: &Env, id: u64) { + if let Some(mut settle) = Self::load_settle_opt(env, id) { + settle.finalized_at = env.ledger().timestamp(); + Self::save_settle(env, id, &settle); + } + } + + /// `None` for legacy markets created before `SettleState` existed; their + /// outstanding shares cannot be reconstructed on-chain, so they have no + /// deadline and `finalize_market` refuses them (`NotSupported`) while + /// `redeem` / `claim_pool_remainder` keep working as before. + fn load_settle_opt(env: &Env, id: u64) -> Option { + let key = DataKey::Settle(id); + let settle = env.storage().persistent().get::<_, SettleState>(&key)?; + // The deadline can be up to 5 years out, past `max_ttl()`; the entry + // is kept as long as the network allows and then needs a restore or + // the keeper (docs/wave-issues/14) until the market is finalized. + ttl::bump_persistent(env, &key, ttl::long_lived_target(env)); + Some(settle) + } + + fn load_settle(env: &Env, id: u64) -> Result { + Self::load_settle_opt(env, id).ok_or(Error::NotSupported) + } + + fn save_settle(env: &Env, id: u64, settle: &SettleState) { + let key = DataKey::Settle(id); + env.storage().persistent().set(&key, settle); + ttl::bump_persistent(env, &key, ttl::long_lived_target(env)); + } + + /// `true` once `finalize_market` closed the market. Raw read on purpose: + /// batch loops must not pay a TTL bump for a market they only skip. + fn settle_closed(env: &Env, id: u64) -> bool { + env.storage() + .persistent() + .get::<_, SettleState>(&DataKey::Settle(id)) + .is_some_and(|s| s.closed) + } + + fn batch_skip(id: u64, why: SkipReason) -> BatchItem { + BatchItem { + market_id: id, + payout: 0, + status: BatchStatus::Skipped(why), + } + } + + /// Batch input validation: at least one id, at most `MAX_BATCH`, and + /// strictly ascending so the footprint is deterministic and duplicates + /// cannot appear. + fn validate_batch(market_ids: &Vec) -> Result<(), Error> { + let n = market_ids.len(); + if n == 0 || n > MAX_BATCH { + return Err(Error::BatchTooLarge); + } + for i in 1..n { + if market_ids.get(i).unwrap() <= market_ids.get(i - 1).unwrap() { + return Err(Error::InvalidAmount); + } + } + Ok(()) + } + + /// `None` for markets created before the accumulator existed; unlike a + /// missing `Settle` entry this cannot be reconstructed on-chain, so the + /// oracle views refuse them (`NotSupported`) while trading keeps working + /// (with no TWAP support) for backwards compatibility. + fn load_oracle_opt(env: &Env, id: u64, close_time: u64) -> Option { + let key = DataKey::Oracle(id); + let st = env.storage().persistent().get::<_, OracleState>(&key)?; + ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); + Some(st) + } + + fn load_oracle(env: &Env, id: u64, close_time: u64) -> Result { + Self::load_oracle_opt(env, id, close_time).ok_or(Error::NotSupported) + } + + fn save_oracle(env: &Env, id: u64, st: &OracleState, close_time: u64) { + let key = DataKey::Oracle(id); + env.storage().persistent().set(&key, st); + ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); + } + + fn save_obs(env: &Env, id: u64, slot: u32, obs: &Observation, close_time: u64) { + let key = DataKey::Obs(id, slot); + env.storage().persistent().set(&key, obs); + ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); + } + + /// Advances the accumulator to `now` using the pre-trade price and + /// writes one ring observation when `now` is a new timestamp. + /// + /// Same-timestamp trades do not accumulate and do not write an + /// observation; only `last_price_bps` is refreshed by the caller after + /// the trade lands. That is what makes the accumulator + /// manipulation-resistant: an attacker trading back and forth inside one + /// ledger can move spot, but the price that counts toward future time is + /// whatever the last trade of the ledger left behind — which arbitrageurs + /// can punish as soon as the next ledger opens. + fn accumulate(env: &Env, id: u64, m: &Market, st: &mut OracleState) -> Result<(), Error> { + let now = env.ledger().timestamp(); + if now <= st.last_ts { + return Ok(()); + } + let dt = (now - st.last_ts) as i128; + st.price_cumulative = st + .price_cumulative + .checked_add( + (st.last_price_bps as i128) + .checked_mul(dt) + .ok_or(Error::Overflow)?, + ) + .ok_or(Error::Overflow)?; + st.last_ts = now; + st.obs_index = (st.obs_index + 1) % ORACLE_RING; + st.obs_count = core::cmp::min(st.obs_count + 1, ORACLE_RING); + Self::save_obs( + env, + id, + st.obs_index, + &Observation { + ts: now, + cumulative: st.price_cumulative, + }, + m.close_time, + ); + Ok(()) + } + + /// `price_cumulative` as of time `t`. Per-slot reads only, so a missing + /// (never-written or archived) slot is skipped, never a panic. + fn observe_at(env: &Env, id: u64, st: &OracleState, t: u64) -> Result { + if t >= st.last_ts { + // Counterfactual extension: the last traded price holds. + let ext = (st.last_price_bps as i128) + .checked_mul((t - st.last_ts) as i128) + .ok_or(Error::Overflow)?; + return st.price_cumulative.checked_add(ext).ok_or(Error::Overflow); + } + // `obs_count >= 1` for every live market: slot 0 is the creation + // point. `oldest` is the ring position of the oldest written slot. + let oldest = (st.obs_index + ORACLE_RING + 1 - st.obs_count) % ORACLE_RING; + let mut a: Option = None; // newest point with ts <= t + let mut b: Option = None; // oldest point with ts >= t + for k in 0..st.obs_count { + let slot = (oldest + k) % ORACLE_RING; + let Some(obs) = env + .storage() .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + .get::<_, Observation>(&DataKey::Obs(id, slot)) + else { + continue; + }; + if obs.ts <= t && a.is_none_or(|p| obs.ts > p.ts) { + a = Some(obs); + } + if obs.ts >= t && b.is_none_or(|p| obs.ts < p.ts) { + b = Some(obs); + } + } + // If the newest ring slot was archived, the live edge still brackets + // `t` from above; it is always at least as new as every observation. + if b.is_none_or(|p| st.last_ts < p.ts) { + b = Some(Observation { + ts: st.last_ts, + cumulative: st.price_cumulative, + }); + } + let Some(a) = a else { + return Err(Error::ObservationTooOld); + }; + let Some(b) = b else { + return Err(Error::ObservationTooOld); + }; + if a.ts == t { + return Ok(a.cumulative); + } + if b.ts == t { + return Ok(b.cumulative); + } + // Chord interpolation: exact between adjacent observations (the + // accumulator is linear on each interval, slope = the price that + // held), an approximation across a lost slot. + let interp = b + .cumulative + .checked_sub(a.cumulative) + .and_then(|d| d.checked_mul((t - a.ts) as i128)) + .ok_or(Error::Overflow)? + / (b.ts - a.ts) as i128; + a.cumulative.checked_add(interp).ok_or(Error::Overflow) + } + + /// Debug-build check of the share-count invariant + /// `reserve_ + outstanding_ == sets_minted`. + /// + /// Only valid while no collateral has left the market (`paid_out == 0` + /// and the LP unclaimed), which is exactly every Open market plus a + /// final one nobody redeemed from yet: redeeming a losing-side position + /// (payout 0) burns outstanding shares without a matching payout, so + /// after the first payout collateral conservation is tracked by + /// `paid_out` instead and enforced by `finalize_market`'s checked + /// residual. Exercised on create / buy / sell and in the tests. + fn debug_assert_share_invariant(m: &Market, settle: &SettleState) { + if settle.paid_out == 0 && !m.lp_claimed { + debug_assert_eq!(m.reserve_yes + settle.outstanding_yes, m.sets_minted); + debug_assert_eq!(m.reserve_no + settle.outstanding_no, m.sets_minted); } } @@ -1444,9 +2310,7 @@ impl MarketCore { let key = DataKey::FeeTerms(id); let terms = env.storage().persistent().get(&key); if terms.is_some() { - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(env, &key, ttl::long_lived_target(env)); } terms } @@ -1454,9 +2318,7 @@ impl MarketCore { fn save_fee_terms(env: &Env, id: u64, terms: &FeeTerms) { let key = DataKey::FeeTerms(id); env.storage().persistent().set(&key, terms); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(env, &key, ttl::long_lived_target(env)); } fn load_lp_fees(env: &Env, id: u64) -> i128 { @@ -1473,9 +2335,7 @@ impl MarketCore { env.storage().persistent().remove(&key); } else { env.storage().persistent().set(&key, &amount); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(env, &key, ttl::long_lived_target(env)); } } @@ -1499,9 +2359,7 @@ impl MarketCore { let key = DataKey::Breaker(id); let cfg: Option = env.storage().persistent().get(&key); if cfg.is_some() { - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(env, &key, ttl::long_lived_target(env)); } cfg } @@ -1509,9 +2367,7 @@ impl MarketCore { fn save_breaker(env: &Env, id: u64, cfg: &BreakerConfig) { let key = DataKey::Breaker(id); env.storage().persistent().set(&key, cfg); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(env, &key, ttl::long_lived_target(env)); } /// Runs the market's snapshotted breakers against a quote's @@ -1585,11 +2441,15 @@ impl MarketCore { #[cfg(test)] mod test { + extern crate std; + use super::*; use soroban_sdk::{ - testutils::{Address as _, Events, Ledger, MockAuth, MockAuthInvoke}, - token::StellarAssetClient, - IntoVal, + testutils::{ + storage::Persistent as _, Address as _, Events as _, Ledger, MockAuth, MockAuthInvoke, + }, + token::{Client as TokenClient, StellarAssetClient}, + Event as _, IntoVal, }; /// Minimal contract used to exercise the resolver/treasury wiring @@ -2949,6 +3809,1348 @@ mod test { client.transfer_lp(&market_id, &Address::generate(&env)); } + // ----- TTL policy ------------------------------------------------------ + + /// Advances both clocks together, as they move on a real network + /// (one ledger per `ttl::LEDGER_SECS`). + fn advance_ledgers(env: &Env, secs: u64) { + assert_eq!(secs % ttl::LEDGER_SECS, 0); + env.ledger() + .set_sequence_number(env.ledger().sequence() + (secs / ttl::LEDGER_SECS) as u32); + env.ledger().set_timestamp(env.ledger().timestamp() + secs); + } + + fn persistent_ttl(env: &Env, contract: &Address, key: &DataKey) -> u32 { + env.as_contract(contract, || env.storage().persistent().get_ttl(key)) + } + + fn create_market_closing_in( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + secs_from_now: u64, + ) -> u64 { + client.create_market( + creator, + &BytesN::from_array(env, &[21u8; 32]), + &BytesN::from_array(env, &[121u8; 32]), + &String::from_str(env, "ipfs://ttl-policy"), + &Category::Other, + &(env.ledger().timestamp() + secs_from_now), + &(100 * 10_000_000_i128), + &5000u32, + ) + } + + #[test] + fn market_entry_survives_until_close_plus_horizon() { + let (env, client, creator, _, _, _) = setup_market(); + // Current network limits: persistent entries may live at most 180 + // days; the minimum is deliberately left low so the measured TTL is + // the contract's own bump, not the network floor. + env.ledger().set_max_entry_ttl(3_110_400); + env.ledger().set_min_persistent_entry_ttl(4_096); + + let market_id = create_market_closing_in(&env, &client, &creator, 90 * ttl::DAY_SECS); + let key = DataKey::Market(market_id); + // 90 days to close + the 90-day settlement horizon, at the 180-day cap. + assert!(persistent_ttl(&env, &client.address, &key) >= 3_110_399); + + // The old fixed 60-day bump let the entry die at day 60, twenty days + // before this market could even close. Lay idle past that, then past + // close, and resolution must still work. + advance_ledgers(&env, 80 * ttl::DAY_SECS); + assert!(persistent_ttl(&env, &client.address, &key) > 0); + advance_ledgers(&env, 11 * ttl::DAY_SECS); + + client.resolve(&market_id, &Outcome::Yes); + assert_eq!( + client.get_market(&market_id).status, + MarketStatus::Resolved(Outcome::Yes) + ); + } + + #[test] + fn position_ttl_tracks_close_time() { + let (env, client, creator, trader, _, _) = setup_market(); + env.ledger().set_max_entry_ttl(3_110_400); + env.ledger().set_min_persistent_entry_ttl(4_096); + + let short = create_market_closing_in(&env, &client, &creator, 10 * ttl::DAY_SECS); + let long = create_market_closing_in(&env, &client, &creator, 100 * ttl::DAY_SECS); + client.buy( + &trader, + &short, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + client.buy( + &trader, + &long, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + + let short_ttl = persistent_ttl( + &env, + &client.address, + &DataKey::Position(short, trader.clone()), + ); + let long_ttl = persistent_ttl( + &env, + &client.address, + &DataKey::Position(long, trader.clone()), + ); + + // 10 days to close + 90-day horizon = 100 days. + assert_eq!(short_ttl, 100 * ttl::DAY_IN_LEDGERS); + // 100 days to close exceeds the 180-day cap only together with the + // horizon, so the long market is clamped at the maximum. + assert!(long_ttl >= 3_110_399); + assert!(long_ttl > short_ttl); + } + + // ----- Outstanding shares + redemption deadline (#14) ------------------ + + fn create_market_with( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + yes_bps: u32, + close_in_secs: u64, + ) -> u64 { + client.create_market( + creator, + &BytesN::from_array(env, &[41u8; 32]), + &BytesN::from_array(env, &[141u8; 32]), + &String::from_str(env, "ipfs://settle"), + &Category::Macro, + &(env.ledger().timestamp() + close_in_secs), + &(100 * 10_000_000_i128), + &yes_bps, + ) + } + + /// `reserve_ + outstanding_ == sets_minted`, checked through + /// the public views. Only meaningful while no payout has left the market + /// (before any redeem/claim); afterwards collateral conservation is + /// tracked by `paid_out` and enforced by `finalize_market`'s checked + /// residual — see the settle tests below. + fn assert_share_invariant(client: &MarketCoreClient, market_id: u64) { + let m = client.get_market(&market_id); + let s = client.settle_state(&market_id); + assert_eq!(s.paid_out, 0, "payouts must be zero for this assertion"); + assert_eq!(m.reserve_yes + s.outstanding_yes, m.sets_minted); + assert_eq!(m.reserve_no + s.outstanding_no, m.sets_minted); + } + + #[test] + fn invariant_reserve_plus_outstanding_equals_sets() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + assert_share_invariant(&client, market_id); + + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + assert_share_invariant(&client, market_id); + client.buy( + &trader, + &market_id, + &Outcome::No, + &(7 * 10_000_000_i128), + &0_i128, + ); + assert_share_invariant(&client, market_id); + + client.sell( + &trader, + &market_id, + &Outcome::Yes, + &(3 * 10_000_000_i128), + &i128::MAX, + ); + assert_share_invariant(&client, market_id); + client.sell( + &trader, + &market_id, + &Outcome::No, + &(2 * 10_000_000_i128), + &i128::MAX, + ); + assert_share_invariant(&client, market_id); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + assert_share_invariant(&client, market_id); + } + + #[test] + fn outstanding_tracks_buy_sell_redeem() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + + let s0 = client.settle_state(&market_id); + let m0 = client.get_market(&market_id); + assert_eq!(s0.finalized_at, 0); + assert_eq!(s0.horizon_secs, DEFAULT_REDEEM_HORIZON_SECS); + assert_eq!(s0.paid_out, 0); + assert!(!s0.closed); + assert_eq!(client.redeem_deadline(&market_id), None); + // The creator's seed shares count as user outstanding, not pool. + assert_eq!(s0.outstanding_yes, m0.sets_minted - m0.reserve_yes); + assert_eq!(s0.outstanding_no, 0); + + let bought = client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let s1 = client.settle_state(&market_id); + assert_eq!(s1.outstanding_yes, s0.outstanding_yes + bought); + + let sold = client.sell( + &trader, + &market_id, + &Outcome::Yes, + &(5 * 10_000_000_i128), + &i128::MAX, + ); + let s2 = client.settle_state(&market_id); + assert_eq!(s2.outstanding_yes, s1.outstanding_yes - sold); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let resolved_at = env.ledger().timestamp(); + assert_eq!( + client.redeem_deadline(&market_id), + Some(resolved_at + DEFAULT_REDEEM_HORIZON_SECS) + ); + + let trader_pos = client.get_position(&market_id, &trader); + let creator_pos = client.get_position(&market_id, &creator); + let trader_payout = client.redeem(&trader, &market_id); + assert_eq!(trader_payout, trader_pos.yes); + let s3 = client.settle_state(&market_id); + assert_eq!(s3.outstanding_yes, s2.outstanding_yes - trader_pos.yes); + assert_eq!(s3.paid_out, trader_payout); + assert_eq!(s3.finalized_at, resolved_at); + + let creator_payout = client.redeem(&creator, &market_id); + assert_eq!(creator_payout, creator_pos.yes); + let s4 = client.settle_state(&market_id); + assert_eq!(s4.outstanding_yes, 0); + assert_eq!(s4.paid_out, trader_payout + creator_payout); + + // The LP remainder still sits in the pool until claimed. + let before_claim = client.get_market(&market_id); + let lp_payout = client.claim_pool_remainder(&market_id); + assert_eq!(lp_payout, before_claim.reserve_yes); + let s5 = client.settle_state(&market_id); + assert_eq!(s5.paid_out, s4.paid_out + lp_payout); + } + + #[test] + #[should_panic(expected = "Error(Contract, #24)")] + fn finalize_before_deadline_rejected() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let deadline = client.redeem_deadline(&market_id).unwrap(); + env.ledger().set_timestamp(deadline - 1); + client.finalize_market(&market_id); + } + + #[test] + fn finalize_after_deadline_sweeps_exact_residual() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let trader2 = Address::generate(&env); + StellarAssetClient::new(&env, &collateral).mint(&trader2, &10_000_000_000_i128); + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + client.buy( + &trader2, + &market_id, + &Outcome::No, + &(7 * 10_000_000_i128), + &0_i128, + ); + + // A void with a mix of odd positions: per-holder floors are what + // leaves rounding dust behind. + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.void_market(&market_id); + + // One holder redeems before the deadline; the other never does. + let trader_payout = client.redeem(&trader, &market_id); + let deadline = client.redeem_deadline(&market_id).unwrap(); + env.ledger().set_timestamp(deadline + 1); + + let m_before = client.get_market(&market_id); + let s_before = client.settle_state(&market_id); + let expected_unclaimed = (s_before.outstanding_yes + s_before.outstanding_no) / 2; + let expected_lp = (m_before.reserve_yes + m_before.reserve_no) / 2; + let expected_dust = + m_before.sets_minted - s_before.paid_out - expected_unclaimed - expected_lp; + assert!(expected_dust >= 0); + let expected_swept = + expected_dust + expected_unclaimed + expected_lp + m_before.fees_accrued; + + let balance_before = token.balance(&client.address); + let treasury_before = token.balance(&client.get_config().treasury); + let swept = client.finalize_market(&market_id); + + // The sweep drains the market's entire balance — nothing stranded. + assert_eq!(swept, expected_swept); + assert_eq!(balance_before, swept); + assert_eq!(token.balance(&client.address), 0); + assert_eq!( + token.balance(&client.get_config().treasury), + treasury_before + swept + ); + + let m_after = client.get_market(&market_id); + assert!(m_after.lp_claimed); + assert_eq!(m_after.reserve_yes, 0); + assert_eq!(m_after.reserve_no, 0); + assert_eq!(m_after.fees_accrued, 0); + let s_after = client.settle_state(&market_id); + assert!(s_after.closed); + assert_eq!(s_after.outstanding_yes, 0); + assert_eq!(s_after.outstanding_no, 0); + // `paid_out` keeps only the holder payouts that ever happened. + assert_eq!(s_after.paid_out, trader_payout); + + // The never-redeemed holder is forfeit, not unpaid-but-pending. + assert!(client.try_redeem(&trader2, &market_id).is_err()); + } + + #[test] + fn redeem_before_deadline_never_underpaid() { + let (env, client, creator, trader, _, _) = setup_market(); + // Two identical markets: one is finalized after the deadline, the + // other is finalized too — the redemptions in between must be paid + // exactly the same in both worlds. + let a = create_market_with(&env, &client, &creator, 6800, 60_000); + let b = create_market_with(&env, &client, &creator, 6800, 60_000); + for id in [a, b] { + client.buy( + &trader, + &id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + } + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&a, &Outcome::Yes); + client.resolve(&b, &Outcome::Yes); + + let deadline_a = client.redeem_deadline(&a).unwrap(); + let payout_a = client.redeem(&trader, &a); + let payout_b = client.redeem(&trader, &b); + assert_eq!(payout_a, payout_b); + assert!(payout_a > 0); + + // Now let both deadlines pass and finalize both. + env.ledger().set_timestamp(deadline_a + 1); + let swept_b = client.finalize_market(&b); + let swept_a = client.finalize_market(&a); + assert_eq!(swept_a, swept_b); + // The redeemer's payout was never touched by the finalize. + assert_eq!(client.settle_state(&a).paid_out, payout_a); + } + + #[test] + #[should_panic(expected = "Error(Contract, #25)")] + fn redeem_after_close_rejected() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let deadline = client.redeem_deadline(&market_id).unwrap(); + env.ledger().set_timestamp(deadline + 1); + client.finalize_market(&market_id); + client.redeem(&trader, &market_id); + } + + #[test] + #[should_panic(expected = "Error(Contract, #25)")] + fn claim_after_close_rejected() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let deadline = client.redeem_deadline(&market_id).unwrap(); + env.ledger().set_timestamp(deadline + 1); + client.finalize_market(&market_id); + client.claim_pool_remainder(&market_id); + } + + #[test] + #[should_panic(expected = "Error(Contract, #25)")] + fn finalize_twice_rejected() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let deadline = client.redeem_deadline(&market_id).unwrap(); + env.ledger().set_timestamp(deadline + 1); + client.finalize_market(&market_id); + client.finalize_market(&market_id); + } + + #[test] + #[should_panic(expected = "Error(Contract, #26)")] + fn legacy_market_cannot_finalize() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + // Simulate a market created before `Settle` entries existed. + env.as_contract(&client.address, || { + env.storage() + .persistent() + .remove(&DataKey::Settle(market_id)); + }); + client.finalize_market(&market_id); + } + + #[test] + fn legacy_market_still_redeems_without_deadline() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + env.as_contract(&client.address, || { + env.storage() + .persistent() + .remove(&DataKey::Settle(market_id)); + }); + + // No deadline, no finalize — but the old redemption paths keep working. + assert_eq!(client.redeem_deadline(&market_id), None); + assert!(client.try_settle_state(&market_id).is_err()); + let payout = client.redeem(&trader, &market_id); + assert!(payout > 0); + assert!(client.claim_pool_remainder(&market_id) > 0); + } + + #[test] + fn redeem_horizon_default_snapshot_and_bounds() { + let (env, client, creator, _, _, _) = setup_market(); + let long_horizon = 200 * ttl::DAY_SECS; + client.set_default_redeem_horizon(&long_horizon); + assert!(client + .try_set_default_redeem_horizon(&(100 * ttl::DAY_SECS)) + .is_err()); + assert!(client + .try_set_default_redeem_horizon(&(2_000 * ttl::DAY_SECS)) + .is_err()); + + let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); + assert_eq!(client.settle_state(&market_id).horizon_secs, long_horizon); + assert_eq!(client.redeem_deadline(&market_id), None); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let resolved_at = env.ledger().timestamp(); + assert_eq!( + client.redeem_deadline(&market_id), + Some(resolved_at + long_horizon) + ); + } + + #[test] + fn wrong_caller_cannot_set_default_redeem_horizon() { + let (env, client, _, trader, _, _) = setup_market(); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: &trader, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "set_default_redeem_horizon", + args: (365 * ttl::DAY_SECS,).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_set_default_redeem_horizon(&(365 * ttl::DAY_SECS)) + .is_err()); + } + + // ----- TWAP accumulator (#11) ------------------------------------------ + + fn oracle_state(env: &Env, client: &MarketCoreClient, market_id: u64) -> OracleState { + env.as_contract(&client.address, || { + env.storage() + .persistent() + .get::<_, OracleState>(&DataKey::Oracle(market_id)) + .unwrap() + }) + } + + fn obs_at( + env: &Env, + client: &MarketCoreClient, + market_id: u64, + slot: u32, + ) -> Option { + env.as_contract(&client.address, || { + env.storage() + .persistent() + .get::<_, Observation>(&DataKey::Obs(market_id, slot)) + }) + } + + #[test] + fn twap_equals_spot_without_trades() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + let spot = client.get_price(&market_id, &Outcome::Yes); + + env.ledger().set_timestamp(3_600); + // No trade yet: every second so far held the seeded price. + assert_eq!(client.twap(&market_id, &3_600), spot); + assert_eq!(client.twap(&market_id, &60), spot); + assert_eq!(client.price_cumulative(&market_id), (spot as i128) * 3_600); + assert_eq!(client.observe(&market_id, &1_800), (spot as i128) * 1_800); + } + + #[test] + fn twap_weights_by_time() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + + env.ledger().set_timestamp(1_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let p_a = client.get_price(&market_id, &Outcome::Yes) as i128; + + env.ledger().set_timestamp(1_100); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(5 * 10_000_000_i128), + &0_i128, + ); + let p_b = client.get_price(&market_id, &Outcome::Yes) as i128; + + env.ledger().set_timestamp(1_400); + // [1000, 1100) held p_a, [1100, 1400) held p_b (extrapolated past + // the last trade). The first 1000 s of seeded price are outside the + // window and must not contribute. + let expected = (p_a * 100 + p_b * 300) / 400; + assert_eq!(client.twap(&market_id, &400) as i128, expected); + assert_eq!(client.twap(&market_id, &300), p_b as u32); + } + + #[test] + fn same_timestamp_trades_do_not_accumulate() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + + env.ledger().set_timestamp(1_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let p1 = client.get_price(&market_id, &Outcome::Yes); + let cum1 = client.price_cumulative(&market_id); + let twap1 = client.twap(&market_id, &500); + let st1 = oracle_state(&env, &client, market_id); + assert_eq!(st1.obs_count, 2, "creation slot + first trade"); + assert_eq!(st1.obs_index, 1); + + // A second trade in the same ledger moves spot but must not write an + // observation or advance the cumulative. + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let p2 = client.get_price(&market_id, &Outcome::Yes); + assert!(p2 > p1); + + let st2 = oracle_state(&env, &client, market_id); + assert_eq!(st2.obs_count, 2); + assert_eq!(st2.obs_index, 1); + assert_eq!(st2.price_cumulative, st1.price_cumulative); + assert_eq!(client.price_cumulative(&market_id), cum1); + assert_eq!(client.twap(&market_id, &500), twap1); + } + + #[test] + fn manipulation_within_one_ledger_does_not_move_twap() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5_000, 600_000); + + env.ledger().set_timestamp(5_000); + let spot_before = client.get_price(&market_id, &Outcome::Yes); + let twap_before = client.twap(&market_id, &1_000); + + // Big buy, then a partial sell-back — all inside one ledger. + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(50 * 10_000_000_i128), + &0_i128, + ); + let spot_after_buy = client.get_price(&market_id, &Outcome::Yes); + assert!( + spot_after_buy > spot_before + 200, + "the buy must move spot materially ({spot_before} -> {spot_after_buy})" + ); + assert_eq!(client.twap(&market_id, &1_000), twap_before); + + client.sell( + &trader, + &market_id, + &Outcome::Yes, + &(40 * 10_000_000_i128), + &i128::MAX, + ); + assert_eq!(client.twap(&market_id, &1_000), twap_before); + } + + #[test] + fn observe_interpolates_between_observations() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + let p0 = client.get_price(&market_id, &Outcome::Yes) as i128; + + env.ledger().set_timestamp(1_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let p_a = client.get_price(&market_id, &Outcome::Yes) as i128; + + env.ledger().set_timestamp(1_100); + // t = 950 sits between the creation point (0) and the t=1000 + // observation; the chord has slope p0 (the price that held). + assert_eq!(client.observe(&market_id, &150), p0 * 950); + // Exact at a stored observation. + assert_eq!(client.observe(&market_id, &100), p0 * 1_000); + // The live edge: cumulative at t=1000 plus p_a over the 100 s since. + assert_eq!(client.price_cumulative(&market_id), p0 * 1_000 + p_a * 100); + } + + #[test] + fn observe_rejects_too_old() { + let (env, client, creator, trader, _, _) = setup_market(); + env.ledger().set_timestamp(1_000); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + env.ledger().set_timestamp(2_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + + // 1001 s ago is one second before the creation observation. + assert_eq!( + client.try_observe(&market_id, &1_001), + Err(Ok(Error::ObservationTooOld)) + ); + // A window reaching back that far fails the same way. + assert_eq!( + client.try_twap(&market_id, &1_001), + Err(Ok(Error::ObservationTooOld)) + ); + // Landing exactly on the creation observation is fine. + assert_eq!(client.observe(&market_id, &1_000), 0); + } + + #[test] + fn ring_buffer_wraps_after_16_distinct_timestamps() { + let (env, client, creator, trader, _, _) = setup_market(); + env.ledger().set_timestamp(1_000); + let market_id = create_market_with(&env, &client, &creator, 5_000, 600_000); + + for k in 1..=17u64 { + env.ledger().set_timestamp(1_000 + 100 * k); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + ); + } + + // 1 creation + 17 trade observations into 16 slots: the oldest + // surviving slot holds t=1200, the newest holds t=2700. + let st = oracle_state(&env, &client, market_id); + assert_eq!(st.obs_count, 16); + assert_eq!(st.obs_index, 1); + assert_eq!(obs_at(&env, &client, market_id, 2).unwrap().ts, 1_200); + assert_eq!(obs_at(&env, &client, market_id, 0).unwrap().ts, 2_600); + assert_eq!(obs_at(&env, &client, market_id, 1).unwrap().ts, 2_700); + + // The oldest surviving observation still answers; one second + // earlier is gone from the ring. + assert!(client.observe(&market_id, &1_500) > 0); + assert_eq!( + client.try_observe(&market_id, &1_501), + Err(Ok(Error::ObservationTooOld)) + ); + } + + #[test] + fn quote_views_do_not_write_oracle_state() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + env.ledger().set_timestamp(1_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + env.ledger().set_timestamp(1_100); + + let st_before = oracle_state(&env, &client, market_id); + let slots_before: [Option; ORACLE_RING as usize] = + core::array::from_fn(|k| obs_at(&env, &client, market_id, k as u32)); + + let _ = client.quote_buy(&market_id, &Outcome::Yes, &(10 * 10_000_000_i128)); + let _ = client.quote_sell(&market_id, &Outcome::No, &(5 * 10_000_000_i128)); + let _ = client.get_price(&market_id, &Outcome::No); + let _ = client.get_market(&market_id); + let _ = client.observe(&market_id, &50); + let _ = client.twap(&market_id, &100); + let _ = client.price_cumulative(&market_id); + + assert_eq!(oracle_state(&env, &client, market_id), st_before); + let slots_after: [Option; ORACLE_RING as usize] = + core::array::from_fn(|k| obs_at(&env, &client, market_id, k as u32)); + assert_eq!(slots_before, slots_after); + } + + #[test] + fn accumulator_is_overflow_safe_over_ten_years() { + let (env, client, creator, trader, _, _) = setup_market(); + let ten_years: u64 = 10 * 365 * ttl::DAY_SECS; + env.ledger().set_timestamp(1_000); + let market_id = create_market_with( + &env, + &client, + &creator, + 5_000, + ten_years + 30 * ttl::DAY_SECS, + ); + + // Monthly trades for 10 years, alternating sides so the price stays + // in a sane band. Each step feeds a ~30-day dt through the checked + // accumulator arithmetic; the final cumulative is ~p_avg * 3.2e8. + let step = ten_years / 120; + for m in 1..=119u64 { + env.ledger().set_timestamp(1_000 + m * step); + let outcome = if m % 2 == 0 { + Outcome::Yes + } else { + Outcome::No + }; + client.buy(&trader, &market_id, &outcome, &10_000_000_i128, &0_i128); + } + env.ledger().set_timestamp(1_000 + 120 * step); + let p_held_for_last_day = client.get_price(&market_id, &Outcome::Yes); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + ); + + let cum = client.price_cumulative(&market_id); + let elapsed = (env.ledger().timestamp() - 1_000) as i128; + assert!(cum > 0); + // Every second contributes at most 10_000 bps. + assert!(cum <= elapsed * 10_000); + // The trailing day was held at the pre-final-trade price the whole + // time (the final trade lands exactly at `now`). + assert_eq!(client.twap(&market_id, &ttl::DAY_SECS), p_held_for_last_day); + assert_eq!(oracle_state(&env, &client, market_id).obs_count, 16); + } + + #[test] + fn legacy_market_oracle_views_not_supported() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + // Simulate a market created before the accumulator existed. + env.as_contract(&client.address, || { + env.storage() + .persistent() + .remove(&DataKey::Oracle(market_id)); + env.storage() + .persistent() + .remove(&DataKey::Obs(market_id, 0)); + }); + assert_eq!( + client.try_observe(&market_id, &1), + Err(Ok(Error::NotSupported)) + ); + assert_eq!( + client.try_twap(&market_id, &10), + Err(Ok(Error::NotSupported)) + ); + assert_eq!( + client.try_price_cumulative(&market_id), + Err(Ok(Error::NotSupported)) + ); + } + + /// Cost harness for the numbers reported in the PR for issue #11. Run + /// explicitly with: `cargo test -p market-core measure_buy_resources -- + /// --ignored --nocapture`. The same test body run against the parent + /// commit's build gives the before/after delta for `buy`. + #[test] + #[ignore = "measurement harness; run explicitly with --ignored --nocapture"] + fn measure_buy_resources() { + extern crate std; + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5_000, 10_000_000); + env.ledger().set_timestamp(1_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + std::println!( + "buy (new timestamp, with accumulator): {:?}", + env.cost_estimate().resources() + ); + } + + // ----- issue #15: batch redemption across many markets ----------------- + + fn open_market_with_position( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + trader: &Address, + outcome: Outcome, + collateral_in: i128, + ) -> u64 { + let market_id = create_market_with(env, client, creator, 5_000, 60_000); + client.buy(trader, &market_id, &outcome, &collateral_in, &0_i128); + market_id + } + + /// Advance past every close used by the batch tests, then resolve `ids`. + fn advance_and_resolve(env: &Env, client: &MarketCoreClient, ids: &[u64], outcome: Outcome) { + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + for id in ids { + client.resolve(id, &outcome); + } + } + + fn ids_vec(env: &Env, ids: &[u64]) -> Vec { + let mut v = Vec::new(env); + for id in ids { + v.push_back(*id); + } + v + } + + /// XDR events published by the market-core contract itself, oldest first. + fn core_events( + env: &Env, + client: &MarketCoreClient, + ) -> std::vec::Vec { + let filtered = env.events().all().filter_by_contract(&client.address); + let mut out = std::vec::Vec::new(); + for event in filtered.events() { + out.push(event.clone()); + } + out + } + + #[test] + fn redeem_many_pays_single_aggregated_transfer() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let m0 = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + let m1 = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 7 * 10_000_000, + ); + let m2 = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 5 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[m0, m1, m2], Outcome::Yes); + + let token = TokenClient::new(&env, &collateral); + let core_before = token.balance(&client.address); + let trader_before = token.balance(&trader); + + let res = client.redeem_many(&trader, &ids_vec(&env, &[m0, m1, m2])); + + // `Events::all()` reports only the last contract invocation's events, + // so capture them before any balance/state reads (which are + // themselves invocations). + let emitted = core_events(&env, &client); + assert_eq!(emitted.len(), 3); + + assert_eq!(res.items.len(), 3); + let mut sum = 0i128; + for (i, item) in res.items.iter().enumerate() { + assert_eq!(item.market_id, [m0, m1, m2][i]); + assert_eq!(item.status, BatchStatus::Settled); + assert!(item.payout > 0); + sum += item.payout; + } + assert_eq!(sum, res.total); + // One aggregated transfer: the contract pays exactly `total`, the + // holder receives exactly `total`, and nothing else moved. + assert_eq!(token.balance(&client.address), core_before - res.total); + assert_eq!(token.balance(&trader), trader_before + res.total); + // Positions are cleared and `SettleState::paid_out` tracks each payout. + for (i, m) in [m0, m1, m2].iter().enumerate() { + let pos = client.get_position(m, &trader); + assert_eq!(pos.yes, 0); + assert_eq!(pos.no, 0); + let settle = client.settle_state(m); + assert_eq!(settle.paid_out, res.items.get(i as u32).unwrap().payout); + } + // One Redeemed event per market, in request order. + for (i, m) in [m0, m1, m2].iter().enumerate() { + let expected = Redeemed { + market_id: *m, + holder: trader.clone(), + payout: res.items.get(i as u32).unwrap().payout, + } + .to_xdr(&env, &client.address); + assert_eq!(emitted[i], expected); + } + } + + #[test] + fn redeem_many_skips_open_and_empty_markets() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let m_open = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + // No position for `trader` — the batch must skip, not revert. + let m_flat = create_market_with(&env, &client, &creator, 5_000, 60_000); + let m_paid = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 5 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[m_flat, m_paid], Outcome::Yes); + + let token = TokenClient::new(&env, &collateral); + let before = token.balance(&trader); + let res = client.redeem_many(&trader, &ids_vec(&env, &[m_open, m_flat, m_paid])); + + assert_eq!( + res.items.get(0).unwrap().status, + BatchStatus::Skipped(SkipReason::NotFinal) + ); + assert_eq!(res.items.get(0).unwrap().payout, 0); + assert_eq!( + res.items.get(1).unwrap().status, + BatchStatus::Skipped(SkipReason::NothingToRedeem) + ); + assert_eq!(res.items.get(2).unwrap().status, BatchStatus::Settled); + assert_eq!(res.total, res.items.get(2).unwrap().payout); + assert_eq!(token.balance(&trader), before + res.total); + // The open market's position is untouched. + assert!(client.get_position(&m_open, &trader).yes > 0); + } + + #[test] + fn redeem_many_rejects_unsorted_or_duplicate_ids() { + let (env, client, creator, trader, _, _) = setup_market(); + let m0 = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + let m1 = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[m0, m1], Outcome::Yes); + + assert_eq!( + client.try_redeem_many(&trader, &ids_vec(&env, &[m1, m0])), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!( + client.try_redeem_many(&trader, &ids_vec(&env, &[m0, m0])), + Err(Ok(Error::InvalidAmount)) + ); + // Validation runs before any state change: positions are intact. + assert!(client.get_position(&m0, &trader).yes > 0); + assert!(client.get_position(&m1, &trader).yes > 0); + } + + #[test] + fn redeem_many_rejects_over_cap() { + let (env, client, _, trader, _, _) = setup_market(); + let mut over = Vec::new(&env); + for i in 0..=(MAX_BATCH as u64) { + over.push_back(i); + } + assert_eq!( + client.try_redeem_many(&trader, &over), + Err(Ok(Error::BatchTooLarge)) + ); + assert_eq!( + client.try_redeem_many(&trader, &Vec::new(&env)), + Err(Ok(Error::BatchTooLarge)) + ); + // The LP batch validates identically. + assert_eq!( + client.try_claim_pool_remainders(&trader, &over), + Err(Ok(Error::BatchTooLarge)) + ); + assert_eq!( + client.try_claim_pool_remainders(&trader, &Vec::new(&env)), + Err(Ok(Error::BatchTooLarge)) + ); + } + + #[test] + fn redeem_many_mixed_yes_no_void() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let m_yes = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + let m_no = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::No, + 7 * 10_000_000, + ); + let m_void = create_market_with(&env, &client, &creator, 5_000, 60_000); + client.buy( + &trader, + &m_void, + &Outcome::Yes, + &(4 * 10_000_000_i128), + &0_i128, + ); + client.buy( + &trader, + &m_void, + &Outcome::No, + &(3 * 10_000_000_i128), + &0_i128, + ); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&m_yes, &Outcome::Yes); + client.resolve(&m_no, &Outcome::No); + client.void_market(&m_void); + + let pos_yes = client.get_position(&m_yes, &trader); + let pos_no = client.get_position(&m_no, &trader); + let pos_void = client.get_position(&m_void, &trader); + assert!(pos_yes.yes > 0 && pos_no.no > 0 && pos_void.no > 0); + + let token = TokenClient::new(&env, &collateral); + let before = token.balance(&trader); + let res = client.redeem_many(&trader, &ids_vec(&env, &[m_yes, m_no, m_void])); + + assert_eq!(res.items.get(0).unwrap().payout, pos_yes.yes); + assert_eq!(res.items.get(1).unwrap().payout, pos_no.no); + assert_eq!( + res.items.get(2).unwrap().payout, + (pos_void.yes + pos_void.no) / 2 + ); + assert_eq!( + res.total, + pos_yes.yes + pos_no.no + (pos_void.yes + pos_void.no) / 2 + ); + assert_eq!(token.balance(&trader), before + res.total); + } + + #[test] + fn claim_pool_remainders_skips_not_creator() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let m_mine = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + let stranger = Address::generate(&env); + StellarAssetClient::new(&env, &collateral).mint(&stranger, &(1_000 * 10_000_000_i128)); + let m_other = open_market_with_position( + &env, + &client, + &stranger, + &trader, + Outcome::Yes, + 5 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[m_mine, m_other], Outcome::Yes); + + let expected_payout = client.get_market(&m_mine).reserve_yes; + let token = TokenClient::new(&env, &collateral); + let before = token.balance(&creator); + let res = client.claim_pool_remainders(&creator, &ids_vec(&env, &[m_mine, m_other])); + + assert_eq!(res.items.get(0).unwrap().status, BatchStatus::Settled); + assert_eq!(res.items.get(0).unwrap().payout, expected_payout); + assert_eq!( + res.items.get(1).unwrap().status, + BatchStatus::Skipped(SkipReason::NotCreator) + ); + assert_eq!(res.items.get(1).unwrap().payout, 0); + assert_eq!(res.total, expected_payout); + assert_eq!(token.balance(&creator), before + res.total); + assert!(client.get_market(&m_mine).lp_claimed); + assert!(!client.get_market(&m_other).lp_claimed); + + // Re-running the same batch is safe: the claimed market reports + // AlreadyClaimed, the stranger's still NotCreator, and no further + // transfer happens. + let again = client.claim_pool_remainders(&creator, &ids_vec(&env, &[m_mine, m_other])); + assert_eq!( + again.items.get(0).unwrap().status, + BatchStatus::Skipped(SkipReason::AlreadyClaimed) + ); + assert_eq!( + again.items.get(1).unwrap().status, + BatchStatus::Skipped(SkipReason::NotCreator) + ); + assert_eq!(again.total, 0); + assert_eq!(token.balance(&creator), before + expected_payout); + } + + #[test] + fn claim_pool_remainder_emits_event() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[market_id], Outcome::Yes); + + let payout = client.claim_pool_remainder(&market_id); + + // Exactly one market-core event: the claim itself (the token transfer + // event belongs to the collateral contract and is filtered out). + let emitted = core_events(&env, &client); + assert_eq!(emitted.len(), 1); + let expected = PoolRemainderClaimed { + market_id, + creator: creator.clone(), + payout, + } + .to_xdr(&env, &client.address); + assert_eq!(emitted[0], expected); + } + + #[test] + fn wrong_caller_cannot_redeem_many_for_other_holder() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[market_id], Outcome::Yes); + + let not_holder = Address::generate(&env); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: ¬_holder, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "redeem_many", + args: (trader.clone(), ids_vec(&env, &[market_id])).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_redeem_many(&trader, &ids_vec(&env, &[market_id])) + .is_err()); + // The position was not touched by the failed call. + assert!(client.get_position(&market_id, &trader).yes > 0); + + // The signed payload is exactly `(holder, market_ids)`: a mock signed + // by the holder over that invocation succeeds and pays. + let res = client + .mock_auths(&[MockAuth { + address: &trader, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "redeem_many", + args: (trader.clone(), ids_vec(&env, &[market_id])).into_val(&env), + sub_invokes: &[], + }, + }]) + .redeem_many(&trader, &ids_vec(&env, &[market_id])); + assert!(res.total > 0); + } + + /// Measurement harness behind the `MAX_BATCH` doc table (issue #15): + /// run explicitly with + /// `cargo test -p market-core measure_redeem_many_resources -- --ignored --nocapture`. + #[test] + #[ignore = "measurement harness; run explicitly with --ignored --nocapture"] + fn measure_redeem_many_resources() { + for n in [1u64, 5, 10, 20, 30, 40, 45, 46] { + let (env, client, creator, trader, _, collateral) = setup_market(); + // Each market needs a 100 USDC seed from the creator, and each + // position 10 USDC from the trader; top both up so the harness + // measures redemption, not the faucet. + let sac = StellarAssetClient::new(&env, &collateral); + sac.mint(&creator, &((n as i128 + 10) * 100 * 10_000_000)); + sac.mint(&trader, &((n as i128 + 10) * 10 * 10_000_000)); + let mut ids = Vec::new(&env); + for _ in 0..n { + ids.push_back(open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + )); + } + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + for id in ids.iter() { + client.resolve(&id, &Outcome::Yes); + } + let res = client.redeem_many(&trader, &ids); + let r = env.cost_estimate().resources(); + std::println!( + "redeem_many n={} total={} items={} | insns={} mem={} disk_read_entries={} mem_read_entries={} write_entries={} footprint={} disk_read_bytes={} write_bytes={} events_bytes={}", + n, + res.total, + res.items.len(), + r.instructions, + r.mem_bytes, + r.disk_read_entries, + r.memory_read_entries, + r.write_entries, + r.disk_read_entries + r.memory_read_entries + r.write_entries, + r.disk_read_bytes, + r.write_bytes, + r.contract_events_size_bytes + ); + } + } + // ----- #62: two-step admin transfer ------------------------------------- #[test] diff --git a/oracledesk-stellar/contracts/market-core/src/math.rs b/oracledesk-stellar/contracts/market-core/src/math.rs index a09574c..fd60aa2 100644 --- a/oracledesk-stellar/contracts/market-core/src/math.rs +++ b/oracledesk-stellar/contracts/market-core/src/math.rs @@ -330,6 +330,23 @@ pub fn settle_value(kind: Settlement, yes: i128, no: i128) -> i128 { } } +/// 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; @@ -395,6 +412,36 @@ mod tests { 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; diff --git a/oracledesk-stellar/contracts/market-core/src/ttl.rs b/oracledesk-stellar/contracts/market-core/src/ttl.rs new file mode 100644 index 0000000..0debebd --- /dev/null +++ b/oracledesk-stellar/contracts/market-core/src/ttl.rs @@ -0,0 +1,107 @@ +//! Close-time-aware storage TTL policy. +//! +//! Soroban archives a ledger entry once its TTL (in ledgers) runs out. An +//! archived persistent entry cannot be read or written until someone submits +//! a restore operation, and an archived **instance** entry makes the whole +//! contract uncallable until restored. Every contract in this workspace +//! therefore has to keep its entries alive at least as long as they can be +//! needed; this module is that policy. +//! +//! It is duplicated in all four contract crates because a contract crate +//! cannot depend on another contract crate (their `#[contractimpl]` wasm +//! exports collide on `__constructor`; see +//! `docs/adr/0002-cross-contract-calls.md`), and the copies must stay +//! byte-identical below this header. +//! +//! Policy +//! - A market's entries are kept until `close_time + SETTLEMENT_HORIZON`. +//! Under a fixed 60-day bump a market closing more than 60 days out could +//! archive before it closed, so the resolver's `get_market` cross-call +//! failed until someone restored it. +//! - Every target is clamped to `env.storage().max_ttl()` — never hardcode +//! network limits. Mainnet and testnet are both at 3_110_400 ledgers +//! (180 days) today, so a market further than 90 days from close is capped +//! at the network maximum and needs activity or the keeper +//! (`docs/wave-issues/14-storage-ttl-keeper.md`) to outlive one bump. +//! - Persistent entries are only re-extended once their remaining TTL has +//! dropped below half the target (`threshold_for`), so a busy hot path +//! does not pay an extension write on every call. +//! +//! Rent: an extension rents `ceil(bytes / 1024) * ledgers * +//! rent_fee_per_1kb(live_state_size) / persistent_rent_rate_denominator` +//! (CAP-0066; denominator 1215, per-1KB rate interpolates from a 1_000 +//! stroop floor to 10_000 as live Soroban state grows toward its target). +//! One 1-KB entry costs roughly 0.04-0.43 XLM per 30 days, so covering one +//! entry for the whole 90-day horizon costs roughly 0.13-1.28 XLM — cheap +//! next to a market that archives before it can be resolved, which blocks +//! resolution until a restore operation (also rent) is submitted. + +use soroban_sdk::Env; + +/// Stellar's target ledger close time. `DAY_IN_LEDGERS` assumes 5 s; if the +/// network ever re-targets close time, both values change together. +pub const LEDGER_SECS: u64 = 5; +pub const DAY_SECS: u64 = 86_400; +pub const DAY_IN_LEDGERS: u32 = (DAY_SECS / LEDGER_SECS) as u32; + +/// Instance storage is bumped on every entrypoint: extend once fewer than +/// 7 days remain, out to 30. A month of cover keeps a contract invocable +/// through normal lulls while bounding the rent a single call can pay. +pub const INSTANCE_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; +pub const INSTANCE_TARGET: u32 = 30 * DAY_IN_LEDGERS; + +/// Time a market may need after `close_time`: resolution, a signer dispute +/// window, and holder redemptions. +pub const SETTLEMENT_HORIZON_SECS: u64 = 90 * DAY_SECS; + +/// Target TTL for long-lived entries with no market association (registered +/// agents), so a routinely-used entry is never at risk; clamped by +/// `max_ttl()` in practice to the network maximum. +pub const LONG_LIVED_SECS: u64 = 365 * DAY_SECS; + +/// Ledgers from now until `deadline_ts`, clamped to the network maximum. +pub fn secs_to_ledgers_clamped(env: &Env, secs: u64) -> u32 { + let ledgers = secs / LEDGER_SECS; + core::cmp::min(ledgers, env.storage().max_ttl() as u64) as u32 +} + +/// How long a market's storage must live: until `close_time` plus the +/// settlement horizon, clamped to `max_ttl()`. +pub fn market_target(env: &Env, close_time: u64) -> u32 { + let now = env.ledger().timestamp(); + let until_close = close_time.saturating_sub(now); + secs_to_ledgers_clamped(env, until_close.saturating_add(SETTLEMENT_HORIZON_SECS)) +} + +/// Target TTL for entries with no market association. See `LONG_LIVED_SECS`. +pub fn long_lived_target(env: &Env) -> u32 { + secs_to_ledgers_clamped(env, LONG_LIVED_SECS) +} + +/// Persistent entries are only re-extended when their remaining TTL has +/// fallen below half the target, which keeps touching an entry cheap. +pub fn threshold_for(target: u32) -> u32 { + target / 2 +} + +/// Extends `key` toward `target` ledgers from now, if it exists and its +/// remaining TTL is under `threshold_for(target)`. Extending a missing key +/// panics, so callers must guard with `has()` where the key may be absent. +pub fn bump_persistent(env: &Env, key: &K, target: u32) +where + K: soroban_sdk::IntoVal, +{ + env.storage() + .persistent() + .extend_ttl(key, threshold_for(target), target); +} + +/// Extends instance storage to `INSTANCE_TARGET` when fewer than +/// `INSTANCE_THRESHOLD` ledgers remain. Called from every entrypoint that +/// reads instance storage — an archived instance entry bricks the contract +/// until restored, so this is the cheapest insurance the contract can buy. +pub fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_THRESHOLD, INSTANCE_TARGET); +} diff --git a/oracledesk-stellar/contracts/reasoning-registry/src/lib.rs b/oracledesk-stellar/contracts/reasoning-registry/src/lib.rs index f8e90cd..844d6c5 100644 --- a/oracledesk-stellar/contracts/reasoning-registry/src/lib.rs +++ b/oracledesk-stellar/contracts/reasoning-registry/src/lib.rs @@ -1,5 +1,7 @@ #![no_std] +pub mod ttl; + // The test module builds over-length `action`/`ipfs_cid` strings with // `str::repeat`, which lives in `alloc`/`std`; the crate itself stays // `no_std`. @@ -11,6 +13,11 @@ use soroban_sdk::{ Env, String, Vec, }; +/// Rolling window for trace entries, unchanged from the pre-#16 policy: +/// 60 days, extended at half that. Registration (`Agent`) instead uses +/// `ttl::long_lived_target` and is refreshed by every `publish_trace`. +const TRACE_TARGET: u32 = 60 * ttl::DAY_IN_LEDGERS; + mod market_core { #[allow(clippy::too_many_arguments)] soroban_sdk::contractimport!(file = "market_core.wasm"); @@ -405,7 +412,11 @@ impl ReasoningRegistry { status: AgentStatus::Active, registered_at: now, }; - Self::put(&env, &DataKey::Agent(agent.clone()), &record); + let agent_key = DataKey::Agent(agent.clone()); + Self::put(&env, &agent_key, &record); + // Registration is long-lived (issue #16), not the 60-day trace window + // `put` bumps to, and is refreshed again on every `publish_trace`. + ttl::bump_persistent(&env, &agent_key, ttl::long_lived_target(&env)); AgentRegistered { agent }.publish(&env); Ok(()) } @@ -462,6 +473,8 @@ impl ReasoningRegistry { Self::retire_agent(env, agent) } + /// Read-only view: no TTL writes, so an indexer's simulation stays cheap. + /// The `Agent` entry is refreshed by `register_agent`/`publish_trace`. pub fn is_registered(env: Env, agent: Address) -> bool { Self::load_agent(&env, &agent).map_or(false, |r| matches!(r.status, AgentStatus::Active)) } @@ -478,7 +491,15 @@ impl ReasoningRegistry { trace_hash: BytesN<32>, ipfs_cid: String, ) -> Result { + ttl::bump_instance(&env); Self::check_publish(&env, &agent, market_id, &action, &ipfs_cid)?; + // Refresh the registration TTL on every publish (#16): without this a + // long-running agent's `Agent` entry archives and later publishes fail. + ttl::bump_persistent( + &env, + &DataKey::Agent(agent.clone()), + ttl::long_lived_target(&env), + ); // Once an agent keeps a chained log, unchained traces would sit outside // it unseen, so they're refused (#52). if Self::load_head(&env, &agent).seq > 0 { @@ -502,7 +523,13 @@ impl ReasoningRegistry { expected_seq: u64, prev_trace_hash: BytesN<32>, ) -> Result { + ttl::bump_instance(&env); Self::check_publish(&env, &agent, market_id, &action, &ipfs_cid)?; + ttl::bump_persistent( + &env, + &DataKey::Agent(agent.clone()), + ttl::long_lived_target(&env), + ); let head = Self::load_head(&env, &agent); if expected_seq != head.seq + 1 || prev_trace_hash != head.head_hash { return Err(Error::ChainHeadMismatch); @@ -634,6 +661,9 @@ impl ReasoningRegistry { Self::commitment_for(&env, &trace_hash, &salt) == commitment } + /// Extends `Trace` on read, as before (a write in a view: simulation + /// footprints are read-write, kept for compatibility and noted in + /// `docs/architecture.md`). pub fn get_trace(env: Env, trace_id: u64) -> Result { let key = DataKey::Trace(trace_id); let trace: Trace = env @@ -641,9 +671,7 @@ impl ReasoningRegistry { .persistent() .get(&key) .ok_or(Error::TraceNotFound)?; - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(&env, &key, TRACE_TARGET); Ok(trace) } @@ -751,7 +779,10 @@ impl ReasoningRegistry { Ok(Self::trace_verdict(env, trace_id, received_hash)? == TraceVerdict::Valid) } + /// Reads the instance's `NextId`; the bump keeps the instance (and with + /// it the whole contract) alive under view-only traffic. pub fn trace_count(env: Env) -> u64 { + ttl::bump_instance(&env); env.storage().instance().get(&DataKey::NextId).unwrap_or(0) } @@ -1685,6 +1716,7 @@ impl ReasoningRegistry { } fn require_admin(env: &Env) -> Result<(), Error> { + ttl::bump_instance(env); let admin: Address = env .storage() .instance() @@ -1699,7 +1731,10 @@ impl ReasoningRegistry { mod test { use super::*; use soroban_sdk::{ - testutils::{Address as _, Ledger as _, MockAuth, MockAuthInvoke}, + testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, Ledger as _, MockAuth, MockAuthInvoke, + }, IntoVal, }; @@ -1895,6 +1930,68 @@ mod test { .is_err()); } + fn advance_ledgers(env: &Env, secs: u64) { + assert_eq!(secs % ttl::LEDGER_SECS, 0); + env.ledger() + .set_sequence_number(env.ledger().sequence() + (secs / ttl::LEDGER_SECS) as u32); + env.ledger().set_timestamp(env.ledger().timestamp() + secs); + } + + fn persistent_ttl(env: &Env, contract: &Address, key: &DataKey) -> u32 { + env.as_contract(contract, || env.storage().persistent().get_ttl(key)) + } + + fn instance_ttl(env: &Env, contract: &Address) -> u32 { + env.as_contract(contract, || env.storage().instance().get_ttl()) + } + + fn publish_trace_at_day( + client: &ReasoningRegistryClient, + env: &Env, + agent: &Address, + day: u64, + ) { + let trace_hash = BytesN::from_array(env, &[day as u8; 32]); + let cid = String::from_str(env, "ipfs://publish-refresh"); + client.publish_trace( + agent, + &(100 + day), + &String::from_str(env, "trade"), + &trace_hash, + &cid, + ); + } + + #[test] + fn agent_entry_extended_on_publish() { + let (env, client, _, agent) = setup(); + let max_ttl = 3_110_400u32; + env.ledger().set_max_entry_ttl(max_ttl); + env.ledger().set_min_persistent_entry_ttl(4_096); + let agent_key = DataKey::Agent(agent.clone()); + + client.register_agent(&agent); + assert!(persistent_ttl(&env, &client.address, &agent_key) >= max_ttl - 1); + + // Day 0 and day 59 publishes; the day-100 publish only succeeds if the + // registration was not archived in between (it would be past the old + // 60-day window with no `Agent` extension on publish). + publish_trace_at_day(&client, &env, &agent, 0); + advance_ledgers(&env, 59 * ttl::DAY_SECS); + publish_trace_at_day(&client, &env, &agent, 59); + + advance_ledgers(&env, 41 * ttl::DAY_SECS); + let before = persistent_ttl(&env, &client.address, &agent_key); + assert!(before < max_ttl / 2, "refresh was already due at day 100"); + assert!(client.is_registered(&agent)); + + publish_trace_at_day(&client, &env, &agent, 100); + assert!( + persistent_ttl(&env, &client.address, &agent_key) >= max_ttl - 1, + "publish_trace did not refresh the Agent entry" + ); + assert_eq!(instance_ttl(&env, &client.address), ttl::INSTANCE_TARGET); + // ── #52 hash-chained traces ────────────────────────────────────────── fn chained( diff --git a/oracledesk-stellar/contracts/reasoning-registry/src/ttl.rs b/oracledesk-stellar/contracts/reasoning-registry/src/ttl.rs new file mode 100644 index 0000000..0debebd --- /dev/null +++ b/oracledesk-stellar/contracts/reasoning-registry/src/ttl.rs @@ -0,0 +1,107 @@ +//! Close-time-aware storage TTL policy. +//! +//! Soroban archives a ledger entry once its TTL (in ledgers) runs out. An +//! archived persistent entry cannot be read or written until someone submits +//! a restore operation, and an archived **instance** entry makes the whole +//! contract uncallable until restored. Every contract in this workspace +//! therefore has to keep its entries alive at least as long as they can be +//! needed; this module is that policy. +//! +//! It is duplicated in all four contract crates because a contract crate +//! cannot depend on another contract crate (their `#[contractimpl]` wasm +//! exports collide on `__constructor`; see +//! `docs/adr/0002-cross-contract-calls.md`), and the copies must stay +//! byte-identical below this header. +//! +//! Policy +//! - A market's entries are kept until `close_time + SETTLEMENT_HORIZON`. +//! Under a fixed 60-day bump a market closing more than 60 days out could +//! archive before it closed, so the resolver's `get_market` cross-call +//! failed until someone restored it. +//! - Every target is clamped to `env.storage().max_ttl()` — never hardcode +//! network limits. Mainnet and testnet are both at 3_110_400 ledgers +//! (180 days) today, so a market further than 90 days from close is capped +//! at the network maximum and needs activity or the keeper +//! (`docs/wave-issues/14-storage-ttl-keeper.md`) to outlive one bump. +//! - Persistent entries are only re-extended once their remaining TTL has +//! dropped below half the target (`threshold_for`), so a busy hot path +//! does not pay an extension write on every call. +//! +//! Rent: an extension rents `ceil(bytes / 1024) * ledgers * +//! rent_fee_per_1kb(live_state_size) / persistent_rent_rate_denominator` +//! (CAP-0066; denominator 1215, per-1KB rate interpolates from a 1_000 +//! stroop floor to 10_000 as live Soroban state grows toward its target). +//! One 1-KB entry costs roughly 0.04-0.43 XLM per 30 days, so covering one +//! entry for the whole 90-day horizon costs roughly 0.13-1.28 XLM — cheap +//! next to a market that archives before it can be resolved, which blocks +//! resolution until a restore operation (also rent) is submitted. + +use soroban_sdk::Env; + +/// Stellar's target ledger close time. `DAY_IN_LEDGERS` assumes 5 s; if the +/// network ever re-targets close time, both values change together. +pub const LEDGER_SECS: u64 = 5; +pub const DAY_SECS: u64 = 86_400; +pub const DAY_IN_LEDGERS: u32 = (DAY_SECS / LEDGER_SECS) as u32; + +/// Instance storage is bumped on every entrypoint: extend once fewer than +/// 7 days remain, out to 30. A month of cover keeps a contract invocable +/// through normal lulls while bounding the rent a single call can pay. +pub const INSTANCE_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; +pub const INSTANCE_TARGET: u32 = 30 * DAY_IN_LEDGERS; + +/// Time a market may need after `close_time`: resolution, a signer dispute +/// window, and holder redemptions. +pub const SETTLEMENT_HORIZON_SECS: u64 = 90 * DAY_SECS; + +/// Target TTL for long-lived entries with no market association (registered +/// agents), so a routinely-used entry is never at risk; clamped by +/// `max_ttl()` in practice to the network maximum. +pub const LONG_LIVED_SECS: u64 = 365 * DAY_SECS; + +/// Ledgers from now until `deadline_ts`, clamped to the network maximum. +pub fn secs_to_ledgers_clamped(env: &Env, secs: u64) -> u32 { + let ledgers = secs / LEDGER_SECS; + core::cmp::min(ledgers, env.storage().max_ttl() as u64) as u32 +} + +/// How long a market's storage must live: until `close_time` plus the +/// settlement horizon, clamped to `max_ttl()`. +pub fn market_target(env: &Env, close_time: u64) -> u32 { + let now = env.ledger().timestamp(); + let until_close = close_time.saturating_sub(now); + secs_to_ledgers_clamped(env, until_close.saturating_add(SETTLEMENT_HORIZON_SECS)) +} + +/// Target TTL for entries with no market association. See `LONG_LIVED_SECS`. +pub fn long_lived_target(env: &Env) -> u32 { + secs_to_ledgers_clamped(env, LONG_LIVED_SECS) +} + +/// Persistent entries are only re-extended when their remaining TTL has +/// fallen below half the target, which keeps touching an entry cheap. +pub fn threshold_for(target: u32) -> u32 { + target / 2 +} + +/// Extends `key` toward `target` ledgers from now, if it exists and its +/// remaining TTL is under `threshold_for(target)`. Extending a missing key +/// panics, so callers must guard with `has()` where the key may be absent. +pub fn bump_persistent(env: &Env, key: &K, target: u32) +where + K: soroban_sdk::IntoVal, +{ + env.storage() + .persistent() + .extend_ttl(key, threshold_for(target), target); +} + +/// Extends instance storage to `INSTANCE_TARGET` when fewer than +/// `INSTANCE_THRESHOLD` ledgers remain. Called from every entrypoint that +/// reads instance storage — an archived instance entry bricks the contract +/// until restored, so this is the cheapest insurance the contract can buy. +pub fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_THRESHOLD, INSTANCE_TARGET); +} diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index edfcc9b..14ac670 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -1,5 +1,7 @@ #![no_std] +pub mod ttl; + #[cfg(test)] extern crate std; @@ -70,6 +72,13 @@ pub const MAX_COMMITTEE: u32 = 32; /// Minimum time-lock delay for proposing and executing committee rotations (1 day). pub const MIN_ROTATION_DELAY: u64 = 86_400; +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OracleAsset { + Stellar(Address), + Other(Symbol), +} + /// Grace period for revealing specs before guardian void is allowed (3 days). pub const REVEAL_GRACE_SECS: u64 = 3 * 86_400; /// Grace period for oracle recovery before guardian void is allowed (1 day). @@ -926,9 +935,8 @@ impl Resolver { return Err(Error::AlreadyFinalized); } let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(&env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(&env, &market_core).try_get_market(&market_id))?; let now = env.ledger().timestamp(); let fallback_deadline = market.close_time.saturating_add(config.fallback_after); if now < fallback_deadline { @@ -1101,6 +1109,7 @@ impl Resolver { if env.ledger().timestamp() >= proposal.proposed_at.saturating_add(dispute_window) { return Err(Error::DisputeWindowClosed); } + let close_time = Self::market_close_time(&env, market_id)?; let cancel_key = DataKey::Cancels(market_id); let cancels: u32 = storage.get(&cancel_key).unwrap_or(0); if cancels >= MAX_CANCELS_PER_MARKET { @@ -1115,6 +1124,7 @@ impl Resolver { &(Self::round(&env, market_id) + 1), ); } + Self::bump_market_entries(&env, market_id, close_time); GuardianAction { market_id, action: GuardianActionKind::CancelProposal, @@ -1203,6 +1213,7 @@ impl Resolver { core_call(core.try_void_market(&market_id))?; Self::refund_open_dispute(&env, market_id); Self::set_state(&env, market_id, ResolutionState::Finalized); + Self::bump_market_entries(&env, market_id, market.close_time); GuardianAction { market_id, action: GuardianActionKind::Void, @@ -1224,9 +1235,8 @@ impl Resolver { return Err(Error::WrongMode); }; let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(&env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(&env, &market_core).try_get_market(&market_id))?; if env.ledger().timestamp() < market.close_time { return Err(Error::MarketNotClosed); } @@ -1275,9 +1285,8 @@ impl Resolver { caller.require_auth(); let config = Self::price_config(&env, market_id)?; let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(&env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(&env, &market_core).try_get_market(&market_id))?; let now = env.ledger().timestamp(); if now < market.close_time { return Err(Error::MarketNotClosed); @@ -1321,9 +1330,8 @@ impl Resolver { /// is known before (and without) a spec reveal. pub fn resolution_deadline(env: Env, market_id: u64) -> Result { let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(&env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(&env, &market_core).try_get_market(&market_id))?; Ok(Self::deadline(&market)) } @@ -1379,6 +1387,7 @@ impl Resolver { core_call(core.try_void_market(&market_id))?; Self::set_state(&env, market_id, ResolutionState::Expired); + Self::bump_market_entries(&env, market_id, market.close_time); MarketExpired { market_id, close_time: market.close_time, @@ -1451,9 +1460,8 @@ impl Resolver { return Err(Error::StaleRound); } let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(&env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(&env, &market_core).try_get_market(&market_id))?; if env.ledger().timestamp() < market.close_time { return Err(Error::MarketNotClosed); } @@ -1534,6 +1542,7 @@ impl Resolver { } } Self::set_state(&env, market_id, ResolutionState::Finalized); + Self::bump_market_entries(&env, market_id, Self::market_close_time(&env, market_id)?); ResolutionFinalizedV2 { market_id, outcome: proposal.outcome, @@ -1577,7 +1586,8 @@ impl Resolver { } let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let token = core_call(MarketCoreClient::new(&env, &market_core).try_get_config())?.collateral; + let token = + core_call(MarketCoreClient::new(&env, &market_core).try_get_config())?.collateral; token::Client::new(&env, &token).transfer( &disputer, env.current_contract_address(), @@ -1688,6 +1698,9 @@ impl Resolver { Ok(()) } + /// Pure view: no TTL extensions. The instance and the market's entries + /// are kept alive by the mutating entrypoints above, so a view-only + /// simulation stays read-only. pub fn state(env: Env, market_id: u64) -> ResolutionState { Self::resolution_state(&env, market_id) } @@ -1698,6 +1711,7 @@ impl Resolver { env: &Env, key: DataKey, ) -> Result { + ttl::bump_instance(env); env.storage() .instance() .get(&key) @@ -1737,9 +1751,6 @@ impl Resolver { fn set_state(env: &Env, market_id: u64, state: ResolutionState) { let key = DataKey::State(market_id); env.storage().persistent().set(&key, &state); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); } fn deadline(market: &market_core::Market) -> u64 { @@ -2182,6 +2193,8 @@ impl Resolver { .try_resolve(&market_id, &to_core_outcome(outcome)), )?; Self::set_state(env, market_id, ResolutionState::Finalized); + let close_time = Self::market_close_time(env, market_id)?; + Self::bump_market_entries(env, market_id, close_time); PriceResolution { market_id, price: data.price, @@ -2230,8 +2243,9 @@ impl Resolver { ) -> Result<(), Error> { Self::assert_unconfigured(env, market_id)?; Self::validate_rule(rule)?; - Self::assert_matches_commitment(env, market_id, commitment)?; + let close_time = Self::assert_matches_commitment(env, market_id, commitment)?; Self::store_rule(env, market_id, rule); + Self::bump_market_entries(env, market_id, close_time); Ok(()) } @@ -2267,7 +2281,7 @@ impl Resolver { fn store_rule(env: &Env, market_id: u64, rule: &ResolutionSpec) { let storage = env.storage().persistent(); - let (key, state) = match rule { + let (_key, state) = match rule { ResolutionSpec::Price(cfg) => { let key = DataKey::Price(market_id); storage.set(&key, cfg); @@ -2392,9 +2406,8 @@ impl Resolver { .unwrap_or(EvidencePolicy::Disabled); let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(env, &market_core).try_get_market(&market_id))?; // Before any write: this is what keeps an `Invalid` proposal (and so // `void_market`, which has no close-time guard) from existing before // close. @@ -2412,15 +2425,14 @@ impl Resolver { if storage.has(&DataKey::EvidenceUsed(trace_id)) { return Err(Error::EvidenceAlreadyUsed); } - let trace = match registry_call( - RegistryClient::new(env, ®istry).try_get_trace(&trace_id), - ) { - Ok(trace) => trace, - // A missing trace (or a registry that cannot be read) is the - // same caller-facing condition: the cited evidence is not - // available. The registry's own numeric code is never leaked. - Err(_) => return Err(Error::EvidenceNotFound), - }; + let trace = + match registry_call(RegistryClient::new(env, ®istry).try_get_trace(&trace_id)) { + Ok(trace) => trace, + // A missing trace (or a registry that cannot be read) is the + // same caller-facing condition: the cited evidence is not + // available. The registry's own numeric code is never leaked. + Err(_) => return Err(Error::EvidenceNotFound), + }; if trace.agent != signer { return Err(Error::EvidenceAuthorMismatch); } @@ -2460,6 +2472,13 @@ impl Resolver { &DataKey::AttestationV2(market_id, outcome, signer.clone()), &true, ); + // #16: attestation entries are not enumerable, so each is extended to + // the market's close-time-aware target where it is written. + ttl::bump_persistent( + env, + &DataKey::AttestationV2(market_id, outcome, signer.clone()), + ttl::market_target(env, market.close_time), + ); if let Some(epoch) = members_opt { Self::persist( env, @@ -2496,6 +2515,7 @@ impl Resolver { if quorum_reached && may_propose { Self::propose(env, market_id, outcome); } + Self::bump_market_entries(env, market_id, market.close_time); Ok(()) } @@ -2504,15 +2524,57 @@ impl Resolver { env: &Env, market_id: u64, commitment: &BytesN<32>, - ) -> Result<(), Error> { + ) -> Result { let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(env, &market_core).try_get_market(&market_id))?; if *commitment != market.resolution_hash { return Err(Error::SpecHashMismatch); } - Ok(()) + Ok(market.close_time) + } + + fn market_close_time(env: &Env, market_id: u64) -> Result { + let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; + Ok(MarketCoreClient::new(env, &market_core) + .get_market(&market_id) + .close_time) + } + + /// Extends every per-market entry to the market's close-time-aware target. + /// Called after each mutating entrypoint so an in-progress vote or a + /// finalized market cannot archive mid-settlement. `Attestation` entries + /// are not enumerable — each is extended where it is written, plus the + /// configured signers' in `finalize_signers` — so they are handled + /// separately. Absent keys are skipped: `extend_ttl` panics on them. + fn bump_market_entries(env: &Env, market_id: u64, close_time: u64) { + let target = ttl::market_target(env, close_time); + let keys = [ + DataKey::Price(market_id), + DataKey::Signers(market_id), + DataKey::WeightedKeys(market_id), + DataKey::Disputable(market_id), + DataKey::Proposal(market_id), + DataKey::ProposalV2(market_id), + DataKey::Snapshot(market_id), + DataKey::State(market_id), + DataKey::Round(market_id), + DataKey::Dispute(market_id), + DataKey::Count(market_id, Outcome::Yes), + DataKey::Count(market_id, Outcome::No), + // #16: `CountV2`/`ProposalV2` are the live tally/proposal keys after + // the `AttestedOutcome` migration (ADR 0003); the legacy `Count`/ + // `Proposal` above are only read during migration, so the close-time + // guarantee lives on the V2 variants. + DataKey::CountV2(market_id, AttestedOutcome::Yes), + DataKey::CountV2(market_id, AttestedOutcome::No), + DataKey::CountV2(market_id, AttestedOutcome::Invalid), + ]; + for key in keys { + if env.storage().persistent().has(&key) { + ttl::bump_persistent(env, &key, target); + } + } } } @@ -2522,7 +2584,10 @@ mod test { use market_core::{Category, MarketStatus}; use soroban_sdk::{ contract, contractimpl, - testutils::{Address as _, Events as _, Ledger, MockAuth, MockAuthInvoke}, + testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, Events as _, Ledger, MockAuth, MockAuthInvoke, + }, token::{self, StellarAssetClient}, BytesN, IntoVal, }; @@ -4167,6 +4232,123 @@ mod test { let _ = f.admin; } + // ---- close-time-aware storage TTL (issue #16) -------------------------- + // + // These helpers read back a live entry's remaining TTL so the tests can + // prove a tally/instance survives an idle gap longer than the fixed + // `BUMP_TO` (60-day) default and therefore needed the write-time + // `ttl::market_target` extension, not just `Self::persist`'s bump. After + // the `AttestedOutcome` migration (ADR 0003) the live tally is `CountV2`. + + fn advance_ledgers(env: &Env, secs: u64) { + assert_eq!(secs % ttl::LEDGER_SECS, 0); + env.ledger() + .set_sequence_number(env.ledger().sequence() + (secs / ttl::LEDGER_SECS) as u32); + env.ledger().set_timestamp(env.ledger().timestamp() + secs); + } + + fn persistent_ttl(env: &Env, contract: &Address, key: &DataKey) -> u32 { + env.as_contract(contract, || env.storage().persistent().get_ttl(key)) + } + + fn instance_ttl(env: &Env, contract: &Address) -> u32 { + env.as_contract(contract, || env.storage().instance().get_ttl()) + } + + #[test] + fn signer_vote_entries_survive_dispute_period() { + let f = fixture(|env, _r, a, b| ResolutionSpec::Signers(signer_spec(env, a, b))); + f.env.ledger().set_max_entry_ttl(3_110_400); + f.env.ledger().set_min_persistent_entry_ttl(4_096); + let ResolutionSpec::Signers(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_signer_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + f.resolver + .attest(&f.market_id, &f.signer_a, &AttestedOutcome::No); + + // 61 days of silence — past the old 60-day cap and past any default + // persistent TTL. Without the write-time extension the tally would be + // archived here and the second attest would read `Count` == 0. + advance_ledgers(&f.env, 61 * ttl::DAY_SECS); + let count_key = DataKey::CountV2(f.market_id, AttestedOutcome::No); + assert!( + persistent_ttl(&f.env, &f.resolver_id, &count_key) > 0, + "vote tally archived during the dispute period" + ); + + f.resolver + .attest(&f.market_id, &f.signer_b, &AttestedOutcome::No); + let count: Option = f.env.as_contract(&f.resolver_id, || { + f.env.storage().persistent().get(&count_key) + }); + assert_eq!(count, Some(2), "tally silently reset after archival"); + + f.env + .ledger() + .set_timestamp(f.env.ledger().timestamp() + 200); + assert_eq!( + f.resolver.finalize_signers(&f.market_id), + AttestedOutcome::No + ); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::Resolved(Outcome::No) + ); + } + + #[test] + fn instance_survives_after_long_idle() { + let f = fixture(|env, _r, a, b| ResolutionSpec::Signers(signer_spec(env, a, b))); + f.env.ledger().set_max_entry_ttl(3_110_400); + f.env.ledger().set_min_persistent_entry_ttl(4_096); + let ResolutionSpec::Signers(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_signer_spec(&f.market_id, &spec); + assert_eq!(instance_ttl(&f.env, &f.resolver_id), ttl::INSTANCE_TARGET); + + // 25 days idle drains the instance TTL below the 7-day threshold... + advance_ledgers(&f.env, 25 * ttl::DAY_SECS); + assert!(instance_ttl(&f.env, &f.resolver_id) < ttl::INSTANCE_THRESHOLD); + + // ...and the next entrypoint call re-extends it. + f.resolver + .attest(&f.market_id, &f.signer_a, &AttestedOutcome::No); + assert_eq!(instance_ttl(&f.env, &f.resolver_id), ttl::INSTANCE_TARGET); + } + + #[test] + fn bump_market_entries_skips_missing_keys() { + let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); + f.env.ledger().set_max_entry_ttl(3_110_400); + f.env.ledger().set_min_persistent_entry_ttl(4_096); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_price_spec(&f.market_id, &spec); + + // Price and State exist; Signers, Proposal and both Counts do not. + // The sweep must skip absent keys — `extend_ttl` traps on them. + f.env.as_contract(&f.resolver_id, || { + Resolver::bump_market_entries(&f.env, f.market_id, 2_000); + }); + + let horizon_ledgers = (ttl::SETTLEMENT_HORIZON_SECS / ttl::LEDGER_SECS) as u32; + assert!( + persistent_ttl(&f.env, &f.resolver_id, &DataKey::Price(f.market_id)) >= horizon_ledgers + ); + assert!( + persistent_ttl(&f.env, &f.resolver_id, &DataKey::State(f.market_id)) >= horizon_ledgers + ); + assert!(!f.env.as_contract(&f.resolver_id, || f + .env + .storage() + .persistent() + .has(&DataKey::Signers(f.market_id)))); + } + // ----- Invalid attestations (committee voids) ------------------------- fn attest_both(f: &Fixture, outcome: AttestedOutcome) { @@ -5294,18 +5476,30 @@ mod shared_types { assert_eq!(shared.to_xdr(&env), imported.to_xdr(&env)); } for (shared, imported) in [ - (oracledesk_types::Category::Crypto, market_core::Category::Crypto), - (oracledesk_types::Category::Macro, market_core::Category::Macro), + ( + oracledesk_types::Category::Crypto, + market_core::Category::Crypto, + ), + ( + oracledesk_types::Category::Macro, + market_core::Category::Macro, + ), ( oracledesk_types::Category::Geopolitics, market_core::Category::Geopolitics, ), - (oracledesk_types::Category::Sports, market_core::Category::Sports), + ( + oracledesk_types::Category::Sports, + market_core::Category::Sports, + ), ( oracledesk_types::Category::Culture, market_core::Category::Culture, ), - (oracledesk_types::Category::Other, market_core::Category::Other), + ( + oracledesk_types::Category::Other, + market_core::Category::Other, + ), ] { assert_eq!(shared.to_xdr(&env), imported.to_xdr(&env)); } @@ -5397,7 +5591,10 @@ mod golden { let env = Env::default(); let declared = hex_of(&env, &signers(&env, &[G1, G2, G3], 2, 3_600)); let canonical = hex_of(&env, &signers(&env, &[G3, G1, G2], 2, 3_600)); - assert_eq!(declared, "4373448f97b0dd998a70efd9a1bfd3b3b8639bebaf682d9fc083c11f248a99ee"); + assert_eq!( + declared, + "4373448f97b0dd998a70efd9a1bfd3b3b8639bebaf682d9fc083c11f248a99ee" + ); assert_ne!(declared, canonical); } diff --git a/oracledesk-stellar/contracts/resolver/src/ttl.rs b/oracledesk-stellar/contracts/resolver/src/ttl.rs new file mode 100644 index 0000000..0debebd --- /dev/null +++ b/oracledesk-stellar/contracts/resolver/src/ttl.rs @@ -0,0 +1,107 @@ +//! Close-time-aware storage TTL policy. +//! +//! Soroban archives a ledger entry once its TTL (in ledgers) runs out. An +//! archived persistent entry cannot be read or written until someone submits +//! a restore operation, and an archived **instance** entry makes the whole +//! contract uncallable until restored. Every contract in this workspace +//! therefore has to keep its entries alive at least as long as they can be +//! needed; this module is that policy. +//! +//! It is duplicated in all four contract crates because a contract crate +//! cannot depend on another contract crate (their `#[contractimpl]` wasm +//! exports collide on `__constructor`; see +//! `docs/adr/0002-cross-contract-calls.md`), and the copies must stay +//! byte-identical below this header. +//! +//! Policy +//! - A market's entries are kept until `close_time + SETTLEMENT_HORIZON`. +//! Under a fixed 60-day bump a market closing more than 60 days out could +//! archive before it closed, so the resolver's `get_market` cross-call +//! failed until someone restored it. +//! - Every target is clamped to `env.storage().max_ttl()` — never hardcode +//! network limits. Mainnet and testnet are both at 3_110_400 ledgers +//! (180 days) today, so a market further than 90 days from close is capped +//! at the network maximum and needs activity or the keeper +//! (`docs/wave-issues/14-storage-ttl-keeper.md`) to outlive one bump. +//! - Persistent entries are only re-extended once their remaining TTL has +//! dropped below half the target (`threshold_for`), so a busy hot path +//! does not pay an extension write on every call. +//! +//! Rent: an extension rents `ceil(bytes / 1024) * ledgers * +//! rent_fee_per_1kb(live_state_size) / persistent_rent_rate_denominator` +//! (CAP-0066; denominator 1215, per-1KB rate interpolates from a 1_000 +//! stroop floor to 10_000 as live Soroban state grows toward its target). +//! One 1-KB entry costs roughly 0.04-0.43 XLM per 30 days, so covering one +//! entry for the whole 90-day horizon costs roughly 0.13-1.28 XLM — cheap +//! next to a market that archives before it can be resolved, which blocks +//! resolution until a restore operation (also rent) is submitted. + +use soroban_sdk::Env; + +/// Stellar's target ledger close time. `DAY_IN_LEDGERS` assumes 5 s; if the +/// network ever re-targets close time, both values change together. +pub const LEDGER_SECS: u64 = 5; +pub const DAY_SECS: u64 = 86_400; +pub const DAY_IN_LEDGERS: u32 = (DAY_SECS / LEDGER_SECS) as u32; + +/// Instance storage is bumped on every entrypoint: extend once fewer than +/// 7 days remain, out to 30. A month of cover keeps a contract invocable +/// through normal lulls while bounding the rent a single call can pay. +pub const INSTANCE_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; +pub const INSTANCE_TARGET: u32 = 30 * DAY_IN_LEDGERS; + +/// Time a market may need after `close_time`: resolution, a signer dispute +/// window, and holder redemptions. +pub const SETTLEMENT_HORIZON_SECS: u64 = 90 * DAY_SECS; + +/// Target TTL for long-lived entries with no market association (registered +/// agents), so a routinely-used entry is never at risk; clamped by +/// `max_ttl()` in practice to the network maximum. +pub const LONG_LIVED_SECS: u64 = 365 * DAY_SECS; + +/// Ledgers from now until `deadline_ts`, clamped to the network maximum. +pub fn secs_to_ledgers_clamped(env: &Env, secs: u64) -> u32 { + let ledgers = secs / LEDGER_SECS; + core::cmp::min(ledgers, env.storage().max_ttl() as u64) as u32 +} + +/// How long a market's storage must live: until `close_time` plus the +/// settlement horizon, clamped to `max_ttl()`. +pub fn market_target(env: &Env, close_time: u64) -> u32 { + let now = env.ledger().timestamp(); + let until_close = close_time.saturating_sub(now); + secs_to_ledgers_clamped(env, until_close.saturating_add(SETTLEMENT_HORIZON_SECS)) +} + +/// Target TTL for entries with no market association. See `LONG_LIVED_SECS`. +pub fn long_lived_target(env: &Env) -> u32 { + secs_to_ledgers_clamped(env, LONG_LIVED_SECS) +} + +/// Persistent entries are only re-extended when their remaining TTL has +/// fallen below half the target, which keeps touching an entry cheap. +pub fn threshold_for(target: u32) -> u32 { + target / 2 +} + +/// Extends `key` toward `target` ledgers from now, if it exists and its +/// remaining TTL is under `threshold_for(target)`. Extending a missing key +/// panics, so callers must guard with `has()` where the key may be absent. +pub fn bump_persistent(env: &Env, key: &K, target: u32) +where + K: soroban_sdk::IntoVal, +{ + env.storage() + .persistent() + .extend_ttl(key, threshold_for(target), target); +} + +/// Extends instance storage to `INSTANCE_TARGET` when fewer than +/// `INSTANCE_THRESHOLD` ledgers remain. Called from every entrypoint that +/// reads instance storage — an archived instance entry bricks the contract +/// until restored, so this is the cheapest insurance the contract can buy. +pub fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_THRESHOLD, INSTANCE_TARGET); +} diff --git a/oracledesk-stellar/contracts/treasury/src/lib.rs b/oracledesk-stellar/contracts/treasury/src/lib.rs index e4c475d..5fcb763 100644 --- a/oracledesk-stellar/contracts/treasury/src/lib.rs +++ b/oracledesk-stellar/contracts/treasury/src/lib.rs @@ -17,7 +17,8 @@ mod market_core { soroban_sdk::contractimport!(file = "market_core.wasm"); } use market_core::{ - Category, Client as MarketCoreClient, Error as MarketCoreError, MarketStatus, Outcome, + BatchResult, Category, Client as MarketCoreClient, Error as MarketCoreError, MarketStatus, + Outcome, }; // The resolver's wasm supplies the `register_spec_v2` client and the @@ -116,7 +117,10 @@ fn map_market_core_code(error: soroban_sdk::Error) -> Error { /// name-dispatched trade calls); the panicking generated methods are never /// used. fn core_call( - result: Result, Result>, + result: Result< + Result, + Result, + >, ) -> Result { match result { Ok(Ok(value)) => Ok(value), @@ -1769,16 +1773,18 @@ impl Treasury { let treasury_addr = env.current_contract_address(); Self::authorize_token_pull(&env, &global, &treasury_addr, seed_amount); - let market_id = core_call(MarketCoreClient::new(&env, &global.market_core).try_create_market( - &treasury_addr, - &question_hash, - &resolution_hash, - &meta_uri, - &category, - &close_time, - &seed_amount, - &initial_yes_bps, - ))?; + let market_id = core_call( + MarketCoreClient::new(&env, &global.market_core).try_create_market( + &treasury_addr, + &question_hash, + &resolution_hash, + &meta_uri, + &category, + &close_time, + &seed_amount, + &initial_yes_bps, + ), + )?; Self::reserve_capacity(&env, &global, &agent, &policy, market_id, seed_amount)?; Self::reserve_net(&env, &global, market_id, seed_amount)?; Self::record_cost(&env, market_id, seed_amount)?; @@ -1983,6 +1989,42 @@ impl Treasury { Self::settle_claim(&env, &core, market_id) } + /// Redeems the treasury's positions in up to `MAX_BATCH` markets in one + /// call (market-core issue #15), delegating to market-core's `redeem_many`. + /// `market_ids` must be strictly ascending; markets that cannot be paid out + /// right now are reported as skipped rather than aborting the batch. The + /// single aggregated token transfer at the end amortizes fixed per-call + /// costs, keeping the treasury inside the per-transaction resource limits + /// even when it holds positions in many markets. (`ROLE_SETTLE`.) + pub fn redeem_many( + env: Env, + agent: Address, + market_ids: Vec, + ) -> Result { + let global = Self::load_global(&env)?; + Self::require_agent(&env, &agent, ROLE_SETTLE)?; + let treasury_addr = env.current_contract_address(); + Ok(MarketCoreClient::new(&env, &global.market_core) + .redeem_many(&treasury_addr, &market_ids)) + } + + /// Claims the LP pool remainders of up to `MAX_BATCH` treasury-created + /// markets in one call, delegating to market-core's `claim_pool_remainders` + /// (skips, never aborts on, markets the treasury did not create, markets + /// not settled, or markets already claimed — see `redeem_many` for the + /// batching rationale). (`ROLE_SETTLE`.) + pub fn claim_pool_remainders( + env: Env, + agent: Address, + market_ids: Vec, + ) -> Result { + let global = Self::load_global(&env)?; + Self::require_agent(&env, &agent, ROLE_SETTLE)?; + let treasury_addr = env.current_contract_address(); + Ok(MarketCoreClient::new(&env, &global.market_core) + .claim_pool_remainders(&treasury_addr, &market_ids)) + } + /// Claims the market's LP fee share for a market the treasury created /// (`ROLE_SETTLE`). The LP portion accrues separately on market-core /// (see its `claim_lp_fees`). diff --git a/oracledesk-stellar/contracts/treasury/src/ttl.rs b/oracledesk-stellar/contracts/treasury/src/ttl.rs new file mode 100644 index 0000000..0debebd --- /dev/null +++ b/oracledesk-stellar/contracts/treasury/src/ttl.rs @@ -0,0 +1,107 @@ +//! Close-time-aware storage TTL policy. +//! +//! Soroban archives a ledger entry once its TTL (in ledgers) runs out. An +//! archived persistent entry cannot be read or written until someone submits +//! a restore operation, and an archived **instance** entry makes the whole +//! contract uncallable until restored. Every contract in this workspace +//! therefore has to keep its entries alive at least as long as they can be +//! needed; this module is that policy. +//! +//! It is duplicated in all four contract crates because a contract crate +//! cannot depend on another contract crate (their `#[contractimpl]` wasm +//! exports collide on `__constructor`; see +//! `docs/adr/0002-cross-contract-calls.md`), and the copies must stay +//! byte-identical below this header. +//! +//! Policy +//! - A market's entries are kept until `close_time + SETTLEMENT_HORIZON`. +//! Under a fixed 60-day bump a market closing more than 60 days out could +//! archive before it closed, so the resolver's `get_market` cross-call +//! failed until someone restored it. +//! - Every target is clamped to `env.storage().max_ttl()` — never hardcode +//! network limits. Mainnet and testnet are both at 3_110_400 ledgers +//! (180 days) today, so a market further than 90 days from close is capped +//! at the network maximum and needs activity or the keeper +//! (`docs/wave-issues/14-storage-ttl-keeper.md`) to outlive one bump. +//! - Persistent entries are only re-extended once their remaining TTL has +//! dropped below half the target (`threshold_for`), so a busy hot path +//! does not pay an extension write on every call. +//! +//! Rent: an extension rents `ceil(bytes / 1024) * ledgers * +//! rent_fee_per_1kb(live_state_size) / persistent_rent_rate_denominator` +//! (CAP-0066; denominator 1215, per-1KB rate interpolates from a 1_000 +//! stroop floor to 10_000 as live Soroban state grows toward its target). +//! One 1-KB entry costs roughly 0.04-0.43 XLM per 30 days, so covering one +//! entry for the whole 90-day horizon costs roughly 0.13-1.28 XLM — cheap +//! next to a market that archives before it can be resolved, which blocks +//! resolution until a restore operation (also rent) is submitted. + +use soroban_sdk::Env; + +/// Stellar's target ledger close time. `DAY_IN_LEDGERS` assumes 5 s; if the +/// network ever re-targets close time, both values change together. +pub const LEDGER_SECS: u64 = 5; +pub const DAY_SECS: u64 = 86_400; +pub const DAY_IN_LEDGERS: u32 = (DAY_SECS / LEDGER_SECS) as u32; + +/// Instance storage is bumped on every entrypoint: extend once fewer than +/// 7 days remain, out to 30. A month of cover keeps a contract invocable +/// through normal lulls while bounding the rent a single call can pay. +pub const INSTANCE_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; +pub const INSTANCE_TARGET: u32 = 30 * DAY_IN_LEDGERS; + +/// Time a market may need after `close_time`: resolution, a signer dispute +/// window, and holder redemptions. +pub const SETTLEMENT_HORIZON_SECS: u64 = 90 * DAY_SECS; + +/// Target TTL for long-lived entries with no market association (registered +/// agents), so a routinely-used entry is never at risk; clamped by +/// `max_ttl()` in practice to the network maximum. +pub const LONG_LIVED_SECS: u64 = 365 * DAY_SECS; + +/// Ledgers from now until `deadline_ts`, clamped to the network maximum. +pub fn secs_to_ledgers_clamped(env: &Env, secs: u64) -> u32 { + let ledgers = secs / LEDGER_SECS; + core::cmp::min(ledgers, env.storage().max_ttl() as u64) as u32 +} + +/// How long a market's storage must live: until `close_time` plus the +/// settlement horizon, clamped to `max_ttl()`. +pub fn market_target(env: &Env, close_time: u64) -> u32 { + let now = env.ledger().timestamp(); + let until_close = close_time.saturating_sub(now); + secs_to_ledgers_clamped(env, until_close.saturating_add(SETTLEMENT_HORIZON_SECS)) +} + +/// Target TTL for entries with no market association. See `LONG_LIVED_SECS`. +pub fn long_lived_target(env: &Env) -> u32 { + secs_to_ledgers_clamped(env, LONG_LIVED_SECS) +} + +/// Persistent entries are only re-extended when their remaining TTL has +/// fallen below half the target, which keeps touching an entry cheap. +pub fn threshold_for(target: u32) -> u32 { + target / 2 +} + +/// Extends `key` toward `target` ledgers from now, if it exists and its +/// remaining TTL is under `threshold_for(target)`. Extending a missing key +/// panics, so callers must guard with `has()` where the key may be absent. +pub fn bump_persistent(env: &Env, key: &K, target: u32) +where + K: soroban_sdk::IntoVal, +{ + env.storage() + .persistent() + .extend_ttl(key, threshold_for(target), target); +} + +/// Extends instance storage to `INSTANCE_TARGET` when fewer than +/// `INSTANCE_THRESHOLD` ledgers remain. Called from every entrypoint that +/// reads instance storage — an archived instance entry bricks the contract +/// until restored, so this is the cheapest insurance the contract can buy. +pub fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_THRESHOLD, INSTANCE_TARGET); +} diff --git a/oracledesk-stellar/docs/STATUS.md b/oracledesk-stellar/docs/STATUS.md index cbe6c5b..e213efa 100644 --- a/oracledesk-stellar/docs/STATUS.md +++ b/oracledesk-stellar/docs/STATUS.md @@ -378,6 +378,32 @@ markers in the files themselves: risk, not systemic market behavior. `agents/sim` now quantifies this (see `docs/simulation.md`, experiment B): with k identical deployments, capital at risk grows linearly in k while total profit does not. +- **Unredeemed shares expire after the redemption window.** Each market + snapshots a redemption horizon at creation (default 365 days; the admin + default is bounded to 180–1825 days). Once `finalized_at + horizon_secs` + has passed, anyone can call `finalize_market`, which sweeps the market's + **entire** remaining balance — expired user claims, void rounding dust, + any unclaimed LP remainder and accrued fees — to the treasury and closes + redemption (`redeem`/`claim_pool_remainder` then revert with + `RedemptionClosed`). Redeeming before the deadline always pays the exact + `settle_value`; expired claims go to the treasury, not the LP. Markets + created before this `SettleState` bookkeeping (issue #14) have no deadline + and cannot be finalized (`NotSupported`), and stay redeemable through the + old paths. A market waiting on a multi-year deadline can be + TTL-archived before it is finalized and needs a restore (or the keeper, + `docs/wave-issues/14-storage-ttl-keeper.md`) first; the deadline itself is + on-chain state, so a late finalization is still exact. +- **The TWAP ring keeps only the most recent 16 observations.** Each + observation is written per trade timestamp, so 16 distinct-timestamp trades + supersede the creation point: `observe`/`twap` windows that reach further + back than the surviving ring revert with `ObservationTooOld` rather than + returning a value. Windows that extend past the newest observation keep + averaging the last traded price (the accumulator extends + counterfactually), including past `close_time` until a resolver action. + `twap` divides with floor rounding; off-chain mirroring code must floor + identically. Markets created before the accumulator (issue #11) return + `NotSupported` from `observe`/`twap`/`price_cumulative`, and an archived + `Oracle`/`Obs` entry needs a restore like any other entry. - **Expiry cannot help an oracle the creator controls.** `expire_market` voids unresolvable markets 30 days after close, but a creator who commits to a feed they control can still decide the outcome of a *resolvable* diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index 1f1110a..a29ed33 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -98,7 +98,44 @@ 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`. +`transfer_lp`. `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 +timestamp, a per-market redemption horizon snapshotted at creation (default +365 days; admin-set default bounded to 180–1825 days), what has already been +paid out, and whether the market is closed. `redeem` and +`claim_pool_remainder` maintain it on every payout. Once +`finalized_at + horizon_secs` has passed, anyone can call `finalize_market`: +it reserves exactly what the outstanding shares can still claim +(`settle_upper` — for void markets the aggregate floor, which is at least +the sum of the per-holder floors) plus any unclaimed LP remainder, then +sweeps the market's **entire** remaining balance — expired user claims, +void rounding dust, the unclaimed remainder and accrued fees — to the +treasury, marks the market `RedemptionClosed`, and zeroes the market's +balance. A holder who redeems before the deadline is paid the exact +`settle_value` they would have received without finalization; expired claims +go to the treasury rather than the LP, so the LP cannot profit from +suppressing redemptions. Markets created before this bookkeeping existed +have no `Settle` entry: they have no deadline and cannot be finalized +(`NotSupported`), and stay redeemable through the old paths. + +A per-market **TWAP accumulator** records a time-weighted price. Every trade +(`buy`/`sell`) folds the market's pre-trade price into a cumulative +`price × time` value before reserves move (Uniswap-v2 semantics), with state +in `Oracle(id)` and a 16-slot ring of `Obs(id, k)` observations so the +`Market` entry's XDR is unchanged. `observe(id, seconds_ago)`, +`twap(id, window_secs)` and `price_cumulative(id)` read it back: between two +bracketing observations the cumulative is interpolated (exact — the +cumulative is piecewise-linear between trades, since no price moves inside +an interval), past the newest observation the last price +is extended counterfactually, and a time earlier than the oldest surviving +observation fails with `ObservationTooOld`. Same-timestamp trades only +refresh the spot price — nothing is accumulated within one ledger, so a +manipulator cannot move the TWAP without holding the price across a ledger +boundary. Markets created before the accumulator existed return +`NotSupported` from these views. ### resolver @@ -660,6 +697,38 @@ events. An indexer (Soroban events → Postgres, replayable) is required for any UI that wants to list traces/trades older than that window — this is one of the seeded `docs/wave-issues/`. +## Storage TTL policy + +Soroban archives persistent/instance entries once their TTL runs out, and an +archived entry cannot be read or written until it is restored. Every contract +carries an identical `src/ttl.rs` (duplicated because contract crates cannot +depend on each other — [ADR 0002](adr/0002-cross-contract-calls.md)) with the +shared policy: + +- Per-market entries (`Market`, `Position`, resolver configs, treasury + exposure) are extended to `close_time` plus a 90-day settlement horizon, + clamped to the network's `max_ttl()`. The threshold is half the target, so + a frequently touched entry is only rewritten when its remaining TTL halves. +- Instance TTL is extended by every entrypoint that reads instance storage + (30-day target, 7-day threshold); pure views over persistent entries stay + read-only so indexer simulations keep read-only footprints. +- The registry additionally refreshes an agent's registration on every + successful `publish_trace`. +- The market-core `Settle` entry (outstanding shares, redemption deadline) is + kept to the long-lived target — 365 days, clamped to `max_ttl()`. A market + whose redemption deadline is more than one network-max TTL away can archive + before it is finalized and needs touches or a restore first; + `finalize_market` works after a restore, and the deadline is computed from + on-chain state (`finalized_at + horizon_secs`), so a late finalization is + still exact. +- The market-core `Oracle` entry and every written `Obs` slot are extended to + the same close-time-aware target as the market's other entries. Unwritten + ring slots are never extended and the read path skips missing slots, so a + half-used ring costs nothing and never fails a read. +- Markets whose `close_time` is further out than `max_ttl` cannot be kept + alive by a single bump and need periodic touches or the keeper described in + [`docs/wave-issues/14-storage-ttl-keeper.md`](wave-issues/14-storage-ttl-keeper.md), + which also covers TTL monitoring and the rent cost model. ### router A stateless address book (`RouterConfig { market_core, resolver, diff --git a/oracledesk-stellar/docs/frontend-integration.md b/oracledesk-stellar/docs/frontend-integration.md index d4ee674..13b747b 100644 --- a/oracledesk-stellar/docs/frontend-integration.md +++ b/oracledesk-stellar/docs/frontend-integration.md @@ -152,6 +152,106 @@ no on-chain sqrt (see the docstring on `sell` in `model/fpmm_model.py` — and the pool may have moved between your read and your transaction). +## Batch redemption (`redeem_many` / `claim_pool_remainders`) + +Settling markets one by one burns a fixed per-call cost (footprint entries, +event bytes) and quickly runs into the per-transaction resource limits. The +batch entry points settle many markets in a single call, paying the total in +one transfer: + +- `redeem_many(holder, market_ids)` — redeems one holder's positions across + markets that have resolved or voided. +- `claim_pool_remainders(creator, market_ids)` — claims the LP remainders of + markets the caller created. + +Rules to build callers around: + +1. **Ascending, deduplicated, non-empty ids.** `market_ids` must be strictly + ascending — that keeps the footprint deterministic and duplicates + impossible. Anything else reverts with `InvalidAmount`; an empty list or + one longer than `max_batch()` reverts with `BatchTooLarge`. +2. **Chunk to `max_batch()`, read from the contract.** Never hardcode the + cap: `const { result: cap } = await contract.max_batch();`. Sort ids + ascending, then slice into chunks of at most `cap`. The committed + measurement table behind the cap is in the `MAX_BATCH` docstring in + `contracts/market-core/src/lib.rs`. +3. **Skips are per-market, not per-call.** A market that cannot pay out + right now (not found, not final, nothing to redeem, already claimed or + claimed by someone else, not the creator) comes back as + `{ status: { tag: "Skipped", … }, payout: 0 }` instead of aborting the + batch — one bad id never strands the rest. Only input validation + (rule 1) aborts the whole call. +4. **Simulate before signing.** The footprint scales with the chunk size; + `AssembledTransaction`'s simulate step (run by `signAndSend`, or + explicitly) surfaces contract errors and resource problems before + anything is submitted. +5. **Archived entries must be restored first.** If a market, position or + settlement entry has expired out of the live state, its restore must + happen in a *separate* transaction (e.g. `stellar tx restore-footprint`): + restores included in the batch transaction consume the 200-entry + disk-read budget and can push a full-size chunk over the limit. Run the + batch chunk against live state. +6. **One transfer per call.** The chunk's payouts arrive in a single token + transfer, so the balance delta equals `result.total` exactly — reconcile + on that, and use `result.items[i]` for per-market receipts. + +```ts +const { result: cap } = await contract.max_batch(); // u32, from the contract +const sorted = [...ids].sort((a, b) => Number(a - b)); // strictly ascending + +for (let i = 0; i < sorted.length; i += cap) { + const chunk = sorted.slice(i, i + cap); + const tx = await contract.redeem_many({ holder, market_ids: chunk }); + const { result } = await tx.signAndSend({ signTransaction }); + + const retry = result.items + .filter((it) => it.status.tag === "Skipped") + .map((it) => [it.market_id, it.status.values]); // e.g. drop AlreadyClaimed +} +``` + +The treasury contract mirrors both entry points (`redeem_many` / +`claim_pool_remainders`, same `BatchResult` shape) for positions it holds +itself; agents call those instead of market-core directly, and the +`max_batch()` on market-core still applies. + +## Reading TWAP prices + +`get_price(market_id)` is the instantaneous spot price; it is trivially +movable by a single trade. For anything automated (bots, alerts, fair-value +displays) prefer the accumulated oracle views: + +```ts +const { result } = await contract.twap({ market_id: 0n, window_secs: 900n }); +// price in bps of YES, time-weighted over the trailing 15 minutes +``` + +- `twap(market_id, window_secs)` — time-weighted average price in bps over + the trailing `window_secs`, floor-rounded. +- `observe(market_id, seconds_ago)` — the cumulative `price × seconds` + value at a past timestamp; difference two of them and divide for a + custom window. +- `price_cumulative(market_id)` — the same value at "now", for streaming + consumers that just want the accumulator. + +Caveats to design around: + +- Observations are written once per trade *timestamp*, so at most the most + recent 16 distinct-timestamp trades are covered; a `seconds_ago` (or + `window_secs` span) that reaches past them reverts with + `ObservationTooOld`. For long windows on busy markets, poll + `price_cumulative`/`observe` regularly and difference snapshots client-side, + or mirror the ring with `agents/core/strategy.ts`'s + `twapFromObservations`. +- Past the newest observation the last traded price is extended + counterfactually (this includes after `close_time`, until resolution), so + a quiet market's TWAP converges to its last price rather than failing. +- Interpolation between two observations is exact — price is piecewise + constant between trades — but `twap` floors, so off-chain comparisons + should floor identically. +- Markets created before the accumulator existed revert `NotSupported`; + fall back to `get_price` for them. + ## Moving shares between wallets `transfer_position(from, to, market_id, outcome, amount)` sends outcome diff --git a/oracledesk-stellar/model/fpmm_model.py b/oracledesk-stellar/model/fpmm_model.py index 4b9c9f1..8ec1882 100644 --- a/oracledesk-stellar/model/fpmm_model.py +++ b/oracledesk-stellar/model/fpmm_model.py @@ -143,6 +143,15 @@ def settle_value(kind: str, yes: int, no: int) -> int: raise ValueError(f"unknown settlement kind {kind!r}") +def settle_upper(kind: str, yes: int, no: int) -> int: + """Mirror of `math::settle_upper`: an upper bound on the total + `settle_value` pays out for a set of per-holder (yes, no) claims. Exact + for yes/no; for void, per-holder floors sum to at most the floor of the + summed halves, so `finalize_market` reserving the aggregate never + under-reserves (see `_check_void_sum_of_floors`).""" + return settle_value(kind, yes, no) + + def gross_for_shares(r_sold: int, r_other: int, shares_in: int, fee_bps: int) -> int: """Off-chain inverse of calc_sell: how many complete sets (`gross`) the pool must burn so that selling exactly `shares_in` shares balances the @@ -306,6 +315,24 @@ def _check_void_never_overpays(): assert settle_value("void", 5, 5) <= 5 +def _check_void_sum_of_floors(trials: int = 2_000, seed: int = 13): + """A void market's finalize must reserve at least what remaining holders + can still redeem. Each holder's claim floors its own half, so the sum of + per-holder floors is at most the aggregate floor `settle_upper`: + floor(a/2) + floor(b/2) <= floor((a + b)/2) for all a, b >= 0.""" + rng = random.Random(seed) + for _ in range(trials): + a = rng.randint(0, 1_000_000_000) + b = rng.randint(0, 1_000_000_000) + part = settle_value("void", a, 0) + settle_value("void", 0, b) + assert part <= settle_upper("void", a, b), (a, b, part) + assert settle_upper("yes", a, b) == settle_value("yes", a, b) + assert settle_upper("no", a, b) == settle_value("no", a, b) + # Boundary shapes around odd sums. + assert settle_value("void", 1, 0) + settle_value("void", 0, 1) <= 1 + assert settle_value("void", 1, 1) + settle_value("void", 0, 1) <= 1 + + def _check_price_after_maps_both_sides(): """Mirrors math.rs's price_after tests: outcome_is_yes selects which of the two post-trade reserves is the YES reserve.""" @@ -386,6 +413,7 @@ def run_all(): _check_k_never_decreases_and_round_trip_never_profits() _check_gross_for_shares_inverts_calc_sell() _check_void_never_overpays() + _check_void_sum_of_floors() _check_price_after_maps_both_sides() _check_limits_boundary_and_symmetry() print("fpmm_model: all property checks passed")