diff --git a/oracledesk-stellar/agents/bin/trader.test.ts b/oracledesk-stellar/agents/bin/trader.test.ts index 6ba6c3f..28f78ee 100644 --- a/oracledesk-stellar/agents/bin/trader.test.ts +++ b/oracledesk-stellar/agents/bin/trader.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp } from "node:fs/promises"; +import { mkdtemp, readFile, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -54,3 +54,50 @@ test("trader honours SLIPPAGE_BPS and DEADLINE_SECS overrides", async () => { assert.equal(args.min_shares_out, (QUOTE_SHARES * 9_900n) / 10_000n); assert.equal(args.deadline, 2_000_030n); }); + +test("live mode reads the market and its effective fee from the chain", async () => { + const traceDir = await mkdtemp(join(tmpdir(), "trader-test-")); + const env = { TRACE_STORE_DIR: traceDir, MARKET_ID: "7" }; + const reads: Array<[string, bigint]> = []; + const adapter = { + mode: "live" as const, + quoteBuy: async () => QUOTE_SHARES, + agentBuyV2: async (args: Record) => ({ + dryRun: true as const, + contractId: "CTREASURY", + method: "agent_buy_v2", + args, + }), + getMarketView: async (args: { market_id: bigint }) => { + reads.push(["get_market", args.market_id]); + return { + close_time: 2_000_000n, + fee_bps: 100, // the flat snapshot; the schedule may charge more + reserve_yes: 1_000_000_000_000_000n, + reserve_no: 1_000_000_000_000_000n, + }; + }, + currentFeeBps: async (args: { market_id: bigint }) => { + reads.push(["current_fee_bps", args.market_id]); + return 321; // the dynamic effective fee at the current ledger time + }, + } as unknown as StellarAdapter; + + await main(adapter, env, () => 1_000_000); + + assert.deepEqual(reads, [ + ["get_market", 7n], + ["current_fee_bps", 7n], + ]); + + // The trace records the live market state, including the effective fee + // (not the flat snapshot) and the reserves the quote was sized against. + const traces = await readdir(traceDir); + assert.equal(traces.length, 1); + const trace = JSON.parse(await readFile(join(traceDir, traces[0]), "utf8")) as { + inputs: { market: { feeBps: number; yesBps: number; reserveYesStroops: string } }; + }; + assert.equal(trace.inputs.market.feeBps, 321); + assert.equal(trace.inputs.market.yesBps, 5000); + assert.equal(trace.inputs.market.reserveYesStroops, "1000000000000000"); +}); diff --git a/oracledesk-stellar/agents/bin/trader.ts b/oracledesk-stellar/agents/bin/trader.ts index ea4361b..8203cb2 100644 --- a/oracledesk-stellar/agents/bin/trader.ts +++ b/oracledesk-stellar/agents/bin/trader.ts @@ -1,6 +1,6 @@ #!/usr/bin/env -S npx tsx /** - * Trader agent, one shot: reads chain state, compares implied price to a + * Trader agent: reads a market's current implied price, compares it to a * fair-value estimate, and trades the mispricing if the edge clears the * configured threshold. Every trade is preceded by a recorded trace. * @@ -10,171 +10,150 @@ * backstop, plus a `deadline` — so a stale or sandwiched transaction * reverts on-chain instead of executing at an arbitrary price. * - * A thin wrapper over the runtime's trader task (runtime/tasks/trader.ts), - * run exactly once under the single-instance lease; `npm run agentd` runs - * the same task continuously. Defaults to dry-run. Set AGENT_MODE=live and - * TRADER_SECRET_KEY (or the shared AGENT_SECRET_KEY) to submit real testnet - * transactions — see .env.example. + * Defaults to dry-run. Set AGENT_MODE=live and TRADER_SECRET_KEY (or the + * shared AGENT_SECRET_KEY) to submit real testnet transactions — see + * .env.example. */ import "dotenv/config"; import { pathToFileURL } from "node:url"; -import { EXIT_CONFIG, EXIT_TEMPFAIL, parseAgentMode } from "../runtime/config.js"; -import { LeaseHeldError } from "../runtime/lease.js"; -import { Runtime } from "../runtime/runtime.js"; -import { traderTask } from "../runtime/tasks/trader.js"; -import { buildAdapter, createLease, createStateStore, loadDeployments, runtimeOptions, traderDeps } from "../runtime/wiring.js"; -import type { StellarAdapter } from "../stellar/adapter.js"; - -/** - * Exported (and injectable) so tests can drive the whole trade flow with a - * fake adapter and clock. `now` returns unix seconds. An adapter bound to a - * deployment reads markets from chain state; one without (a test fake) - * trades the demo fixture market. -import { buildTrace, type TraceStore } from "../core/trace.js"; -import { createTraceStore } from "../core/trace-store-factory.js"; -import { BPS } from "../core/fpmm.js"; +import { buildTrace, LocalTraceStore } from "../core/trace.js"; +import { BPS, priceYesBps } from "../core/fpmm.js"; import { decideTrade, type MarketState } from "../core/strategy.js"; import { agentCredentials, loadDeployments, StellarAdapter } from "../stellar/adapter.js"; -import { ChainStateReader } from "../core/state/reader.js"; -import { SnapshotCache } from "../core/state/cache.js"; -import { tradableMarkets, availableCapital } from "../core/state/select.js"; -import { toMarketState } from "../core/state/types.js"; -import { Server } from "@stellar/stellar-sdk/rpc"; + +/** + * Reads the market to trade and the fair value to compare it against. + * + * In live mode the market is a real chain read (`get_market` plus + * `current_fee_bps`) for `MARKET_ID`. The effective fee — not the flat + * `Market.fee_bps` snapshot, which ignores dynamic fee schedules — is fed + * into the strategy's market state so every off-chain quote and impact + * estimate uses the number the contract will actually charge. + * + * The fair-value estimate stays a placeholder: wiring a real + * ReferencePriceSource (and the question lookup it needs) is an open issue — + * see docs/wave-issues. + */ +async function fetchMarketAndFairValue( + adapter: StellarAdapter, + env: NodeJS.ProcessEnv, + now: () => number, +): Promise<{ market: MarketState; fairValueBps: number }> { + if (adapter.mode === "live") { + const marketId = BigInt(env.MARKET_ID ?? "0"); + const m = await adapter.getMarketView({ market_id: marketId }); + const feeBps = await adapter.currentFeeBps({ market_id: marketId }); + return { + market: { + marketId: Number(marketId), + question: "live market", + yesBps: Number(priceYesBps(m.reserve_yes, m.reserve_no)), + closeTime: Number(m.close_time), + reserveYesStroops: m.reserve_yes, + reserveNoStroops: m.reserve_no, + feeBps, + }, + fairValueBps: 6000, + }; + } + return { + market: { marketId: 0, question: "demo", yesBps: 4000, closeTime: now() + 3600 }, + fairValueBps: 6000, + }; +} /** * Exported (and injectable) so tests can drive the whole trade flow with a - * fake adapter and clock. `now` returns unix seconds. The trace store - * defaults to `createTraceStore(env, adapter.mode)` (TRACE_STORE=local|ipfs). + * fake adapter and clock. `now` returns unix seconds. */ export async function main( adapter: StellarAdapter, env: NodeJS.ProcessEnv = process.env, now: () => number = () => Math.floor(Date.now() / 1000), - traceStore: TraceStore = createTraceStore(env, adapter.mode).store, ): Promise { - const deployments = (adapter as Partial).deployments; - const runtime = new Runtime({ - tasks: [traderTask(traderDeps(env, adapter, deployments, now), { intervalMs: 0 })], - state: await createStateStore(adapter.mode, env, deployments?.contracts.treasury ?? "unbound"), - lease: createLease(env), - ...runtimeOptions(env), - }); - await runtime.runOnce(); - const deployments = adapter.deployments ?? {}; - - const rpcUrl = env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org"; - const server = new Server(rpcUrl); - const stateReader = new ChainStateReader({ - marketContractId: deployments.market_contract ?? "", - portfolioContractId: deployments.portfolio_contract ?? "", - server, - }); - const cache = new SnapshotCache(); + const traceStore = new LocalTraceStore(env.TRACE_STORE_DIR ?? "./.traces"); console.log(`[trader] mode=${adapter.mode}`); + const { market, fairValueBps } = await fetchMarketAndFairValue(adapter, env, now); + const decision = decideTrade(market, fairValueBps, { + minEdgeBps: Number(env.MIN_EDGE_BPS ?? 500), + tradeSizeStroops: BigInt(env.TRADE_SIZE_STROOPS ?? "100000000"), + }); + console.log(`[trader] ${decision.action}: ${decision.reasoning}`); + if (decision.action === "skip") return; + + const built = buildTrace({ + agent: "trader", + marketId: market.marketId, + action: "buy", + reasoning: decision.reasoning, + inputs: { market, fairValueBps, decision }, + }); + const { cid } = await traceStore.put(built.hash, built.json); + console.log(`[trader] trace ${built.hash} -> ${cid}`); + + const outcome = { tag: decision.outcome!, values: undefined } as { + tag: "Yes" | "No"; + values: void; + }; + const collateralInStroops = decision.collateralInStroops!; + + // Fresh quote -> non-zero bounds. The quote is a read-only simulation, + // so this runs in dry-run mode too and the same code path is exercised + // before any real submission. + const quotedShares = await adapter.quoteBuy({ + market_id: BigInt(market.marketId), + outcome, + collateral_in: collateralInStroops, + }); + const slippageBps = BigInt(env.SLIPPAGE_BPS ?? 50); + const minSharesOut = (quotedShares * (BPS - slippageBps)) / BPS; + // Never pay more than fair for the outcome being bought: for a YES buy + // that is `fairValueBps`; for a NO buy it is the complement, since the + // contract compares the limit against the average price of the outcome + // being bought (the NO price). + const limitPriceBps = decision.outcome === "Yes" ? fairValueBps : Number(BPS) - fairValueBps; + const deadline = BigInt(now() + Number(env.DEADLINE_SECS ?? 60)); + console.log( + `[trader] quote ${quotedShares} shares -> min_shares_out=${minSharesOut} limit_price_bps=${limitPriceBps} deadline=${deadline}`, + ); + + const result = await adapter.agentBuyV2({ + market_id: BigInt(market.marketId), + outcome, + collateral_in: collateralInStroops, + min_shares_out: minSharesOut, + limit_price_bps: limitPriceBps, + deadline, + }); + console.log("[trader] agent_buy_v2:", result); - const snapshot = await cache.get(() => stateReader.readSnapshot([])); - - if (!snapshot.consistent) { - console.warn(`[trader] ledger drift ${snapshot.ledgers.max - snapshot.ledgers.min} — snapshot may be inconsistent`); - } - - const markets = tradableMarkets(snapshot); - if (markets.length === 0) { - console.log("[trader] no tradable markets on-chain"); - return; - } - - const capital = availableCapital(snapshot); - const tradeSizeStroops = BigInt(env.TRADE_SIZE_STROOPS ?? "100000000"); - if (capital < tradeSizeStroops) { - console.log(`[trader] insufficient capital: ${capital} < ${tradeSizeStroops}`); - return; - } - - for (const chainMarket of markets) { - const market: MarketState = toMarketState(chainMarket); - // Fair value: use env override or default to complement of current price - // (the agent holds no external oracle right now — strategy.ts decides edge) - const fairValueBps = Number(env.FAIR_VALUE_BPS ?? String(market.yesBps)); - - const decision = decideTrade(market, fairValueBps, { - minEdgeBps: Number(env.MIN_EDGE_BPS ?? 500), - tradeSizeStroops, - }); - console.log(`[trader] market=${market.marketId} ${decision.action}: ${decision.reasoning}`); - if (decision.action === "skip") continue; - - const built = buildTrace({ - agent: "trader", - marketId: market.marketId, - action: "buy", - reasoning: decision.reasoning, - inputs: { market, fairValueBps, decision }, - }); - // The trace must be pinned and verified before the trade: never trade - // without a publishable trace. - let cid: string; - try { - ({ cid } = await traceStore.put(built.hash, built.json)); - } catch (err) { - console.error(`[trader] market=${market.marketId} skipped: trace ${built.hash} could not be stored:`, err); - continue; - } - console.log(`[trader] trace ${built.hash} -> ${cid}`); - - const outcome = decision.outcome as "Yes" | "No"; - const collateralInStroops = decision.tradeSizeStroops!; - - const quotedShares = await adapter.quoteBuy({ - market_id: BigInt(market.marketId), - outcome, - collateral_in: collateralInStroops, - }); - const slippageBps = BigInt(env.SLIPPAGE_BPS ?? 50); - const minSharesOut = (quotedShares * (BPS - slippageBps)) / BPS; - const limitPriceBps = decision.outcome === "Yes" ? fairValueBps : Number(BPS) - fairValueBps; - const deadline = BigInt(now() + Number(env.DEADLINE_SECS ?? 60)); - console.log( - `[trader] quote ${quotedShares} shares -> min_shares_out=${minSharesOut} limit_price_bps=${limitPriceBps} deadline=${deadline}`, - ); - - const result = await adapter.agentBuyV2({ + if (!result.dryRun) { + const traceResult = await adapter.publishTrace({ + agent: adapter.agent, market_id: BigInt(market.marketId), - outcome, - collateral_in: collateralInStroops, - min_shares_out: minSharesOut, - limit_price_bps: limitPriceBps, - deadline, + action: "buy", + trace_hash: Buffer.from(built.hash, "hex"), + ipfs_cid: cid, }); - console.log("[trader] agent_buy_v2:", result); - - if (!result.dryRun) { - const traceResult = await adapter.publishTrace({ - agent: adapter.agent, - market_id: BigInt(market.marketId), - action: "buy", - trace_hash: Buffer.from(built.hash, "hex"), - ipfs_cid: cid, - }); - console.log("[trader] publish_trace:", traceResult); - } + console.log("[trader] publish_trace:", traceResult); } } const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { - let mode; - try { - mode = parseAgentMode(process.env); - } catch (err) { - console.error((err as Error).message); - process.exit(EXIT_CONFIG); - } + const mode = (process.env.AGENT_MODE as "dry-run" | "live") ?? "dry-run"; const deployments = loadDeployments(); - main(buildAdapter("trader", mode, process.env, deployments)).catch((err) => { + const adapter = new StellarAdapter({ + mode, + networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015", + rpcUrl: process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org", + ...agentCredentials("trader", process.env, deployments), + deployments, + }); + main(adapter).catch((err) => { console.error(err); - process.exit(err instanceof LeaseHeldError ? EXIT_TEMPFAIL : 1); + process.exit(1); }); } diff --git a/oracledesk-stellar/agents/core/fpmm.test.ts b/oracledesk-stellar/agents/core/fpmm.test.ts index 1fbb6a2..0cd77b6 100644 --- a/oracledesk-stellar/agents/core/fpmm.test.ts +++ b/oracledesk-stellar/agents/core/fpmm.test.ts @@ -7,17 +7,29 @@ import { buyWithinLimit, calcBuy, calcSell, + catCalcBuy, + catCalcSell, + catPrices, + catSeed, + catSettleValue, collateralOutForShares, + effectiveFeeBps, grossForShares, + MathError, + MAX_MARKET_OUTCOMES, + MAX_RAMP_SECS, + MAX_SKEW_MULT_BPS, mulDivCeil, mulDivFloor, priceAfter, priceAfterBuy, priceAfterSell, priceYesBps, + scalarPayout, seedReserves, sellWithinLimit, settleValue, + type FeeSchedule, } from "./fpmm.js"; // Same worked examples as math.rs's unit tests and model/fpmm_model.py's @@ -218,3 +230,255 @@ test("buyWithinLimit and sellWithinLimit mirror the on-chain boundaries", () => assert.equal(sellWithinLimit(0n, 1n, 0n), true); assert.equal(buyWithinLimit(1n, 1n, 0n), false); }); + +// Same small LCG as math.rs / fpmm_model.py, so the three suites explore a +// comparable input space without a property-testing dependency. +function lcg(seed: bigint): () => bigint { + let x = seed; + const mask = (1n << 64n) - 1n; + return () => { + x = (x * 6364136223846793005n + 1442695040888963407n) & mask; + return x >> 33n; + }; +} + +test("catCalcBuy and catCalcSell match the reference 3-outcome example", () => { + // Same worked examples as math_categorical.rs and model/fpmm_model.py: + // a 1000/1000/1000 pool, buying/selling 100 of outcome 0 with no fee. + const q = catCalcBuy([1000n, 1000n, 1000n], 0, 100n, 0n); + assert.equal(q.sharesOut, 272n); + assert.deepEqual(q.newReserves, [828n, 1100n, 1100n]); + const s = catCalcSell([1000n, 1000n, 1000n], 0, 100n, 0n); + assert.equal(s.sharesIn, 336n); + assert.deepEqual(s.newReserves, [1236n, 900n, 900n]); + + // An n = 2 categorical pool is the binary FPMM: same fee/net/reserves and + // a bought outcome within one unit of rounding. + const bin = calcBuy(1_000_000n, 3_000_000n, 100_000_000n, 100n); + const cat = catCalcBuy([1_000_000n, 3_000_000n], 0, 100_000_000n, 100n); + assert.equal(cat.fee, bin.fee); + assert.equal(cat.net, bin.net); + assert.equal(cat.newReserves[1], bin.newReserveOther); + const drift = cat.sharesOut - bin.sharesOut; + assert.ok(drift <= 1n && drift >= -1n, `drift=${drift}`); +}); + +test("catCalcBuy keeps k non-decreasing and a round trip never profits", () => { + const next = lcg(0x9e3779b97f4a7c15n); + for (let i = 0; i < 2000; i++) { + const n = 2 + Number(next() % 7n); + const reserves = Array.from({ length: n }, () => 100n + (next() % 10_000n)); + const j = Number(next() % BigInt(n)); + const amt = 100n + (next() % 100n); + const fee = next() % 300n; + + const k0 = reserves.reduce((a, b) => a * b, 1n); + const q = catCalcBuy(reserves, j, amt, fee); + assert.ok(q.newReserves.reduce((a, b) => a * b, 1n) >= k0, "buy decreased k"); + for (let k = 0; k < n; k++) { + if (k !== j) assert.equal(q.newReserves[k], reserves[k] + q.net); + } + try { + const s = catCalcSell(q.newReserves, j, amt, fee); + assert.ok(s.sharesIn >= q.sharesOut, "free money"); + } catch { + // insufficient liquidity to sell back out is fine, not what's tested here + } + } +}); + +test("catPrices sum to within n of BPS and are flat for equal reserves", () => { + const next = lcg(0xb1a5e5b1a5b1a5b1n); + for (let i = 0; i < 2000; i++) { + const n = 2 + Number(next() % 7n); + const reserves = Array.from({ length: n }, () => 1_000n + (next() % 1_000_000_000_000n)); + const sum = catPrices(reserves).reduce((a, b) => a + b, 0n); + assert.ok(sum <= BPS, `prices sum above BPS: n=${n} sum=${sum}`); + assert.ok(sum + BigInt(n) >= BPS, `prices sum below BPS - n: n=${n} sum=${sum}`); + } + for (let n = 2; n <= MAX_MARKET_OUTCOMES; n++) { + const prices = catPrices(Array.from({ length: n }, () => 7_777n)); + assert.deepEqual(prices, Array.from({ length: n }, () => BigInt(Math.floor(10_000 / n)))); + } +}); + +test("catSeed opens at the requested probabilities and validates inputs", () => { + for (const probs of [ + [5000, 5000], + [6800, 3200], + [5000, 3000, 2000], + [9000, 500, 400, 100], + ]) { + const s = catSeed(1_000_000_000n, probs); + const prices = catPrices(s.reserves); + for (let i = 0; i < probs.length; i++) { + const drift = prices[i] - BigInt(probs[i]); + assert.ok(drift <= 2n && drift >= -2n, `price drift ${drift} bps at i=${i}`); + assert.equal(s.reserves[i] + s.creatorShares[i], 1_000_000_000n); + } + const minIndex = probs.indexOf(Math.min(...probs)); + assert.equal(s.creatorShares[minIndex], 0n); + } + // 8 equal outcomes: prices sum to exactly BPS, creator holds nothing. + const flat = catSeed(1_000_000_000n, Array(8).fill(1250)); + assert.deepEqual(catPrices(flat.reserves), Array(8).fill(1250n)); + assert.ok(flat.creatorShares.every((c) => c === 0n)); + + // Validation: bad sum, below MIN_PROB_BPS, n out of range, seed 0. + for (const [seed, probs] of [ + [1_000n, [6000, 3000]], + [1_000n, [9950, 50]], + [1_000n, [10_000]], + [1_000n, Array(9).fill(1250)], + [0n, [5000, 5000]], + ] as [bigint, number[]][]) { + assert.throws( + () => catSeed(seed, probs), + MathError, + `catSeed accepted ${probs} seed=${seed}`, + ); + } +}); + +test("catSettleValue never overpays and handles void", () => { + const next = lcg(0xdeadbeefdeadbeefn); + for (let i = 0; i < 1000; i++) { + const n = 2 + Number(next() % 7n); + const sets = 1_000_000n; + // Three holders share each outcome's `sets` shares with the pool. + const held: bigint[][] = Array.from({ length: n }, () => { + let rest = sets; + return [0, 1, 2].map((h) => { + const take = h === 2 ? rest : next() % (rest + 1n); + rest -= take; + return take; + }); + }); + const w = Number(next() % BigInt(n)); + let resolvedTotal = 0n; + let voidTotal = 0n; + for (let h = 0; h < 3; h++) { + const balances = held.map((col) => col[h]); + resolvedTotal += catSettleValue(w, balances); + voidTotal += catSettleValue(null, balances); + } + assert.ok(resolvedTotal <= sets); + assert.ok(voidTotal <= sets, `void overpaid: ${voidTotal} > ${sets}`); + } + assert.equal(catSettleValue(1, [3n, 7n, 11n]), 7n); + assert.equal(catSettleValue(null, [3n, 7n, 11n]), 7n); // 21 / 3 + assert.equal(catSettleValue(null, Array(7).fill(1n)), 1n); +}); + +test("scalarPayout matches the binary legs at the extremes and never overpays", () => { + assert.equal(scalarPayout(BPS, 5n, 7n), settleValue("yes", 5n, 7n)); + assert.equal(scalarPayout(0n, 5n, 7n), settleValue("no", 5n, 7n)); + assert.equal(scalarPayout(5_000n, 1_000n, 1_000n), 1_000n); + assert.equal(scalarPayout(3_333n, 1_000n, 1_000n), 999n); + + const next = lcg(0x5ca1ab1e5ca1ab1en); + for (let i = 0; i < 1000; i++) { + const sets = 1n + (next() % 1_000_000n); + const payout = next() % (BPS + 1n); + // Three holders split `sets` YES and `sets` NO shares exactly. + let yesLeft = sets; + let noLeft = sets; + let total = 0n; + for (let h = 0; h < 3; h++) { + const takeY = h === 2 ? yesLeft : next() % (yesLeft + 1n); + const takeN = h === 2 ? noLeft : next() % (noLeft + 1n); + yesLeft -= takeY; + noLeft -= takeN; + total += scalarPayout(payout, takeY, takeN); + } + assert.ok(total <= sets, `scalar overpaid: ${total} > ${sets} at p=${payout}`); + } +}); + +test("effectiveFeeBps matches the shared cross-language fixtures", () => { + // Same table as math.rs's effective_fee_matches_fixtures and + // model/fpmm_model.py's _check_effective_fee_known_values: one row per + // fixture, nine numbers — [base_bps, max_bps, ramp_secs, skew_mult_bps, + // now, close, r_yes, r_no, expected_fee_bps]. + const rows = JSON.parse( + readFileSync(new URL("../../model/fee_fixtures.json", import.meta.url), "utf8"), + ) as number[][]; + assert.ok(rows.length > 0, "fee_fixtures.json has no rows"); + for (const row of rows) { + assert.equal(row.length, 9); + const schedule: FeeSchedule = { + baseBps: BigInt(row[0]), + maxBps: BigInt(row[1]), + rampSecs: BigInt(row[2]), + skewMultBps: BigInt(row[3]), + }; + const fee = effectiveFeeBps(schedule, BigInt(row[4]), BigInt(row[5]), BigInt(row[6]), BigInt(row[7])); + assert.equal(fee, BigInt(row[8]), `fixture ${JSON.stringify(row)}`); + } +}); + +test("effectiveFeeBps stays within bounds and ramps monotonically", () => { + const next = lcg(0xfee10beefee10been); + for (let i = 0; i < 2000; i++) { + const base = next() % 1_000n; + const maxBps = base + (next() % (1_000n - base + 1n)); + const schedule: FeeSchedule = { + baseBps: base, + maxBps, + rampSecs: next() % (MAX_RAMP_SECS + 1n), + skewMultBps: next() % (MAX_SKEW_MULT_BPS + 1n), + }; + const close = 1_000n + (next() % 2_000_000_000n); + const now = next() % (close + 10_000n); + const fee = effectiveFeeBps(schedule, now, close, 1n + (next() % 10n ** 15n), 1n + (next() % 10n ** 15n)); + assert.ok(fee >= base && fee <= maxBps, `fee=${fee} base=${base} max=${maxBps}`); + } + + // Ramp monotonic in time, full ramp at/past close. + const sched: FeeSchedule = { baseBps: 100n, maxBps: 800n, rampSecs: 10_000n, skewMultBps: 0n }; + let prev = -1n; + for (let t = 2_000_000n - 20_000n; t <= 2_000_000n + 5_000n; t += 137n) { + const fee = effectiveFeeBps(sched, t, 2_000_000n, 1_000n, 1_000n); + assert.ok(fee >= prev); + prev = fee; + } + assert.equal(prev, 800n); + + // Zero ramp means "no ramp", even at close. + const flat: FeeSchedule = { baseBps: 100n, maxBps: 800n, rampSecs: 0n, skewMultBps: 0n }; + assert.equal(effectiveFeeBps(flat, 0n, 2_000_000n, 1_000n, 1_000n), 100n); + assert.equal(effectiveFeeBps(flat, 2_000_000n, 2_000_000n, 1_000n, 1_000n), 100n); + + // Skew is symmetric around 50/50 for representable ratios. + for (const [a, b] of [ + [1000n, 3000n], + [900n, 1100n], + [5000n, 5000n], + [1n, 4n], + [7n, 3n], + [1n, 1n], + ]) { + const skewSched: FeeSchedule = { baseBps: 100n, maxBps: 1_000n, rampSecs: 0n, skewMultBps: 500n }; + assert.equal( + effectiveFeeBps(skewSched, 0n, 1n, a, b), + effectiveFeeBps(skewSched, 0n, 1n, b, a), + `skew asymmetry for ${a}/${b}`, + ); + } + + // The pure function only requires base <= max < BPS (the tighter + // MAX_FEE_BPS cap is the contract's validate_schedule), so (1000, 1000) + // is valid here. + assert.equal( + effectiveFeeBps({ baseBps: 1_000n, maxBps: 1_000n, rampSecs: 0n, skewMultBps: 0n }, 0n, 1n, 1_000n, 1_000n), + 1_000n, + ); + assert.throws( + () => effectiveFeeBps({ baseBps: 500n, maxBps: 100n, rampSecs: 0n, skewMultBps: 0n }, 0n, 1n, 1_000n, 1_000n), + MathError, + ); + assert.throws( + () => effectiveFeeBps({ baseBps: 0n, maxBps: BPS, rampSecs: 0n, skewMultBps: 0n }, 0n, 1n, 1_000n, 1_000n), + MathError, + ); +}); diff --git a/oracledesk-stellar/agents/core/fpmm.ts b/oracledesk-stellar/agents/core/fpmm.ts index b3de64e..8c3b59f 100644 --- a/oracledesk-stellar/agents/core/fpmm.ts +++ b/oracledesk-stellar/agents/core/fpmm.ts @@ -230,3 +230,240 @@ export function sellWithinLimit(collateralOut: bigint, sharesIn: bigint, limitBp if (collateralOut < 0n || sharesIn <= 0n) throw new MathError("invalid input"); return collateralOut * BPS >= sharesIn * limitBps; } + +// --------------------------------------------------------------------------- +// Categorical (N-outcome) FPMM — mirrors math_categorical.rs. The binary +// invariant k = rYes * rNo generalises to the product of all reserves; the +// iterative ceil accumulation keeps k non-decreasing with at most n-1 units +// of pool-favouring drift per trade (see the rounding proof in +// math_categorical.rs::calc_buy). +// --------------------------------------------------------------------------- + +export const MAX_MARKET_OUTCOMES = 8; +export const PRICE_SCALE = 10n ** 18n; // same reciprocal scale as math_categorical.rs + +function catN(reserves: bigint[]): number { + const n = reserves.length; + if (n < 2 || n > MAX_MARKET_OUTCOMES) throw new MathError("invalid input"); + for (const r of reserves) if (r <= 0n) throw new MathError("invalid input"); + return n; +} + +function catIndex(n: number, outcomeIndex: number): number { + if (outcomeIndex < 0 || outcomeIndex >= n) throw new MathError("invalid input"); + return outcomeIndex; +} + +export interface CatBuyQuote { + sharesOut: bigint; + fee: bigint; + net: bigint; + newReserves: bigint[]; +} + +/** + * Buy `collateralIn` of outcome `outcomeIndex`: mint `net` sets into every + * other reserve and shrink the bought reserve so the product of all + * reserves holds. Mirrors math_categorical.rs::calc_buy. + */ +export function catCalcBuy( + reserves: bigint[], + outcomeIndex: number, + collateralIn: bigint, + feeBps: bigint, +): CatBuyQuote { + const n = catN(reserves); + const j = catIndex(n, outcomeIndex); + if (collateralIn <= 0n) throw new MathError("invalid input"); + const fee = mulDivCeil(collateralIn, feeBps, BPS); + const net = collateralIn - fee; + if (net <= 0n) throw new MathError("invalid input"); + const newReserves = [...reserves]; + let acc = reserves[j]; + for (let i = 0; i < n; i++) { + if (i === j) continue; + const newOther = reserves[i] + net; + newReserves[i] = newOther; + acc = mulDivCeil(acc, reserves[i], newOther); + } + newReserves[j] = acc; + const sharesOut = reserves[j] + net - acc; + if (sharesOut <= 0n) throw new MathError("invalid input"); + return { sharesOut, fee, net, newReserves }; +} + +export interface CatSellQuote { + sharesIn: bigint; + fee: bigint; + gross: bigint; + newReserves: bigint[]; +} + +/** + * Sell `outcomeIndex` for an exact `collateralOut` (Gnosis-style, so no + * square root on-chain). Mirrors math_categorical.rs::calc_sell. + */ +export function catCalcSell( + reserves: bigint[], + outcomeIndex: number, + collateralOut: bigint, + feeBps: bigint, +): CatSellQuote { + const n = catN(reserves); + const j = catIndex(n, outcomeIndex); + if (collateralOut <= 0n || feeBps >= BPS) throw new MathError("invalid input"); + const gross = mulDivCeil(collateralOut, BPS, BPS - feeBps); + const fee = gross - collateralOut; + const newReserves = [...reserves]; + let acc = reserves[j]; + for (let i = 0; i < n; i++) { + if (i === j) continue; + if (reserves[i] <= gross) throw new MathError("insufficient liquidity"); + const newOther = reserves[i] - gross; + newReserves[i] = newOther; + acc = mulDivCeil(acc, reserves[i], newOther); + } + newReserves[j] = acc; + const sharesIn = acc + gross - reserves[j]; + if (sharesIn <= 0n) throw new MathError("invalid input"); + return { sharesIn, fee, gross, newReserves }; +} + +/** + * Spot price of every outcome in bps, `p_j = (1/r_j) / Σ(1/r_i)` evaluated + * on integer reciprocals. Each price floors, so the n prices sum to at most + * BPS and at least BPS - n (display-only; never trade math). Mirrors + * math_categorical.rs::prices_bps. + */ +export function catPrices(reserves: bigint[]): bigint[] { + const n = catN(reserves); + const recs = reserves.slice(0, n).map((r) => PRICE_SCALE / r); + let total = 0n; + for (const rec of recs) total += rec; + if (total <= 0n) throw new MathError("insufficient liquidity"); + return recs.map((rec) => mulDivFloor(BPS, rec, total)); +} + +export interface CatSeed { + reserves: bigint[]; + creatorShares: bigint[]; +} + +/** + * Open a pool at the requested probabilities: each reserve is + * `seed * pMin / p_i` (floored), the creator keeps `seed - r_i` of every + * outcome. `probsBps` must have length 2..=MAX_MARKET_OUTCOMES, every entry + * >= MIN_PROB_BPS, and the entries must sum to exactly BPS. Mirrors + * math_categorical.rs::seed_reserves. + */ +export function catSeed(seed: bigint, probsBps: number[]): CatSeed { + const n = probsBps.length; + if (n < 2 || n > MAX_MARKET_OUTCOMES || seed <= 0n) throw new MathError("invalid input"); + let sum = 0; + for (const p of probsBps) { + if (BigInt(p) < MIN_PROB_BPS) throw new MathError("invalid input"); + sum += p; + } + if (sum !== 10_000) throw new MathError("invalid input"); + const pMin = BigInt(Math.min(...probsBps)); + const reserves: bigint[] = []; + const creatorShares: bigint[] = []; + for (const p of probsBps) { + const r = mulDivFloor(seed, pMin, BigInt(p)); + if (r <= 0n) throw new MathError("invalid input"); + reserves.push(r); + creatorShares.push(seed - r); + } + return { reserves, creatorShares }; +} + +/** + * Collateral value of one holder's per-outcome balances once final. + * `resolved = w` pays the winning balance; `resolved = null` (void) pays + * `floor(Σ balances / n)`. Every complete set is worth exactly 1 and every + * rounding floors, so the total paid across holders never exceeds + * setsMinted. Mirrors math_categorical.rs::settle_value. + */ +export function catSettleValue(resolved: number | null, balances: bigint[]): bigint { + const n = balances.length; + if (n < 2 || n > MAX_MARKET_OUTCOMES) throw new MathError("invalid input"); + if (resolved === null) { + let sum = 0n; + for (const b of balances) { + if (b < 0n) throw new MathError("invalid input"); + sum += b; + } + return sum / BigInt(n); + } + catIndex(n, resolved); + return balances[resolved]; +} + +// --------------------------------------------------------------------------- +// Scalar (range) market settlement — mirrors math.rs::scalarPayout, which +// backs the Rust `Settlement::Scalar(p)` variant. `scalarPayout(BPS, ...)` +// equals YES and `(0, ...)` equals NO. +// --------------------------------------------------------------------------- + +/** + * Collateral value of a (yes, no) share pair when the resolved value maps + * to `payoutLongBps` LONG per complete set. Both legs floor independently, + * so no holder ever beats their exact rational share. + */ +export function scalarPayout(payoutLongBps: bigint, yes: bigint, no: bigint): bigint { + return (yes * payoutLongBps) / BPS + (no * (BPS - payoutLongBps)) / BPS; +} + +// --------------------------------------------------------------------------- +// Dynamic fee schedule — mirrors math.rs::effectiveFeeBps. Deterministic +// integer math shared with the contract and model/fpmm_model.py via +// model/fee_fixtures.json; every floor rounds toward the trader (a lower +// fee never harms the pool, whose protection is calc* rounding). +// --------------------------------------------------------------------------- + +export const MAX_RAMP_SECS = 30n * 86_400n; +export const MAX_SKEW_MULT_BPS = 1_000n; + +export interface FeeSchedule { + baseBps: bigint; + maxBps: bigint; + rampSecs: bigint; + skewMultBps: bigint; +} + +/** + * Fee in bps for a trade at `now` with the given pre-trade reserves: + * `min(max, base + timeRamp + skew)`. The ramp adds up to `max - base` + * linearly over the last `rampSecs` before close (0 disables it, and any + * timestamp at or past close clamps to the full ramp); the skew term adds + * `skewMultBps` per 1000 bps of distance from a 50/50 price. Guaranteed + * `base <= result <= max`, and `max < BPS` is validated so `calcSell` is + * always well-defined. + */ +export function effectiveFeeBps( + schedule: FeeSchedule, + now: bigint, + closeTime: bigint, + rYes: bigint, + rNo: bigint, +): bigint { + if (schedule.baseBps > schedule.maxBps || schedule.maxBps >= BPS) { + throw new MathError("invalid input"); + } + let t = 0n; + if (schedule.rampSecs !== 0n) { + const intoRamp = + now >= closeTime + ? schedule.rampSecs + : closeTime - now >= schedule.rampSecs + ? 0n + : schedule.rampSecs - (closeTime - now); + t = mulDivFloor(intoRamp, schedule.maxBps - schedule.baseBps, schedule.rampSecs); + } + const p = priceYesBps(rYes, rNo); + const midpoint = BPS / 2n; + const d = p >= midpoint ? p - midpoint : midpoint - p; + const skew = mulDivFloor(d, schedule.skewMultBps, 1_000n); + const total = schedule.baseBps + t + skew; + return total > schedule.maxBps ? schedule.maxBps : total; +} diff --git a/oracledesk-stellar/agents/stellar/adapter.ts b/oracledesk-stellar/agents/stellar/adapter.ts index f348953..a9cda63 100644 --- a/oracledesk-stellar/agents/stellar/adapter.ts +++ b/oracledesk-stellar/agents/stellar/adapter.ts @@ -38,6 +38,15 @@ export interface Deployments { }; } +/** Subset of market-core's `Market` the agents consume. i128 maps to bigint + * and u64 to bigint per the generated bindings (`u32` -> number). */ +export interface MarketView { + close_time: bigint; + fee_bps: number; + reserve_yes: bigint; + reserve_no: bigint; +} + export function loadDeployments( path = process.env.DEPLOYMENTS_PATH ?? join(REPO_ROOT, "deployments", "testnet.json"), ): Deployments { @@ -282,6 +291,31 @@ export class StellarAdapter { return result as bigint; } + /** + * Reads a market's trade-relevant state via market-core's `get_market` view + * (read-only — simulates, never signs or submits). Lets the trader seed + * its market state (reserves, close time) from the chain in live mode. + * Named apart from `getMarket`, which returns the spec-reveal metadata view. + */ + async getMarketView(args: { market_id: bigint }): Promise { + const client = await this.client("market_core", "@oracledesk/bindings-market-core"); + const { result } = await client.get_market(args); + return result as MarketView; + } + + /** + * Reads market-core's `current_fee_bps` view: the fee a trade would pay + * right now, including any dynamic `FeeSchedule`. That schedule is + * deliberately invisible in `Market.fee_bps` (the flat snapshot legacy + * markets charge), so quoting off the snapshot would understate the fee + * near close. Read-only, simulates only. + */ + async currentFeeBps(args: { market_id: bigint }): Promise { + const client = await this.client("market_core", "@oracledesk/bindings-market-core"); + const { result } = await client.current_fee_bps(args); + return result as number; + } + /** * Reveals a signer-mode spec (resolver `register_signer_spec`). * Permissionless: the commitment, not the caller, authorizes it. diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 2b6c6b7..e224b83 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -1,13 +1,17 @@ #![no_std] +#[cfg(test)] +extern crate std; + mod math; +mod math_categorical; #[cfg(kani)] mod math_proofs; 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, }; // --------------------------------------------------------------------------- @@ -24,6 +28,9 @@ 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; +/// Outcome-count cap for categorical markets (mirrors the math module). +const MAX_N: usize = math_categorical::MAX_MARKET_OUTCOMES as usize; + /// 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. @@ -38,6 +45,17 @@ const ADMIN_PROPOSAL_TTL_SECS: u64 = 7 * 86_400; // contract's spec, so generated TypeScript bindings are unchanged. pub use oracledesk_types::{Category, MarketStatus, Outcome}; +/// Display metadata of a scalar market: the `[lower, upper]` range of the +/// committed resolution spec, in the feed's raw units. Stored under +/// `DataKey::Scalar(u64)` at creation; the resolver's revealed spec is the +/// authoritative copy, this entry only tells UIs what to display. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ScalarMeta { + pub lower: i128, + pub upper: i128, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Market { @@ -56,7 +74,9 @@ pub struct Market { pub category: Category, /// Unix seconds (ledger timestamp). Trading stops at this time. pub close_time: u64, - /// Snapshot of the fee at creation so later config changes never move it. + /// Snapshot of the flat fee at creation so later config changes never + /// move it. Charged as-is by categorical markets and by binary markets + /// without a `FeeSchedule` side entry (legacy / flat-fee markets). pub fee_bps: u32, pub status: MarketStatus, pub reserve_yes: i128, @@ -76,6 +96,63 @@ pub struct Position { pub no: i128, } +/// Which pool a market id refers to. Absent for markets created before +/// categorical markets existed, hence `market_kind` reports `Binary` for +/// a stored `Market(u64)` and a `Kind` entry is only written for +/// categorical ids (which store a `CatMarket` instead of a `Market`). +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MarketKind { + Binary, + /// Outcome count, 2..=MAX_MARKET_OUTCOMES. + Categorical(u32), +} + +/// Lifecycle of a categorical market: same three states as `MarketStatus` +/// with the winning index instead of a two-way outcome. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CatStatus { + Open, + /// Final. Winning shares redeem 1:1. + Resolved(u32), + /// Invalid market. Every share redeems at 1/n. + Void, +} + +/// Categorical market state. Stored under `DataKey::CatMarket(u64)`, sharing +/// the id space with binary markets (`DataKey::NextId`), so `get_market` on +/// a categorical id is `MarketNotFound` and callers must read `market_kind`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatMarket { + pub creator: Address, + /// Same commitment scheme as the binary `Market` (see there). + pub question_hash: BytesN<32>, + pub resolution_hash: BytesN<32>, + pub meta_uri: String, + pub category: Category, + pub close_time: u64, + pub fee_bps: u32, + pub status: CatStatus, + /// One reserve per outcome; `len()` is the outcome count n. + pub reserves: Vec, + /// Complete sets in existence == collateral locked for this market. Each + /// outcome's shares (reserve + all holder balances) sum to this. + pub sets_minted: i128, + pub fees_accrued: i128, + /// Creator has withdrawn the pool remainder after finality. + pub lp_claimed: bool, +} + +/// Per-holder categorical position: one balance per outcome, same order as +/// `CatMarket::reserves`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatPosition { + pub balances: Vec, +} + /// Fee destination and split, snapshotted per market at creation as a side /// entry (`DataKey::FeeTerms`) so `Market`'s XDR shape never changes. /// @@ -94,6 +171,35 @@ pub struct FeeTerms { pub protocol_share_bps: u32, } +/// Dynamic fee schedule, snapshotted per binary market at creation as a +/// side entry (`DataKey::FeeSchedule`) so `Market`'s XDR shape never +/// changes. The effective rate of a trade is +/// `math::effective_fee_bps(schedule, now, close_time, r_yes, r_no)`: +/// a base rate that ramps up towards `max_bps` as `close_time` approaches +/// and grows with the pool's distance from 50/50, protecting the single LP +/// from informed flow near expiry and from one-sided pools. +/// +/// Markets created before this existed (and categorical markets, which +/// still charge their flat `Market::fee_bps`) have no `FeeSchedule` entry +/// and keep a flat fee. The schedule is immutable after creation; only +/// `set_default_fee_schedule` (the default for *new* markets) can change. +/// Field order is part of the XDR — keep `base_bps`, `max_bps`, +/// `ramp_secs`, `skew_mult_bps` stable. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FeeSchedule { + /// Fee far from close with the pool at 50/50. + pub base_bps: u32, + /// Hard ceiling for this market, `<= MAX_FEE_BPS`. + pub max_bps: u32, + /// The time ramp starts this many seconds before `close_time` + /// (0 = no ramp, `<= math::MAX_RAMP_SECS`). + pub ramp_secs: u64, + /// Extra bps per 1_000 bps of distance from a 5_000 price + /// (`<= math::MAX_SKEW_MULT_BPS`). + pub skew_mult_bps: u32, +} + /// Per-market circuit breakers, snapshotted at creation from /// `DataKey::DefaultBreaker`. 0 disables each check. A market without a /// `Breaker` entry (created before breakers existed) is never checked. @@ -161,6 +267,21 @@ pub enum DataKey { Breaker(u64), // temporary storage (expires on its own, one entry per market) Band(u64), + // appended (new variants only ever go at the end — the XDR tag is the + // variant position) + /// Only written for categorical markets; absent means binary. + Kind(u64), + CatMarket(u64), + CatPosition(u64, Address), + /// Display metadata of a scalar market, written by + /// `create_scalar_market`; absent for binary and categorical markets. + Scalar(u64), + /// Dynamic fee schedule snapshotted at creation; absent for legacy and + /// categorical markets, which charge the flat `Market::fee_bps`. + FeeSchedule(u64), + /// Instance storage, but appended here anyway: the XDR tag is the + /// variant position, so even instance keys must only ever be appended. + DefaultFeeSchedule, } // --------------------------------------------------------------------------- @@ -205,6 +326,10 @@ pub enum Error { NoPendingAdmin = 25, /// A proposed admin handover was not accepted within its `expires_at`. PendingAdminExpired = 26, + // 27 is reserved for an in-flight contributor PR (batch redemption); + // new codes here start at 28. + /// `outcome_index` is not a valid outcome of this categorical market. + InvalidOutcomeIndex = 28, } impl From for Error { @@ -248,6 +373,55 @@ pub struct Trade { pub reserve_no: i128, } +/// Same trade as `Trade` plus the realised fee rate. New event rather than +/// a new field so the existing `Trade` XDR stays stable for indexers (the +/// `FeesSweptTo` convention); both are emitted on every binary trade. +/// `fee_bps` is the effective dynamic rate applied, already snapshotted +/// into `fee` — indexers can recover it from `fee / collateral` but not +/// exactly, because `fee` carries the ceiling rounding. +#[contractevent] +pub struct TradeV2 { + #[topic] + pub market_id: u64, + #[topic] + pub trader: Address, + pub outcome: Outcome, + pub is_buy: bool, + pub collateral: i128, + pub shares: i128, + pub fee: i128, + pub fee_bps: u32, + pub reserve_yes: i128, + pub reserve_no: i128, +} + +#[contractevent] +pub struct CatMarketCreated { + #[topic] + pub market_id: u64, + pub creator: Address, + pub category: Category, + pub close_time: u64, + pub seed_amount: i128, + /// Requested opening probabilities, one per outcome, summing to BPS. + pub probs_bps: Vec, +} + +#[contractevent] +pub struct CatTrade { + #[topic] + pub market_id: u64, + #[topic] + pub trader: Address, + pub outcome_index: u32, + pub is_buy: bool, + pub collateral: i128, + pub shares: i128, + pub fee: i128, + /// Post-trade reserves, same order as `CatMarket::reserves`. + pub reserves: Vec, +} + #[contractevent] pub struct MarketResolved { #[topic] @@ -256,6 +430,23 @@ pub struct MarketResolved { pub outcome: Option, } +#[contractevent] +pub struct CatMarketResolved { + #[topic] + pub market_id: u64, + /// None means the market was voided. + pub outcome_index: Option, +} + +#[contractevent] +pub struct MarketResolvedScalar { + #[topic] + pub market_id: u64, + /// LONG payout per complete set, 0..=BPS; the SHORT leg pays the + /// complement. `MarketResolved` is never emitted for scalar markets. + pub payout_long_bps: u32, +} + #[contractevent] pub struct Redeemed { #[topic] @@ -469,6 +660,20 @@ impl MarketCore { Ok(()) } + /// Sets the dynamic fee schedule snapshotted into binary markets created + /// after the call. Bounded by `MAX_FEE_BPS` and the ramp/skew caps; + /// existing markets keep their schedule. A creator can pick a stricter + /// schedule through `create_market_with_fees`, never a looser one (its + /// `max_bps` is capped by this default's `max_bps`). + pub fn set_default_fee_schedule(env: Env, schedule: FeeSchedule) -> Result<(), Error> { + Self::require_admin(&env)?; + Self::validate_schedule(&schedule, MAX_FEE_BPS)?; + env.storage() + .instance() + .set(&DataKey::DefaultFeeSchedule, &schedule); + Ok(()) + } + pub fn propose_resolver(env: Env, resolver: Address) -> Result<(), Error> { Self::require_admin(&env)?; env.storage() @@ -569,6 +774,9 @@ impl MarketCore { /// Creates a market and seeds the pool. `creator` becomes the sole LP. /// `initial_yes_bps` in [100, 9900] sets the opening YES probability; /// the creator receives the leftover shares of the likelier outcome. + /// When a default dynamic fee schedule is set it is snapshotted for + /// this market (see `set_default_fee_schedule`); otherwise the market + /// charges the flat `DefaultFeeBps`. #[allow(clippy::too_many_arguments)] pub fn create_market( env: Env, @@ -580,6 +788,66 @@ impl MarketCore { close_time: u64, seed_amount: i128, initial_yes_bps: u32, + ) -> Result { + Self::do_create_market( + env, + creator, + question_hash, + resolution_hash, + meta_uri, + category, + close_time, + seed_amount, + initial_yes_bps, + None, + ) + } + + /// `create_market` with an explicit per-market `FeeSchedule`. Validated + /// against `MAX_FEE_BPS` and the current default schedule's `max_bps` + /// (creators can undercut protocol policy, never exceed it), then + /// snapshotted for this market's lifetime. + #[allow(clippy::too_many_arguments)] + pub fn create_market_with_fees( + env: Env, + creator: Address, + question_hash: BytesN<32>, + resolution_hash: BytesN<32>, + meta_uri: String, + category: Category, + close_time: u64, + seed_amount: i128, + initial_yes_bps: u32, + schedule: FeeSchedule, + ) -> Result { + Self::do_create_market( + env, + creator, + question_hash, + resolution_hash, + meta_uri, + category, + close_time, + seed_amount, + initial_yes_bps, + Some(schedule), + ) + } + + /// Shared implementation of `create_market` / `create_market_with_fees`. + /// `schedule = None` snapshots `DataKey::DefaultFeeSchedule` when set. + #[allow(clippy::too_many_arguments)] + fn do_create_market( + env: Env, + creator: Address, + question_hash: BytesN<32>, + resolution_hash: BytesN<32>, + meta_uri: String, + category: Category, + close_time: u64, + seed_amount: i128, + initial_yes_bps: u32, + schedule: Option, ) -> Result { Self::assert_not_paused(&env)?; creator.require_auth(); @@ -593,6 +861,16 @@ impl MarketCore { if !(math::MIN_PROB_BPS..=math::MAX_PROB_BPS).contains(&initial_yes_bps) { return Err(Error::InvalidProbability); } + // validated before the id is allocated so a rejected schedule + // never burns a market id + if let Some(sch) = &schedule { + let cap = env + .storage() + .instance() + .get::<_, FeeSchedule>(&DataKey::DefaultFeeSchedule) + .map_or(MAX_FEE_BPS, |d| d.max_bps); + Self::validate_schedule(sch, cap)?; + } let seed = math::seed_reserves(seed_amount, initial_yes_bps)?; let s = env.storage().instance(); @@ -606,6 +884,7 @@ impl MarketCore { .ok_or(Error::NotInitialized)?; let treasury: Address = s.get(&DataKey::Treasury).ok_or(Error::NotInitialized)?; let breaker: Option = s.get(&DataKey::DefaultBreaker); + let default_schedule: Option = s.get(&DataKey::DefaultFeeSchedule); let market = Market { creator: creator.clone(), @@ -634,6 +913,9 @@ impl MarketCore { if let Some(b) = breaker { Self::save_breaker(&env, id, &b); } + if let Some(sch) = schedule.or(default_schedule) { + Self::save_fee_schedule(&env, id, &sch); + } if seed.creator_yes > 0 || seed.creator_no > 0 { Self::save_position( &env, @@ -660,6 +942,164 @@ impl MarketCore { Ok(id) } + /// Creates a scalar (range) market: the binary pool with LONG = YES and + /// SHORT = NO, whose final status carries a fractional payout + /// (`MarketStatus::ResolvedScalar`) instead of a side. Everything else + /// matches `create_market`; `[lower, upper]` is display metadata stored + /// under `DataKey::Scalar` (the authoritative range is inside the + /// `ResolutionSpec` committed through `resolution_hash`). + #[allow(clippy::too_many_arguments)] + pub fn create_scalar_market( + env: Env, + creator: Address, + question_hash: BytesN<32>, + resolution_hash: BytesN<32>, + meta_uri: String, + category: Category, + close_time: u64, + seed_amount: i128, + initial_yes_bps: u32, + lower: i128, + upper: i128, + ) -> Result { + if lower >= upper { + return Err(Error::InvalidAmount); + } + let id = Self::create_market( + env.clone(), + creator, + question_hash, + resolution_hash, + meta_uri, + category, + close_time, + seed_amount, + initial_yes_bps, + )?; + let key = DataKey::Scalar(id); + env.storage() + .persistent() + .set(&key, &ScalarMeta { lower, upper }); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + Ok(id) + } + + /// Creates a categorical market with 2..=8 outcomes and seeds the pool. + /// `creator` becomes the sole LP and receives the leftover shares of the + /// likelier outcomes. `probs_bps` must have one entry per outcome with + /// every probability in `math::MIN_PROB_BPS..=math::MAX_PROB_BPS` and the + /// entries summing to exactly BPS. + /// + /// Collateral, id space, pause switch, fee snapshot and `resolution_hash` + /// commitment are shared with `create_market`; the pool is the same FPMM + /// with the reserve-product invariant generalised to n outcomes (see + /// `math_categorical`). Per-market circuit breakers are price-based and + /// binary-only, so categorical markets are not snapshotted with them — + /// nor with a `FeeSchedule`: they always charge their flat `fee_bps`. + #[allow(clippy::too_many_arguments)] + pub fn create_categorical_market( + env: Env, + creator: Address, + question_hash: BytesN<32>, + resolution_hash: BytesN<32>, + meta_uri: String, + category: Category, + close_time: u64, + seed_amount: i128, + probs_bps: Vec, + ) -> Result { + Self::assert_not_paused(&env)?; + creator.require_auth(); + + if close_time <= env.ledger().timestamp() { + return Err(Error::InvalidCloseTime); + } + if !(MIN_SEED..=MAX_SETS).contains(&seed_amount) { + return Err(Error::InvalidAmount); + } + let n = probs_bps.len(); + if !(2..=math_categorical::MAX_MARKET_OUTCOMES).contains(&n) { + return Err(Error::InvalidAmount); + } + let mut prob_sum: u32 = 0; + for p in probs_bps.iter() { + if !(math::MIN_PROB_BPS..=math::MAX_PROB_BPS).contains(&p) { + return Err(Error::InvalidProbability); + } + prob_sum = prob_sum.checked_add(p).ok_or(Error::Overflow)?; + } + if prob_sum != BPS as u32 { + return Err(Error::InvalidProbability); + } + let mut probs = [0u32; MAX_N]; + for (i, p) in probs_bps.iter().enumerate() { + probs[i] = p; + } + let seed = math_categorical::seed_reserves(seed_amount, &probs[..n as usize])?; + + let s = env.storage().instance(); + let id: u64 = s.get(&DataKey::NextId).ok_or(Error::NotInitialized)?; + s.set(&DataKey::NextId, &(id + 1)); + let fee_bps: u32 = s + .get(&DataKey::DefaultFeeBps) + .ok_or(Error::NotInitialized)?; + let protocol_share_bps: u32 = s + .get(&DataKey::ProtocolFeeShareBps) + .ok_or(Error::NotInitialized)?; + let treasury: Address = s.get(&DataKey::Treasury).ok_or(Error::NotInitialized)?; + + let market = CatMarket { + creator: creator.clone(), + question_hash, + resolution_hash, + meta_uri, + category, + close_time, + fee_bps, + status: CatStatus::Open, + reserves: Vec::from_slice(&env, &seed.reserves[..n as usize]), + sets_minted: seed_amount, + fees_accrued: 0, + lp_claimed: false, + }; + Self::save_kind(&env, id, &MarketKind::Categorical(n)); + Self::save_cat_market(&env, id, &market); + Self::save_fee_terms( + &env, + id, + &FeeTerms { + recipient: treasury, + protocol_share_bps, + }, + ); + let creator_shares: Vec = Vec::from_slice(&env, &seed.creator_shares[..n as usize]); + if creator_shares.iter().any(|b| b > 0) { + Self::save_cat_position( + &env, + id, + &creator, + &CatPosition { + balances: creator_shares, + }, + ); + } + + Self::token(&env)?.transfer(&creator, env.current_contract_address(), &seed_amount); + + CatMarketCreated { + market_id: id, + creator, + category, + close_time, + seed_amount, + probs_bps, + } + .publish(&env); + Ok(id) + } + // ----- trading -------------------------------------------------------- /// Spend `collateral_in` to buy `outcome` shares. Returns shares received. @@ -747,7 +1187,8 @@ impl MarketCore { Self::assert_tradable(&env, &m)?; let (r_bought, r_other) = Self::reserves_for(&m, outcome); - let q = math::calc_buy(r_bought, r_other, collateral_in, m.fee_bps)?; + let fee_bps = Self::fee_at(&env, market_id, &m, env.ledger().timestamp())?; + let q = math::calc_buy(r_bought, r_other, collateral_in, fee_bps)?; if q.shares_out < min_shares_out { return Err(Error::SlippageExceeded); } @@ -790,7 +1231,7 @@ impl MarketCore { Trade { market_id, - trader, + trader: trader.clone(), outcome, is_buy: true, collateral: collateral_in, @@ -800,12 +1241,25 @@ impl MarketCore { reserve_no: m.reserve_no, } .publish(&env); - Ok(q.shares_out) - } - - /// Receive exactly `collateral_out` by selling `outcome` shares. Returns - /// shares sold. Reverts if more than `max_shares_in` would be needed. - /// Clients turn "sell N shares" into `collateral_out` off-chain + TradeV2 { + market_id, + trader, + outcome, + is_buy: true, + collateral: collateral_in, + shares: q.shares_out, + fee: q.fee, + fee_bps, + reserve_yes: m.reserve_yes, + reserve_no: m.reserve_no, + } + .publish(&env); + Ok(q.shares_out) + } + + /// Receive exactly `collateral_out` by selling `outcome` shares. Returns + /// shares sold. Reverts if more than `max_shares_in` would be needed. + /// Clients turn "sell N shares" into `collateral_out` off-chain /// (see `math::calc_sell` docs) and add a slippage margin to `max_shares_in`. /// /// Legacy entrypoint: no deadline and no limit price. Prefer `sell_v2`. @@ -881,7 +1335,8 @@ impl MarketCore { Self::assert_tradable(&env, &m)?; let (r_sold, r_other) = Self::reserves_for(&m, outcome); - let q = math::calc_sell(r_sold, r_other, collateral_out, m.fee_bps)?; + let fee_bps = Self::fee_at(&env, market_id, &m, env.ledger().timestamp())?; + let q = math::calc_sell(r_sold, r_other, collateral_out, fee_bps)?; if q.shares_in > max_shares_in { return Err(Error::SlippageExceeded); } @@ -927,6 +1382,18 @@ impl MarketCore { Self::token(&env)?.transfer(&env.current_contract_address(), &trader, &collateral_out); Trade { + market_id, + trader: trader.clone(), + outcome, + is_buy: false, + collateral: collateral_out, + shares: q.shares_in, + fee: q.fee, + reserve_yes: m.reserve_yes, + reserve_no: m.reserve_no, + } + .publish(&env); + TradeV2 { market_id, trader, outcome, @@ -934,6 +1401,7 @@ impl MarketCore { collateral: collateral_out, shares: q.shares_in, fee: q.fee, + fee_bps, reserve_yes: m.reserve_yes, reserve_no: m.reserve_no, } @@ -941,6 +1409,138 @@ impl MarketCore { Ok(q.shares_in) } + /// Categorical-market counterpart of `buy`: spend `collateral_in` on + /// `outcome_index` shares. Returns shares received; reverts if fewer than + /// `min_shares_out` would be received. No v2 (deadline/limit) variant + /// yet — see the categorical notes in `docs/architecture.md`. + pub fn buy_cat( + env: Env, + trader: Address, + market_id: u64, + outcome_index: u32, + collateral_in: i128, + min_shares_out: i128, + ) -> Result { + Self::assert_not_paused(&env)?; + trader.require_auth(); + let mut m = Self::load_cat_market(&env, market_id)?; + Self::assert_tradable_cat(&env, &m)?; + let (reserves, n) = Self::cat_array(&m.reserves)?; + if outcome_index >= n { + return Err(Error::InvalidOutcomeIndex); + } + let q = math_categorical::calc_buy( + &reserves[..n as usize], + outcome_index, + collateral_in, + m.fee_bps, + )?; + if q.shares_out < min_shares_out { + return Err(Error::SlippageExceeded); + } + let new_sets = m.sets_minted.checked_add(q.net).ok_or(Error::Overflow)?; + if new_sets > MAX_SETS { + return Err(Error::MarketTooLarge); + } + + // effects + m.reserves = Vec::from_slice(&env, &q.new_reserves[..n as usize]); + m.sets_minted = new_sets; + 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)?; + let mut pos = Self::load_cat_position(&env, market_id, &trader, n); + let held = pos.balances.get(outcome_index).unwrap_or(0); + pos.balances.set( + outcome_index, + held.checked_add(q.shares_out).ok_or(Error::Overflow)?, + ); + Self::save_cat_position(&env, market_id, &trader, &pos); + Self::save_cat_market(&env, market_id, &m); + + // interaction + Self::token(&env)?.transfer(&trader, env.current_contract_address(), &collateral_in); + + CatTrade { + market_id, + trader, + outcome_index, + is_buy: true, + collateral: collateral_in, + shares: q.shares_out, + fee: q.fee, + reserves: m.reserves, + } + .publish(&env); + Ok(q.shares_out) + } + + /// Categorical-market counterpart of `sell`: receive exactly + /// `collateral_out` by selling `outcome_index` shares. Returns shares + /// sold; reverts if more than `max_shares_in` would be needed. + pub fn sell_cat( + env: Env, + trader: Address, + market_id: u64, + outcome_index: u32, + collateral_out: i128, + max_shares_in: i128, + ) -> Result { + Self::assert_not_paused(&env)?; + trader.require_auth(); + let mut m = Self::load_cat_market(&env, market_id)?; + Self::assert_tradable_cat(&env, &m)?; + let (reserves, n) = Self::cat_array(&m.reserves)?; + if outcome_index >= n { + return Err(Error::InvalidOutcomeIndex); + } + let q = math_categorical::calc_sell( + &reserves[..n as usize], + outcome_index, + collateral_out, + m.fee_bps, + )?; + if q.shares_in > max_shares_in { + return Err(Error::SlippageExceeded); + } + + let mut pos = Self::load_cat_position(&env, market_id, &trader, n); + let held = pos.balances.get(outcome_index).unwrap_or(0); + if held < q.shares_in { + return Err(Error::InsufficientShares); + } + + // effects + pos.balances.set(outcome_index, held - q.shares_in); + Self::save_cat_position(&env, market_id, &trader, &pos); + m.reserves = Vec::from_slice(&env, &q.new_reserves[..n as usize]); + m.sets_minted -= q.gross; + 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_cat_market(&env, market_id, &m); + + // interaction + Self::token(&env)?.transfer(&env.current_contract_address(), &trader, &collateral_out); + + CatTrade { + market_id, + trader, + outcome_index, + is_buy: false, + collateral: collateral_out, + shares: q.shares_in, + fee: q.fee, + reserves: m.reserves, + } + .publish(&env); + Ok(q.shares_in) + } + // ----- resolution ----------------------------------------------------- // Only the configured resolver contract may call these. When the resolver // contract is the direct caller, `require_auth` on its address passes. @@ -964,23 +1564,91 @@ impl MarketCore { Ok(()) } - /// Invalid or unresolvable market. Allowed any time before finality. - pub fn void_market(env: Env, market_id: u64) -> Result<(), Error> { + /// Categorical-market counterpart of `resolve`. Same resolver-only gate + /// and close-time rule. + pub fn resolve_cat(env: Env, market_id: u64, outcome_index: u32) -> Result<(), Error> { + Self::require_resolver(&env)?; + let mut m = Self::load_cat_market(&env, market_id)?; + if m.status != CatStatus::Open { + return Err(Error::MarketNotOpen); + } + if env.ledger().timestamp() < m.close_time { + return Err(Error::NotClosedYet); + } + if outcome_index >= m.reserves.len() { + return Err(Error::InvalidOutcomeIndex); + } + m.status = CatStatus::Resolved(outcome_index); + Self::save_cat_market(&env, market_id, &m); + CatMarketResolved { + market_id, + outcome_index: Some(outcome_index), + } + .publish(&env); + Ok(()) + } + + /// Scalar-market counterpart of `resolve`. `payout_long_bps` is the + /// final LONG payout in basis points of 1.0 (0..=10_000); the SHORT side + /// pays the complement. Same resolver-only gate and close-time rule. + pub fn resolve_scalar(env: Env, market_id: u64, payout_long_bps: u32) -> Result<(), Error> { Self::require_resolver(&env)?; let mut m = Self::load_market(&env, market_id)?; if m.status != MarketStatus::Open { return Err(Error::MarketNotOpen); } - m.status = MarketStatus::Void; + if env.ledger().timestamp() < m.close_time { + return Err(Error::NotClosedYet); + } + if payout_long_bps as i128 > math::BPS { + return Err(Error::InvalidProbability); + } + m.status = MarketStatus::ResolvedScalar(payout_long_bps); Self::save_market(&env, market_id, &m); - MarketResolved { + MarketResolvedScalar { market_id, - outcome: None, + payout_long_bps, } .publish(&env); Ok(()) } + /// Invalid or unresolvable market. Allowed any time before finality. + /// Dispatches on the market kind: binary markets emit `MarketResolved` + /// with `None`, categorical markets `CatMarketResolved`. + pub fn void_market(env: Env, market_id: u64) -> Result<(), Error> { + Self::require_resolver(&env)?; + match Self::market_kind_of(&env, market_id)? { + MarketKind::Categorical(_) => { + let mut m = Self::load_cat_market(&env, market_id)?; + if m.status != CatStatus::Open { + return Err(Error::MarketNotOpen); + } + m.status = CatStatus::Void; + Self::save_cat_market(&env, market_id, &m); + CatMarketResolved { + market_id, + outcome_index: None, + } + .publish(&env); + } + MarketKind::Binary => { + let mut m = Self::load_market(&env, market_id)?; + if m.status != MarketStatus::Open { + return Err(Error::MarketNotOpen); + } + m.status = MarketStatus::Void; + Self::save_market(&env, market_id, &m); + MarketResolved { + market_id, + outcome: None, + } + .publish(&env); + } + } + Ok(()) + } + // ----- settlement ----------------------------------------------------- /// Pays out `holder`'s position for a final market and clears it. @@ -1039,21 +1707,91 @@ impl MarketCore { Ok(payout) } + /// Categorical-market counterpart of `redeem`. Winning balances pay 1:1, + /// a void pays `floor(Σ balances / n)` — both floored, so the total paid + /// across all holders never exceeds `sets_minted`. + pub fn redeem_cat(env: Env, holder: Address, market_id: u64) -> Result { + holder.require_auth(); + let m = Self::load_cat_market(&env, market_id)?; + let settled = Self::cat_settlement(&m)?; + let (_, n) = Self::cat_array(&m.reserves)?; + let pos = Self::load_cat_position(&env, market_id, &holder, n); + if pos.balances.iter().all(|b| b == 0) { + return Err(Error::NothingToRedeem); + } + let (balances, _) = Self::cat_array(&pos.balances)?; + let payout = math_categorical::settle_value(settled, &balances[..n as usize])?; + + Self::save_cat_position( + &env, + market_id, + &holder, + &CatPosition { + balances: Self::zero_reserves(&env, n), + }, + ); + if payout > 0 { + Self::token(&env)?.transfer(&env.current_contract_address(), &holder, &payout); + } + Redeemed { + market_id, + holder, + payout, + } + .publish(&env); + Ok(payout) + } + + /// Categorical-market counterpart of `claim_pool_remainder`: the creator + /// (the LP) withdraws the settled pool value plus any unclaimed LP fees. + pub fn claim_pool_remainder_cat(env: Env, market_id: u64) -> Result { + let mut m = Self::load_cat_market(&env, market_id)?; + m.creator.require_auth(); + let settled = Self::cat_settlement(&m)?; + if m.lp_claimed { + return Err(Error::AlreadyClaimed); + } + let (reserves, n) = Self::cat_array(&m.reserves)?; + let pool_value = math_categorical::settle_value(settled, &reserves[..n as usize])?; + let payout = pool_value + .checked_add(Self::load_lp_fees(&env, market_id)) + .ok_or(Error::Overflow)?; + Self::save_lp_fees(&env, market_id, 0); + m.reserves = Self::zero_reserves(&env, n); + m.lp_claimed = true; + let creator = m.creator.clone(); + Self::save_cat_market(&env, market_id, &m); + if payout > 0 { + Self::token(&env)?.transfer(&env.current_contract_address(), &creator, &payout); + } + PoolRemainderClaimed { + market_id, + creator, + payout, + } + .publish(&env); + Ok(payout) + } + /// 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`). + /// nothing is pending, mirroring `sweep_fees`). Works for both market + /// kinds (the fee side-entries are keyed by the shared market id). pub fn claim_lp_fees(env: Env, market_id: u64) -> Result { - let m = Self::load_market(&env, market_id)?; - m.creator.require_auth(); + let creator = match Self::market_kind_of(&env, market_id)? { + MarketKind::Binary => Self::load_market(&env, market_id)?.creator, + MarketKind::Categorical(_) => Self::load_cat_market(&env, market_id)?.creator, + }; + creator.require_auth(); let amount = Self::load_lp_fees(&env, market_id); if amount <= 0 { return Ok(0); } Self::save_lp_fees(&env, market_id, 0); - Self::token(&env)?.transfer(&env.current_contract_address(), &m.creator, &amount); + Self::token(&env)?.transfer(&env.current_contract_address(), &creator, &amount); LpFeesClaimed { market_id, - creator: m.creator.clone(), + creator, amount, } .publish(&env); @@ -1065,14 +1803,30 @@ impl MarketCore { /// Sends to the recipient snapshotted in `FeeTerms` at creation. Markets /// created before `FeeTerms` existed have none and keep the legacy /// behaviour: the `DataKey::Treasury` of the moment of the sweep. + /// Works for both market kinds. pub fn sweep_fees(env: Env, market_id: u64) -> Result { - let mut m = Self::load_market(&env, market_id)?; - let amount = m.fees_accrued; - if amount <= 0 { - return Ok(0); - } - m.fees_accrued = 0; - Self::save_market(&env, market_id, &m); + let amount = match Self::market_kind_of(&env, market_id)? { + MarketKind::Binary => { + let mut m = Self::load_market(&env, market_id)?; + let amount = m.fees_accrued; + if amount <= 0 { + return Ok(0); + } + m.fees_accrued = 0; + Self::save_market(&env, market_id, &m); + amount + } + MarketKind::Categorical(_) => { + let mut m = Self::load_cat_market(&env, market_id)?; + let amount = m.fees_accrued; + if amount <= 0 { + return Ok(0); + } + m.fees_accrued = 0; + Self::save_cat_market(&env, market_id, &m); + amount + } + }; let recipient = match Self::load_fee_terms(&env, market_id) { Some(t) => t.recipient, None => Self::instance_get(&env, &DataKey::Treasury)?, @@ -1202,10 +1956,47 @@ impl MarketCore { Self::load_market(&env, market_id) } + /// Display metadata (the staked range) of a scalar market. Absent for + /// binary and categorical markets. + pub fn get_scalar_meta(env: Env, market_id: u64) -> Result { + let key = DataKey::Scalar(market_id); + let meta: ScalarMeta = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::MarketNotFound)?; + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + Ok(meta) + } + pub fn get_position(env: Env, market_id: u64, holder: Address) -> Position { Self::load_position(&env, market_id, &holder) } + /// Whether a market id is a binary (`Market`) or categorical + /// (`CatMarket`) market. `MarketNotFound` for unknown ids. + pub fn market_kind(env: Env, market_id: u64) -> Result { + Self::market_kind_of(&env, market_id) + } + + pub fn get_cat_market(env: Env, market_id: u64) -> Result { + Self::load_cat_market(&env, market_id) + } + + /// A holder's categorical position, zero-filled to the market's outcome + /// count when they never traded. Errors if the market does not exist. + pub fn get_cat_position( + env: Env, + market_id: u64, + holder: Address, + ) -> Result { + let m = Self::load_cat_market(&env, market_id)?; + let n = m.reserves.len(); + Ok(Self::load_cat_position(&env, market_id, &holder, n)) + } + /// Spot price of `outcome` in bps (0..=10_000). YES + NO ~ 10_000. pub fn get_price(env: Env, market_id: u64, outcome: Outcome) -> Result { let m = Self::load_market(&env, market_id)?; @@ -1217,7 +2008,8 @@ impl MarketCore { Ok(p as u32) } - /// Shares a `buy` of `collateral_in` would return right now. + /// Shares a `buy` of `collateral_in` would return right now, at the + /// effective (possibly dynamic) fee for this ledger's timestamp. pub fn quote_buy( env: Env, market_id: u64, @@ -1225,11 +2017,31 @@ impl MarketCore { collateral_in: i128, ) -> Result { let m = Self::load_market(&env, market_id)?; + let fee_bps = Self::fee_at(&env, market_id, &m, env.ledger().timestamp())?; + let (r_bought, r_other) = Self::reserves_for(&m, outcome); + Ok(math::calc_buy(r_bought, r_other, collateral_in, fee_bps)?.shares_out) + } + + /// Shares a `buy` of `collateral_in` would return at ledger timestamp + /// `at_timestamp`. Under a ramping schedule the fee rises with time, so + /// clients expecting inclusion latency can quote the worst case here + /// (see `docs/frontend-integration.md`). No other state is simulated: + /// the reserves are the current ones. + pub fn quote_buy_at( + env: Env, + market_id: u64, + outcome: Outcome, + collateral_in: i128, + at_timestamp: u64, + ) -> Result { + let m = Self::load_market(&env, market_id)?; + let fee_bps = Self::fee_at(&env, market_id, &m, at_timestamp)?; let (r_bought, r_other) = Self::reserves_for(&m, outcome); - Ok(math::calc_buy(r_bought, r_other, collateral_in, m.fee_bps)?.shares_out) + Ok(math::calc_buy(r_bought, r_other, collateral_in, fee_bps)?.shares_out) } - /// Shares a `sell` for exactly `collateral_out` would consume right now. + /// Shares a `sell` for exactly `collateral_out` would consume right now, + /// at the effective (possibly dynamic) fee for this ledger's timestamp. pub fn quote_sell( env: Env, market_id: u64, @@ -1237,8 +2049,41 @@ impl MarketCore { collateral_out: i128, ) -> Result { let m = Self::load_market(&env, market_id)?; + let fee_bps = Self::fee_at(&env, market_id, &m, env.ledger().timestamp())?; let (r_sold, r_other) = Self::reserves_for(&m, outcome); - Ok(math::calc_sell(r_sold, r_other, collateral_out, m.fee_bps)?.shares_in) + Ok(math::calc_sell(r_sold, r_other, collateral_out, fee_bps)?.shares_in) + } + + /// Shares a `buy_cat` of `collateral_in` would return right now. + pub fn quote_buy_cat( + env: Env, + market_id: u64, + outcome_index: u32, + collateral_in: i128, + ) -> Result { + let m = Self::load_cat_market(&env, market_id)?; + let (reserves, n) = Self::cat_array(&m.reserves)?; + if outcome_index >= n { + return Err(Error::InvalidOutcomeIndex); + } + Ok(math_categorical::calc_buy( + &reserves[..n as usize], + outcome_index, + collateral_in, + m.fee_bps, + )? + .shares_out) + } + + /// Spot prices of all outcomes in bps, same order as the reserves. Each + /// price is floored, so they sum to at most BPS and at least BPS - n; + /// clients should treat the slack as display rounding, never as a + /// mispricing (prices do not enter trade math). + pub fn get_prices_cat(env: Env, market_id: u64) -> Result, Error> { + let m = Self::load_cat_market(&env, market_id)?; + let (reserves, n) = Self::cat_array(&m.reserves)?; + let prices = math_categorical::prices_bps(&reserves[..n as usize])?; + Ok(Vec::from_slice(&env, &prices[..n as usize])) } /// The market's snapshotted breakers (`None` for legacy markets created @@ -1248,6 +2093,28 @@ impl MarketCore { Ok(Self::load_breaker(&env, market_id)) } + /// The market's snapshotted dynamic fee schedule (`None` for legacy and + /// categorical markets, which charge the flat `Market::fee_bps`). + pub fn fee_schedule(env: Env, market_id: u64) -> Result, Error> { + Self::load_market(&env, market_id)?; + Ok(Self::load_fee_schedule(&env, market_id)) + } + + /// The fee bps this ledger's next trade on `market_id` pays: the + /// dynamic effective fee for binary markets with a schedule, the flat + /// snapshot for legacy and categorical markets. Under a ramping + /// schedule the value changes with the ledger timestamp; use + /// `quote_buy_at` to price expected inclusion latency. + pub fn current_fee_bps(env: Env, market_id: u64) -> Result { + match Self::market_kind_of(&env, market_id)? { + MarketKind::Binary => { + let m = Self::load_market(&env, market_id)?; + Self::fee_at(&env, market_id, &m, env.ledger().timestamp()) + } + MarketKind::Categorical(_) => Ok(Self::load_cat_market(&env, market_id)?.fee_bps), + } + } + /// The current ledger's band reference, if a band check has run this /// ledger. `None` means the next trade sets the reference (to its own /// pre-trade price, which equals the ledger-open price). @@ -1282,7 +2149,8 @@ impl MarketCore { return Ok(false); } let (r_bought, r_other) = Self::reserves_for(&m, outcome); - let q = math::calc_buy(r_bought, r_other, collateral_in, m.fee_bps)?; + let fee_bps = Self::fee_at(&env, market_id, &m, env.ledger().timestamp())?; + let q = math::calc_buy(r_bought, r_other, collateral_in, fee_bps)?; let pre = math::price_yes_bps(m.reserve_yes, m.reserve_no)?; let post = math::price_after( matches!(outcome, Outcome::Yes), @@ -1369,6 +2237,9 @@ impl MarketCore { MarketStatus::Resolved(Outcome::Yes) => Ok(Settlement::Yes), MarketStatus::Resolved(Outcome::No) => Ok(Settlement::No), MarketStatus::Void => Ok(Settlement::Void), + MarketStatus::ResolvedScalar(payout_long_bps) => { + Ok(Settlement::Scalar(payout_long_bps)) + } } } @@ -1414,17 +2285,127 @@ impl MarketCore { .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); } - fn load_position(env: &Env, id: u64, holder: &Address) -> Position { - let key = DataKey::Position(id, holder.clone()); - match env.storage().persistent().get::<_, Position>(&key) { - Some(p) => { - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); - p - } - None => Position { yes: 0, no: 0 }, - } + /// Which pool an id refers to: the `Kind` side entry when present + /// (categorical), otherwise a stored binary `Market`, otherwise unknown. + fn market_kind_of(env: &Env, id: u64) -> Result { + let key = DataKey::Kind(id); + if let Some(kind) = env.storage().persistent().get::<_, MarketKind>(&key) { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + return Ok(kind); + } + Self::load_market(env, id).map(|_| MarketKind::Binary) + } + + fn save_kind(env: &Env, id: u64, kind: &MarketKind) { + let key = DataKey::Kind(id); + env.storage().persistent().set(&key, kind); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + fn load_cat_market(env: &Env, id: u64) -> Result { + let key = DataKey::CatMarket(id); + let m: CatMarket = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::MarketNotFound)?; + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + Ok(m) + } + + fn save_cat_market(env: &Env, id: u64, m: &CatMarket) { + let key = DataKey::CatMarket(id); + env.storage().persistent().set(&key, m); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + /// Reads a categorical reserve/balance vector into a fixed array and + /// returns `(array, n)`. The length check is unreachable for stored + /// values (creation validated them) but keeps malformed storage from + /// panicking. + fn cat_array(v: &Vec) -> Result<([i128; MAX_N], u32), Error> { + let n = v.len(); + if !(2..=math_categorical::MAX_MARKET_OUTCOMES).contains(&n) { + return Err(Error::InvalidAmount); + } + let mut arr = [0i128; MAX_N]; + for (i, r) in v.iter().enumerate() { + arr[i] = r; + } + Ok((arr, n)) + } + + fn zero_reserves(env: &Env, n: u32) -> Vec { + Vec::from_slice(env, &[0i128; MAX_N][..n as usize]) + } + + fn load_cat_position(env: &Env, id: u64, holder: &Address, n: u32) -> CatPosition { + let key = DataKey::CatPosition(id, holder.clone()); + match env.storage().persistent().get::<_, CatPosition>(&key) { + Some(p) => { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + p + } + None => CatPosition { + balances: Self::zero_reserves(env, n), + }, + } + } + + /// Empty positions are removed so storage does not grow with dust. + fn save_cat_position(env: &Env, id: u64, holder: &Address, p: &CatPosition) { + let key = DataKey::CatPosition(id, holder.clone()); + if p.balances.iter().all(|b| b == 0) { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, p); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + } + + fn assert_tradable_cat(env: &Env, m: &CatMarket) -> Result<(), Error> { + if m.status != CatStatus::Open { + return Err(Error::MarketNotOpen); + } + if env.ledger().timestamp() >= m.close_time { + return Err(Error::MarketClosed); + } + Ok(()) + } + + /// `Some(winning index)` when final, `None` for a void (which settles + /// proportionally), `NotFinal` while open. + fn cat_settlement(m: &CatMarket) -> Result, Error> { + match m.status { + CatStatus::Open => Err(Error::NotFinal), + CatStatus::Resolved(w) => Ok(Some(w)), + CatStatus::Void => Ok(None), + } + } + + fn load_position(env: &Env, id: u64, holder: &Address) -> Position { + let key = DataKey::Position(id, holder.clone()); + match env.storage().persistent().get::<_, Position>(&key) { + Some(p) => { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + p + } + None => Position { yes: 0, no: 0 }, + } } /// Empty positions are removed so storage does not grow with dust entries. @@ -1514,6 +2495,69 @@ impl MarketCore { .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); } + /// Shared bounds for any `FeeSchedule`: `base <= max <= cap`, the hard + /// `MAX_FEE_BPS` ceiling, and the ramp/skew caps. `cap` is `MAX_FEE_BPS` + /// for the default setter and the current default's `max_bps` for + /// creator-supplied schedules, so a creator can only tighten policy. + fn validate_schedule(s: &FeeSchedule, cap_bps: u32) -> Result<(), Error> { + if s.base_bps > s.max_bps || s.max_bps > MAX_FEE_BPS || s.max_bps > cap_bps { + return Err(Error::InvalidFee); + } + if s.ramp_secs > math::MAX_RAMP_SECS || s.skew_mult_bps > math::MAX_SKEW_MULT_BPS { + return Err(Error::InvalidFee); + } + Ok(()) + } + + fn load_fee_schedule(env: &Env, id: u64) -> Option { + let key = DataKey::FeeSchedule(id); + let s: Option = env.storage().persistent().get(&key); + if s.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + s + } + + fn save_fee_schedule(env: &Env, id: u64, s: &FeeSchedule) { + let key = DataKey::FeeSchedule(id); + env.storage().persistent().set(&key, s); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + fn fee_schedule_m(s: &FeeSchedule) -> math::FeeScheduleM { + math::FeeScheduleM { + base_bps: s.base_bps, + max_bps: s.max_bps, + ramp_secs: s.ramp_secs, + skew_mult_bps: s.skew_mult_bps, + } + } + + /// The fee rate for this market's next binary trade at time `now`: the + /// snapshotted `FeeSchedule` evaluated against the pre-trade reserves, + /// or the flat `fee_bps` for markets without one. The result is always + /// `< BPS`, which `calc_sell` requires. + fn fee_at(env: &Env, id: u64, m: &Market, now: u64) -> Result { + let fee = match Self::load_fee_schedule(env, id) { + Some(s) => math::effective_fee_bps( + &Self::fee_schedule_m(&s), + now, + m.close_time, + m.reserve_yes, + m.reserve_no, + )?, + None => m.fee_bps, + }; + if fee as i128 >= BPS { + return Err(Error::InvalidFee); + } + Ok(fee) + } + /// Runs the market's snapshotted breakers against a quote's /// outcome-relative post-trade reserves. Absent entry (legacy market) or /// an all-zero config means no checks. @@ -3331,4 +4375,1311 @@ mod multisig_account { env.set_auths(&[entry]); assert!(client.try_set_paused(&true).is_err()); } + // ----- #6: categorical (N-outcome) markets ----------------------------- + + /// One 100 USDC categorical market opening at `probs` (bps per outcome). + fn new_cat_market( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + probs: &[u32], + ) -> u64 { + let mut p = Vec::new(env); + for x in probs { + p.push_back(*x); + } + client.create_categorical_market( + creator, + &BytesN::from_array(env, &[43u8; 32]), + &BytesN::from_array(env, &[143u8; 32]), + &String::from_str(env, "ipfs://test-cat-market"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &(100 * 10_000_000_i128), + &p, + ) + } + + /// Contract balance always covers every outstanding claim: per outcome, + /// `reserve_i + Σ holder balances_i == sets_minted`, so the collateral + /// held equals `sets_minted + fees_accrued + lp_fees` while nothing has + /// been claimed yet. + fn assert_cat_solvent( + env: &Env, + client: &MarketCoreClient, + collateral: &Address, + market_id: u64, + ) { + let m = client.get_cat_market(&market_id); + let balance = + soroban_sdk::token::TokenClient::new(env, collateral).balance(&client.address); + assert_eq!( + balance, + m.sets_minted + m.fees_accrued + lp_fees_stored(env, client, market_id), + "cat market solvency broken" + ); + } + + #[test] + fn create_categorical_market_and_trade() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[5000, 3000, 2000]); + + // shared id space, categorical kind, binary views reject the id + assert_eq!(client.market_count(), 1); + assert_eq!(client.market_kind(&market_id), MarketKind::Categorical(3)); + assert_eq!( + client.try_get_market(&market_id), + Err(Ok(Error::MarketNotFound)) + ); + assert!(client.try_get_cat_market(&market_id).is_ok()); + + // seeded reserves and opening prices mirror the requested probabilities + let m = client.get_cat_market(&market_id); + assert_eq!( + m.reserves, + Vec::from_slice(&env, &[400_000_000_i128, 666_666_666, 1_000_000_000]) + ); + assert_eq!(m.sets_minted, 1_000_000_000); + // display prices land within a bps or two of the request (reciprocal + // rounding), and the floor slack stays within n units of BPS + let prices = client.get_prices_cat(&market_id); + let want = [5000i128, 3000, 2000]; + let mut sum: i128 = 0; + for (i, p) in prices.iter().enumerate() { + assert!((p as i128 - want[i]).abs() <= 2, "i={i} price={p}"); + sum += p as i128; + } + assert!(sum <= BPS && sum + 3 >= BPS); + + // creator keeps shares of the two likelier outcomes only + let creator_pos = client.get_cat_position(&market_id, &creator); + assert_eq!( + creator_pos.balances, + Vec::from_slice(&env, &[600_000_000_i128, 333_333_334, 0]) + ); + // a never-traded address reads a zero-filled position of length n + let empty = client.get_cat_position(&market_id, &Address::generate(&env)); + assert_eq!(empty.balances, Vec::from_slice(&env, &[0_i128, 0, 0])); + assert_cat_solvent(&env, &client, &collateral, market_id); + + // quote matches execution + let quote = client.quote_buy_cat(&market_id, &0u32, &(10 * 10_000_000_i128)); + let shares = client.buy_cat(&trader, &market_id, &0u32, &(10 * 10_000_000_i128), &0_i128); + assert_eq!(shares, quote); + + // buy: fee = ceil(100e6 * 1%) = 1e6, net = 99e6; other reserves grow + // by net, the bought reserve absorbs net minus shares + let m = client.get_cat_market(&market_id); + let net = 99_000_000_i128; + assert_eq!(m.reserves.get(1), Some(666_666_666 + net)); + assert_eq!(m.reserves.get(2), Some(1_000_000_000 + net)); + assert_eq!(m.reserves.get(0), Some(400_000_000 + net - shares)); + assert_eq!(m.sets_minted, 1_000_000_000 + net); + assert_eq!(m.fees_accrued, 1_000_000); + assert_eq!(lp_fees_stored(&env, &client, market_id), 0); + let pos = client.get_cat_position(&market_id, &trader); + assert_eq!(pos.balances, Vec::from_slice(&env, &[shares, 0, 0])); + assert_cat_solvent(&env, &client, &collateral, market_id); + + // sell 5 USDC back: gross = ceil(50e6 / 0.99) = 50_505_051 + let sold = client.sell_cat( + &trader, + &market_id, + &0u32, + &(5 * 10_000_000_i128), + &i128::MAX, + ); + assert!(sold > 0 && sold < shares); + let m = client.get_cat_market(&market_id); + assert_eq!(m.sets_minted, 1_099_000_000 - 50_505_051); + assert_eq!(m.fees_accrued, 1_000_000 + 505_051); + let pos = client.get_cat_position(&market_id, &trader); + assert_eq!(pos.balances.get(0), Some(shares - sold)); + assert_cat_solvent(&env, &client, &collateral, market_id); + } + + #[test] + fn cat_redeem_after_resolution() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[5000, 3000, 2000]); + let shares = client.buy_cat(&trader, &market_id, &2u32, &(10 * 10_000_000_i128), &0_i128); + + // settlement is resolver-only after close + assert_eq!( + client.try_resolve_cat(&market_id, &2u32), + Err(Ok(Error::NotClosedYet)) + ); + assert_eq!( + client.try_redeem_cat(&trader, &market_id), + Err(Ok(Error::NotFinal)) + ); + assert_eq!( + client.try_claim_pool_remainder_cat(&market_id), + Err(Ok(Error::NotFinal)) + ); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve_cat(&market_id, &2u32); + assert_eq!( + client.get_cat_market(&market_id).status, + CatStatus::Resolved(2) + ); + + // winner pays 1:1, loser pays 0, then the pool leftover goes to the LP + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let before = token.balance(&trader); + assert_eq!(client.redeem_cat(&trader, &market_id), shares); + assert_eq!(token.balance(&trader), before + shares); + // creator holds [600e6, 333_333_334, 0]: nothing on the winning index + assert_eq!(client.redeem_cat(&creator, &market_id), 0); + assert_eq!( + client.try_redeem_cat(&trader, &market_id), + Err(Ok(Error::NothingToRedeem)) + ); + + let pool = client.get_cat_market(&market_id).reserves.get(2).unwrap(); + assert_eq!(client.claim_pool_remainder_cat(&market_id), pool); + assert_eq!( + client.try_claim_pool_remainder_cat(&market_id), + Err(Ok(Error::AlreadyClaimed)) + ); + // only the unclaimed protocol fees remain locked in the contract + let m = client.get_cat_market(&market_id); + assert_eq!(token.balance(&client.address), m.fees_accrued); + let treasury_balance_before = token.balance(&treasury_of(&client)); + assert_eq!(client.sweep_fees(&market_id), m.fees_accrued); + assert_eq!(token.balance(&client.address), 0); + assert_eq!( + token.balance(&treasury_of(&client)), + treasury_balance_before + m.fees_accrued + ); + } + + fn treasury_of(client: &MarketCoreClient) -> Address { + client.get_config().treasury + } + + #[test] + fn cat_void_pays_proportional() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[2500, 2500, 2500, 2500]); + let m = client.get_cat_market(&market_id); + assert_eq!(m.reserves, Vec::from_slice(&env, &[1_000_000_000_i128; 4])); + // flat probabilities leave the creator no leftover shares at all + let creator_pos = client.get_cat_position(&market_id, &creator); + assert_eq!(creator_pos.balances, Vec::from_slice(&env, &[0_i128; 4])); + + let shares = client.buy_cat(&trader, &market_id, &1u32, &(10 * 10_000_000_i128), &0_i128); + client.void_market(&market_id); + assert_eq!(client.get_cat_market(&market_id).status, CatStatus::Void); + + // void pays floor(balances / n) per holder + assert_eq!(client.redeem_cat(&trader, &market_id), shares / 4); + assert_eq!( + client.try_redeem_cat(&creator, &market_id), + Err(Ok(Error::NothingToRedeem)) + ); + let reserves = client.get_cat_market(&market_id).reserves; + let pool = (reserves.get(0).unwrap() + + reserves.get(1).unwrap() + + reserves.get(2).unwrap() + + reserves.get(3).unwrap()) + / 4; + assert_eq!(client.claim_pool_remainder_cat(&market_id), pool); + client.sweep_fees(&market_id); + // floors can strand at most one base unit per void settlement + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + assert!(token.balance(&client.address) <= 1); + } + + #[test] + fn wrong_caller_cannot_resolve_cat() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[5000, 5000]); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + let attacker = Address::generate(&env); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "resolve_cat", + args: (market_id, 0u32).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_resolve_cat(&market_id, &0u32) + .is_err()); + } + + #[test] + fn cat_trade_and_settlement_validations() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[5000, 3000, 2000]); + + // outcome index must be < n on every entrypoint that takes one + assert_eq!( + client.try_buy_cat(&trader, &market_id, &3u32, &(10_000_000_i128), &0_i128), + Err(Ok(Error::InvalidOutcomeIndex)) + ); + assert_eq!( + client.try_quote_buy_cat(&market_id, &3u32, &(10_000_000_i128)), + Err(Ok(Error::InvalidOutcomeIndex)) + ); + assert_eq!( + client.try_sell_cat(&trader, &market_id, &9u32, &(1_000_000_i128), &i128::MAX), + Err(Ok(Error::InvalidOutcomeIndex)) + ); + + // slippage and share checks + assert_eq!( + client.try_buy_cat(&trader, &market_id, &0u32, &(10_000_000_i128), &i128::MAX), + Err(Ok(Error::SlippageExceeded)) + ); + assert_eq!( + client.try_sell_cat(&trader, &market_id, &0u32, &(10_000_000_i128), &i128::MAX), + Err(Ok(Error::InsufficientShares)) + ); + assert_eq!( + client.try_buy_cat(&trader, &market_id, &0u32, &0_i128, &0_i128), + Err(Ok(Error::InvalidAmount)) + ); + + // after close: trading stops, resolution validates the index first + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + assert_eq!( + client.try_buy_cat(&trader, &market_id, &0u32, &(10_000_000_i128), &0_i128), + Err(Ok(Error::MarketClosed)) + ); + assert_eq!( + client.try_resolve_cat(&market_id, &3u32), + Err(Ok(Error::InvalidOutcomeIndex)) + ); + client.resolve_cat(&market_id, &0u32); + // status is checked before the index, so a second resolve reports + // MarketNotOpen even with an out-of-range index + assert_eq!( + client.try_resolve_cat(&market_id, &9u32), + Err(Ok(Error::MarketNotOpen)) + ); + assert_eq!( + client.try_buy_cat(&trader, &market_id, &0u32, &(10_000_000_i128), &0_i128), + Err(Ok(Error::MarketNotOpen)) + ); + assert_eq!( + client.try_void_market(&market_id), + Err(Ok(Error::MarketNotOpen)) + ); + } + + #[test] + fn cat_create_validations() { + let (env, client, creator, _, _, _) = setup_market(); + + // sum must be exactly BPS + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &1_000_000_000_i128, + &Vec::from_slice(&env, &[6000u32, 3000]), + ), + Err(Ok(Error::InvalidProbability)) + ); + // every probability within MIN..=MAX + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &1_000_000_000_i128, + &Vec::from_slice(&env, &[9950u32, 50]), + ), + Err(Ok(Error::InvalidProbability)) + ); + // outcome count must be 2..=8 + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &1_000_000_000_i128, + &Vec::from_slice(&env, &[10_000u32]), + ), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &1_000_000_000_i128, + &Vec::from_slice(&env, &[1000u32; 9]), + ), + Err(Ok(Error::InvalidAmount)) + ); + // seed below the shared MIN_SEED + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &1_000_000_i128, + &Vec::from_slice(&env, &[5000u32, 5000]), + ), + Err(Ok(Error::InvalidAmount)) + ); + // past close time + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &env.ledger().timestamp(), + &1_000_000_000_i128, + &Vec::from_slice(&env, &[5000u32, 5000]), + ), + Err(Ok(Error::InvalidCloseTime)) + ); + + // boundary probabilities are accepted + let market_id = new_cat_market(&env, &client, &creator, &[100, 9900]); + let m = client.get_cat_market(&market_id); + assert_eq!( + m.reserves, + Vec::from_slice(&env, &[1_000_000_000_i128, 10_101_010]) + ); + let prices = client.get_prices_cat(&market_id); + let sum: u32 = prices.iter().sum(); + assert!((sum as i128) + 2 >= BPS && (sum as i128) <= BPS); + } + + #[test] + fn cat_fee_split_and_sweep_dispatch() { + let (env, client, creator, trader, _, collateral) = setup_market_full(5000, None); + let market_id = new_cat_market(&env, &client, &creator, &[5000, 5000]); + client.buy_cat(&trader, &market_id, &0u32, &(10 * 10_000_000_i128), &0_i128); + + // fee = 1e6, protocol = ceil(1e6 * 50%) = 5e5, LP = 5e5 + let m = client.get_cat_market(&market_id); + assert_eq!(m.fees_accrued, 500_000); + assert_eq!(lp_fees_stored(&env, &client, market_id), 500_000); + + // claim_lp_fees works on categorical ids; second call is a no-op + assert_eq!(client.claim_lp_fees(&market_id), 500_000); + assert_eq!(client.claim_lp_fees(&market_id), 0); + + // sweep_fees sends the protocol part to the treasury + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let treasury = treasury_of(&client); + let before = token.balance(&treasury); + assert_eq!(client.sweep_fees(&market_id), 500_000); + assert_eq!(token.balance(&treasury), before + 500_000); + } + + #[test] + fn cat_eight_outcomes_smoke() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[1250; 8]); + assert_eq!(client.market_kind(&market_id), MarketKind::Categorical(8)); + let prices = client.get_prices_cat(&market_id); + assert_eq!(prices, Vec::from_slice(&env, &[1250u32; 8])); + + // one small buy per outcome exercises every branch of the product + let sizes = [1i128, 2, 3, 4, 5, 6, 7, 8]; + for (i, lots) in sizes.iter().enumerate() { + let shares = client.buy_cat( + &trader, + &market_id, + &(i as u32), + &(lots * 10_000_000_i128), + &0_i128, + ); + assert!(shares > 0); + } + assert_cat_solvent(&env, &client, &collateral, market_id); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve_cat(&market_id, &7u32); + let pos = client.get_cat_position(&market_id, &trader); + assert_eq!( + client.redeem_cat(&trader, &market_id), + pos.balances.get(7).unwrap() + ); + client.claim_pool_remainder_cat(&market_id); + client.sweep_fees(&market_id); + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + assert_eq!(token.balance(&client.address), 0); + } + + /// The resource ask from the categorical issue: the widest single trade + /// (an 8-outcome buy — the longest mul_div loop and `Vec` writes) + /// metered against the mainnet per-transaction limits. Native-contract + /// numbers; the wasm build only adds VM overhead on top. + #[test] + fn cat_eight_outcomes_resource_budget() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[1250; 8]); + env.cost_estimate().budget().reset_unlimited(); + client.buy_cat(&trader, &market_id, &0u32, &(10 * 10_000_000_i128), &0_i128); + let used = env.cost_estimate().resources(); + std::println!( + "buy_cat n=8: {} instructions, {} memory bytes, {} write bytes, {} write entries", + used.instructions, + used.mem_bytes, + used.write_bytes, + used.write_entries + ); + // Mainnet per-transaction limits (soroban-sdk 27 + // `InvocationResourceLimits::mainnet`): 400M instructions, 40 MiB + // memory, 132_096 write bytes; stay under 60% of each. + assert!(used.instructions <= 240_000_000); + assert!(used.mem_bytes <= 25_165_824); + assert!(used.write_bytes <= 79_257); + } + + // ----- #7: scalar (range) markets -------------------------------------- + + /// One 100 USDC scalar market staked on the display range + /// `[100_000_000, 250_000_000]` (the `scalar_meta` golden vector values), + /// opening at 50%. + fn new_scalar_market(env: &Env, client: &MarketCoreClient, creator: &Address) -> u64 { + client.create_scalar_market( + creator, + &BytesN::from_array(env, &[44u8; 32]), + &BytesN::from_array(env, &[144u8; 32]), + &String::from_str(env, "ipfs://test-scalar-market"), + &Category::Macro, + &(env.ledger().timestamp() + 60_000), + &(100 * 10_000_000_i128), + &5000u32, + &100_000_000_i128, + &250_000_000_i128, + ) + } + + #[test] + fn create_scalar_market_validates_range_and_stores_meta() { + let (env, client, creator, _, _, _) = setup_market(); + for (lower, upper) in [(100_i128, 100_i128), (200_i128, 100_i128)] { + assert_eq!( + client.try_create_scalar_market( + &creator, + &BytesN::from_array(&env, &[44u8; 32]), + &BytesN::from_array(&env, &[144u8; 32]), + &String::from_str(&env, "ipfs://scalar"), + &Category::Macro, + &(env.ledger().timestamp() + 60_000), + &(100 * 10_000_000_i128), + &5000u32, + &lower, + &upper, + ), + Err(Ok(Error::InvalidAmount)) + ); + } + + let market_id = new_scalar_market(&env, &client, &creator); + assert_eq!( + client.get_scalar_meta(&market_id), + ScalarMeta { + lower: 100_000_000, + upper: 250_000_000, + } + ); + // a scalar market reuses the binary pool and position storage; only + // the final status and the display metadata tell it apart + assert_eq!(client.market_kind(&market_id), MarketKind::Binary); + assert!(client.try_get_cat_market(&market_id).is_err()); + // binary ids carry no scalar metadata + let binary = new_market(&env, &client, &creator, 5000); + assert_eq!( + client.try_get_scalar_meta(&binary), + Err(Ok(Error::MarketNotFound)) + ); + } + + #[test] + fn wrong_caller_cannot_resolve_scalar() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = new_scalar_market(&env, &client, &creator); + + // not closed yet, even for the configured resolver + assert_eq!( + client.try_resolve_scalar(&market_id, &6_000u32), + Err(Ok(Error::NotClosedYet)) + ); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + let not_resolver = Address::generate(&env); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: ¬_resolver, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "resolve_scalar", + args: (market_id, 6_000u32).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_resolve_scalar(&market_id, &6_000u32) + .is_err()); + env.mock_all_auths(); + + client.resolve_scalar(&market_id, &6_250u32); + assert_eq!( + client.get_market(&market_id).status, + MarketStatus::ResolvedScalar(6_250) + ); + // a final market cannot be re-resolved + assert_eq!( + client.try_resolve_scalar(&market_id, &5_000u32), + Err(Ok(Error::MarketNotOpen)) + ); + } + + #[test] + fn scalar_redeem_long_and_short() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_scalar_market(&env, &client, &creator); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + client.buy( + &trader, + &market_id, + &Outcome::No, + &(10 * 10_000_000_i128), + &0_i128, + ); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve_scalar(&market_id, &6_250u32); + + let pos = client.get_position(&market_id, &trader); + assert!(pos.yes > 0 && pos.no > 0); + // every share stays covered by the pool: reserve + positions == sets + let m = client.get_market(&market_id); + let lp = client.get_position(&market_id, &creator); + assert_eq!(m.reserve_yes + pos.yes + lp.yes, m.sets_minted); + assert_eq!(m.reserve_no + pos.no + lp.no, m.sets_minted); + + // both legs floor, exactly as `scalar_payout` computes them + let want = pos.yes * 6_250 / BPS + pos.no * 3_750 / BPS; + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let before = token.balance(&trader); + assert_eq!(client.redeem(&trader, &market_id), want); + assert_eq!(token.balance(&trader), before + want); + assert_eq!( + client.get_position(&market_id, &trader), + Position { yes: 0, no: 0 } + ); + assert_eq!( + client.try_redeem(&trader, &market_id), + Err(Ok(Error::NothingToRedeem)) + ); + } + + #[test] + fn scalar_claim_pool_remainder() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_scalar_market(&env, &client, &creator); + 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_scalar(&market_id, &6_250u32); + + // the LP claims both reserves at the final payout; with the default + // all-protocol split there are no LP fees on top + let m = client.get_market(&market_id); + let want = m.reserve_yes * 6_250 / BPS + m.reserve_no * 3_750 / BPS; + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let creator_before = token.balance(&creator); + assert_eq!(client.claim_pool_remainder(&market_id), want); + assert_eq!(token.balance(&creator), creator_before + want); + assert!(client.get_market(&market_id).lp_claimed); + assert_eq!( + client.try_claim_pool_remainder(&market_id), + Err(Ok(Error::AlreadyClaimed)) + ); + + // drain the market: only rounding dust may stay in the contract (the + // trader's and the pool's payouts each floor two legs, so the two + // losses sum to under two units). The creator holds no shares — at a + // 50/50 seed the reserves absorb the whole seed — so nothing else is + // outstanding. + client.redeem(&trader, &market_id); + client.sweep_fees(&market_id); + assert!(token.balance(&client.address) <= 1); + } + + #[test] + fn resolve_scalar_rejects_bps_above_max() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = new_scalar_market(&env, &client, &creator); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + + assert_eq!( + client.try_resolve_scalar(&market_id, &10_001u32), + Err(Ok(Error::InvalidProbability)) + ); + assert_eq!(client.get_market(&market_id).status, MarketStatus::Open); + + // the 0 and BPS boundaries are valid payouts + client.resolve_scalar(&market_id, &10_000u32); + assert_eq!( + client.get_market(&market_id).status, + MarketStatus::ResolvedScalar(10_000) + ); + } + + #[test] + fn scalar_extremes_match_binary_resolution() { + let (env, client, creator, trader, _, _) = setup_market(); + // four identical 5000 markets with identical long+short trades + let mut ids = [0u64; 4]; + for id in ids.iter_mut() { + *id = new_scalar_market(&env, &client, &creator); + client.buy(&trader, id, &Outcome::Yes, &(10 * 10_000_000_i128), &0_i128); + client.buy(&trader, id, &Outcome::No, &(5 * 10_000_000_i128), &0_i128); + } + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve_scalar(&ids[0], &0u32); + client.resolve(&ids[1], &Outcome::No); + client.resolve_scalar(&ids[2], &10_000u32); + client.resolve(&ids[3], &Outcome::Yes); + + let pos = |id: u64| client.get_position(&id, &trader); + assert_eq!(pos(ids[0]), pos(ids[1])); + assert_eq!(pos(ids[2]), pos(ids[3])); + + let short_wins = pos(ids[0]).no; + let long_wins = pos(ids[2]).yes; + let scalar_zero = client.redeem(&trader, &ids[0]); + let binary_no = client.redeem(&trader, &ids[1]); + let scalar_max = client.redeem(&trader, &ids[2]); + let binary_yes = client.redeem(&trader, &ids[3]); + assert_eq!(scalar_zero, binary_no); + assert_eq!(scalar_max, binary_yes); + assert_eq!(scalar_zero, short_wins); + assert_eq!(scalar_max, long_wins); + } + + // ----- #9: dynamic fee model (time-to-close + skew) --------------------- + + fn fee_sched(base_bps: u32, max_bps: u32, ramp_secs: u64, skew_mult_bps: u32) -> FeeSchedule { + FeeSchedule { + base_bps, + max_bps, + ramp_secs, + skew_mult_bps, + } + } + + #[test] + fn buy_uses_dynamic_fee_near_close() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(100, 1_000, 10_000, 0)); + let id = new_market(&env, &client, &creator, 5000); // closes at t = 60_000 + assert_eq!( + client.fee_schedule(&id), + Some(fee_sched(100, 1_000, 10_000, 0)), + "creation snapshots the default schedule" + ); + + // outside the ramp window (opens at close - 10_000): flat base fee + assert_eq!(client.current_fee_bps(&id), 100); + let buy = 10 * 10_000_000_i128; + let shares_a = client.buy(&trader, &id, &Outcome::Yes, &buy, &0_i128); + // read events before any other invocation — only the trade's linger + let events_a = env.events().all().filter_by_contract(&client.address); + let m_a = client.get_market(&id); + assert_eq!(m_a.fees_accrued, 1_000_000); // ceil(1e8 * 100 / 1e4) + let trade_a = Trade { + market_id: id, + trader: trader.clone(), + outcome: Outcome::Yes, + is_buy: true, + collateral: buy, + shares: shares_a, + fee: 1_000_000, + reserve_yes: m_a.reserve_yes, + reserve_no: m_a.reserve_no, + }; + let trade_a_v2 = TradeV2 { + market_id: id, + trader: trader.clone(), + outcome: Outcome::Yes, + is_buy: true, + collateral: buy, + shares: shares_a, + fee: 1_000_000, + fee_bps: 100, + reserve_yes: m_a.reserve_yes, + reserve_no: m_a.reserve_no, + }; + assert_eq!( + events_a, + [ + soroban_sdk::Event::to_xdr(&trade_a, &env, &client.address), + soroban_sdk::Event::to_xdr(&trade_a_v2, &env, &client.address), + ], + "Trade and TradeV2 both describe the base-rate trade" + ); + + // 60s before close, inside the ramp: t = (10_000 - 60) / 10_000 of + // the 900 bps band -> 894, so the fee is 994 bps + env.ledger().set_timestamp(59_940); + assert_eq!(client.current_fee_bps(&id), 994); + let shares_b = client.buy(&trader, &id, &Outcome::Yes, &buy, &0_i128); + let events_b = env.events().all().filter_by_contract(&client.address); + let m_b = client.get_market(&id); + assert_eq!(m_b.fees_accrued, 1_000_000 + 9_940_000); + let trade_b = Trade { + market_id: id, + trader: trader.clone(), + outcome: Outcome::Yes, + is_buy: true, + collateral: buy, + shares: shares_b, + fee: 9_940_000, + reserve_yes: m_b.reserve_yes, + reserve_no: m_b.reserve_no, + }; + let trade_b_v2 = TradeV2 { + market_id: id, + trader: trader.clone(), + outcome: Outcome::Yes, + is_buy: true, + collateral: buy, + shares: shares_b, + fee: 9_940_000, + fee_bps: 994, + reserve_yes: m_b.reserve_yes, + reserve_no: m_b.reserve_no, + }; + assert_eq!( + events_b, + [ + soroban_sdk::Event::to_xdr(&trade_b, &env, &client.address), + soroban_sdk::Event::to_xdr(&trade_b_v2, &env, &client.address), + ], + "near close the same trade pays 10x the base fee" + ); + } + + #[test] + fn quote_buy_equals_buy_same_ledger() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(80, 600, 3_600, 250)); + let id = new_market(&env, &client, &creator, 5000); // closes at t = 60_000 + + // mid-ramp: t = 1_800/3_600 of the 520 bps band = 260 -> fee 340 + env.ledger().set_timestamp(58_200); + assert_eq!(client.current_fee_bps(&id), 340); + let buy = 7 * 10_000_000_i128; + let quoted = client.quote_buy(&id, &Outcome::Yes, &buy); + let shares = client.buy(&trader, &id, &Outcome::Yes, &buy, &0_i128); + assert_eq!(quoted, shares, "quote and same-ledger buy must agree"); + + let sell_out = 10_000_000_i128; + let quoted_sell = client.quote_sell(&id, &Outcome::Yes, &sell_out); + let shares_in = client.sell(&trader, &id, &Outcome::Yes, &sell_out, &i128::MAX); + assert_eq!(quoted_sell, shares_in, "sell quote must agree too"); + } + + #[test] + fn legacy_market_keeps_flat_fee() { + let (env, client, creator, trader, _, _) = setup_market(); + let id = new_market(&env, &client, &creator, 5000); + assert_eq!(client.fee_schedule(&id), None, "no default -> no snapshot"); + + // a schedule set after creation never retro-fits existing markets + client.set_default_fee_schedule(&fee_sched(900, 1_000, 0, 1_000)); + assert_eq!(client.fee_schedule(&id), None); + assert_eq!(client.current_fee_bps(&id), 100); + + let shares = client.buy( + &trader, + &id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + assert!(shares > 0); + assert_eq!(client.get_market(&id).fees_accrued, 1_000_000); + } + + #[test] + fn categorical_market_keeps_flat_fee() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(900, 1_000, 0, 1_000)); + let id = new_cat_market(&env, &client, &creator, &[5000, 5000]); + assert_eq!( + client.current_fee_bps(&id), + 100, + "categorical markets always charge their flat fee" + ); + client.buy_cat(&trader, &id, &0u32, &(10 * 10_000_000_i128), &0_i128); + assert_eq!(client.get_cat_market(&id).fees_accrued, 1_000_000); + } + + #[test] + fn set_default_fee_schedule_rejects_out_of_bounds() { + let (_env, client, _, _, _, _) = setup_market(); + let cases = [ + fee_sched(500, 499, 0, 0), // base > max + fee_sched(0, 1_001, 0, 0), // max > MAX_FEE_BPS + fee_sched(0, 500, math::MAX_RAMP_SECS + 1, 0), // ramp > 30 days + fee_sched(0, 500, 0, math::MAX_SKEW_MULT_BPS + 1), // skew > cap + ]; + for c in cases { + assert_eq!( + client.try_set_default_fee_schedule(&c), + Err(Ok(Error::InvalidFee)), + "schedule {c:?} must be rejected" + ); + } + // the boundary values themselves are accepted + client.set_default_fee_schedule(&fee_sched( + 0, + 1_000, + math::MAX_RAMP_SECS, + math::MAX_SKEW_MULT_BPS, + )); + } + + #[test] + fn wrong_caller_cannot_set_default_fee_schedule() { + let (env, client, _, trader, _, _) = setup_market(); + let schedule = fee_sched(10, 100, 0, 0); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: &trader, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "set_default_fee_schedule", + args: (schedule,).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_set_default_fee_schedule(&schedule) + .is_err()); + } + + #[test] + fn create_market_with_fees_snapshots_and_caps() { + let (env, client, creator, _, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(50, 500, 1_000, 100)); + let close = env.ledger().timestamp() + 60_000; + let args = |env: &Env, schedule: FeeSchedule| { + ( + BytesN::from_array(env, &[22u8; 32]), + BytesN::from_array(env, &[122u8; 32]), + String::from_str(env, "ipfs://explicit-fees"), + Category::Crypto, + close, + 100 * 10_000_000_i128, + 5000u32, + schedule, + ) + }; + + let explicit = fee_sched(20, 400, 0, 0); + let (q, r, u, c, s, seed, yes, sch) = args(&env, explicit); + let id = client.create_market_with_fees(&creator, &q, &r, &u, &c, &s, &seed, &yes, &sch); + assert_eq!(client.fee_schedule(&id), Some(explicit)); + assert_eq!(client.current_fee_bps(&id), 20); + + // above the protocol policy cap (default max 500) / hard cap / invert + for bad in [ + fee_sched(0, 501, 0, 0), + fee_sched(0, 1_001, 0, 0), + fee_sched(100, 50, 0, 0), + ] { + let (q, r, u, c, s, seed, yes, sch) = args(&env, bad); + assert_eq!( + client.try_create_market_with_fees(&creator, &q, &r, &u, &c, &s, &seed, &yes, &sch), + Err(Ok(Error::InvalidFee)), + "schedule {bad:?} must be rejected" + ); + } + // rejected attempts must not burn ids + assert_eq!(client.market_count(), 1); + } + + #[test] + fn quote_buy_at_simulates_future_fee() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(100, 1_000, 10_000, 0)); + let id = new_market(&env, &client, &creator, 5000); // closes at t = 60_000 + let buy = 5 * 10_000_000_i128; + + let now_quote = client.quote_buy(&id, &Outcome::Yes, &buy); + let late_quote = client.quote_buy_at(&id, &Outcome::Yes, &buy, &59_999); + assert!(late_quote < now_quote, "a later timestamp pays more fee"); + // quotes past close clamp to the ramp end instead of failing + let after_close = client.quote_buy_at(&id, &Outcome::Yes, &buy, &60_001); + assert!(after_close < late_quote); + + env.ledger().set_timestamp(59_999); + let actual = client.buy(&trader, &id, &Outcome::Yes, &buy, &0_i128); + assert_eq!(actual, late_quote, "quote_buy_at matches the later buy"); + } + + #[test] + fn split_trades_pay_at_least_single_trade_fee_minus_rounding() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(100, 1_000, 0, 1_000)); + let id_single = new_market(&env, &client, &creator, 5000); + let id_split = new_market(&env, &client, &creator, 5000); + let unit = 10_000_000_i128; + + client.buy(&trader, &id_single, &Outcome::Yes, &(10 * unit), &0_i128); + for _ in 0..10 { + client.buy(&trader, &id_split, &Outcome::Yes, &unit, &0_i128); + } + + let single_fee = client.get_market(&id_single).fees_accrued; + let split_fee = client.get_market(&id_split).fees_accrued; + // both pools open at exactly 50/50 and pay the base rate first, so + // the single 10-unit buy costs exactly 1_000_000 (100 bps of 1e8) + assert_eq!(single_fee, 1_000_000); + // every split piece pays >= the base rate, and each unit is an exact + // 1_000x scaling, so splitting can never undercut the lump sum + assert!( + split_fee >= single_fee, + "split {split_fee} < single {single_fee}" + ); + } +} + +/// Pins the exact XDR bytes behind stored binary-market state and the tag +/// order of every append-only enum. A change to field names, field order, +/// variant names, or the position of an appended variant changes the encoding +/// and strands every live market, so a failure here must never be "fixed" by +/// editing the vectors: add a new struct or an end-appended enum variant +/// instead (see the append-only note on `DataKey`). The six `u64`-payload +/// key vectors (`key_market`, `key_band`, `key_kind`, `key_cat_market`, +/// `key_scalar`, `key_fee_schedule`) are pairwise distinct, which is what +/// proves no variant moved when the categorical, scalar and fee-schedule +/// keys were appended. +#[cfg(test)] +mod golden { + use super::*; + use soroban_sdk::xdr::ToXdr; + use std::{format, string::String as StdString}; + + const G1: &str = "GBAKBHXK7SV7C3HGY3DDYB4QWUEGL52SEF6OO77LAD672JVXD4QCYJLF"; + const G2: &str = "GCKBJQ6633JFYSFV6HHF2FQUN4QUX5PVE63PODYIKO2CE3RCXAF5NZCW"; + + fn hex_of(env: &Env, xdr: &soroban_sdk::Bytes) -> StdString { + let hash: BytesN<32> = env.crypto().sha256(xdr).into(); + hash.to_array().iter().map(|b| format!("{b:02x}")).collect() + } + + fn fixed_market(env: &Env) -> Market { + Market { + creator: Address::from_str(env, G1), + question_hash: BytesN::from_array(env, &[1u8; 32]), + resolution_hash: BytesN::from_array(env, &[2u8; 32]), + meta_uri: String::from_str(env, "ipfs://golden"), + category: Category::Crypto, + close_time: 1_700_000_000, + fee_bps: 100, + status: MarketStatus::Resolved(Outcome::Yes), + reserve_yes: 1_234_567, + reserve_no: 7_654_321, + sets_minted: 8_888_888, + fees_accrued: 1_000, + lp_claimed: false, + } + } + + fn fixed_cat_market(env: &Env) -> CatMarket { + CatMarket { + creator: Address::from_str(env, G2), + question_hash: BytesN::from_array(env, &[1u8; 32]), + resolution_hash: BytesN::from_array(env, &[2u8; 32]), + meta_uri: String::from_str(env, "ipfs://golden"), + category: Category::Sports, + close_time: 1_700_000_000, + fee_bps: 100, + status: CatStatus::Resolved(2), + reserves: Vec::from_slice(env, &[11i128, 22, 33]), + sets_minted: 44, + fees_accrued: 5, + lp_claimed: true, + } + } + + #[test] + fn storage_encoding_matches_golden_vectors() { + let env = Env::default(); + let vectors: &[(&str, soroban_sdk::Bytes, &str)] = &[ + ( + "market", + fixed_market(&env).to_xdr(&env), + "36188085f458f8786a726cbb5e0e08ef6904e4374fbbbf3e7de03907820b0339", + ), + ( + "cat_market", + fixed_cat_market(&env).to_xdr(&env), + "27a6f739035f3c381e5059fcd136bbae2900664b9c5ba0f20ea1bf93aa3bccf9", + ), + ( + "position", + Position { yes: 5, no: 6 }.to_xdr(&env), + "40a402f6a3cc8da11230556272326a297af445d24981444ee3155354096735c0", + ), + ( + "cat_position", + CatPosition { + balances: Vec::from_slice(&env, &[7i128, 8, 9]), + } + .to_xdr(&env), + "7a72eca94d0dd95da8d6af1c9403d91751010a1a80d555ed82893816aa8f26eb", + ), + ( + "fee_terms", + FeeTerms { + recipient: Address::from_str(&env, G1), + protocol_share_bps: 5_000, + } + .to_xdr(&env), + "dd4e5aff38dae3986b90290f57c5e6604b603b4be9466aa078d7a4ab1e6fc6d2", + ), + ( + "status_open", + MarketStatus::Open.to_xdr(&env), + "f5bb24256f8d569f3e445b6fdb70770cb4e3c5a7b153bcb99bdeec43d7d15a32", + ), + ( + "status_resolved_yes", + MarketStatus::Resolved(Outcome::Yes).to_xdr(&env), + "2b321394eba29f0c5f18fd2a221d68da481a61805ccd42b5ab26689fb66e7a05", + ), + ( + "status_resolved_no", + MarketStatus::Resolved(Outcome::No).to_xdr(&env), + "bc79cb5737cf6e7a62d6cea95f96197f02826e3abfe1de0b36d8c26240d09d5e", + ), + ( + "status_void", + MarketStatus::Void.to_xdr(&env), + "84fb1b711f446432537ce5420b5b68f7e7fcc259b43b4fd1cd33ba2cfa3bc847", + ), + ( + "status_resolved_scalar", + MarketStatus::ResolvedScalar(6_250).to_xdr(&env), + "4f228882af5807e96236df11e60796ad737ead4e5a8e18a3d72a29a1a1500321", + ), + ( + "cat_open", + CatStatus::Open.to_xdr(&env), + "f5bb24256f8d569f3e445b6fdb70770cb4e3c5a7b153bcb99bdeec43d7d15a32", + ), + ( + "cat_resolved", + CatStatus::Resolved(2).to_xdr(&env), + "1affbe5b375f59f3eb26c012a92d4694d50ca88b55d070c193a60b891ba6f003", + ), + ( + "cat_void", + CatStatus::Void.to_xdr(&env), + "84fb1b711f446432537ce5420b5b68f7e7fcc259b43b4fd1cd33ba2cfa3bc847", + ), + ( + "kind_binary", + MarketKind::Binary.to_xdr(&env), + "2c4dbaa6a4d48e791b13a8ace9144759ebc4eccaed186c20ab8eb644d152c147", + ), + ( + "kind_categorical", + MarketKind::Categorical(8).to_xdr(&env), + "f36c32da22305b016f5397af49f91f8afe624c3c604b9007194c2d150c97c8e1", + ), + ( + "key_admin", + DataKey::Admin.to_xdr(&env), + "c551fd4e3b592f8f79eb0e6d45db3ef38efaff551e0f97ad2f8179a51ce1706e", + ), + ( + "key_collateral", + DataKey::Collateral.to_xdr(&env), + "be058f1cb09249de00e6f9c9317c9eb05695901ac133b690a633c3ccc8d0e746", + ), + ( + "key_treasury", + DataKey::Treasury.to_xdr(&env), + "2c3c4d715ae64eb2118c999c11d775074ef37164e99c5886f14066734b268079", + ), + ( + "key_resolver", + DataKey::Resolver.to_xdr(&env), + "220e075fa80aa713117ff5c970b113b23fe3101492c7f615a9199f0d009662c1", + ), + ( + "key_default_fee_bps", + DataKey::DefaultFeeBps.to_xdr(&env), + "5757b4fac1b45e48d21f51c49f2001d79cfa537e4e66c1c20ba177226abf96ac", + ), + ( + "key_protocol_fee_share_bps", + DataKey::ProtocolFeeShareBps.to_xdr(&env), + "99f2c34332a3a712c42188a99cdcc27e2b4df902520bd82958436f5ad2eb00b1", + ), + ( + "key_default_breaker", + DataKey::DefaultBreaker.to_xdr(&env), + "27a5c8f3ec0b32815e01af29c993d0e81434a9c956b1862ce60a37775577a442", + ), + ( + "key_paused", + DataKey::Paused.to_xdr(&env), + "a2c6f2f4702c89a770af96e0f55eea0899b67fa595bf8791c1c804a30ff930ac", + ), + ( + "key_next_id", + DataKey::NextId.to_xdr(&env), + "79dcd4a0e0d838ee8fd46ded92313aaf4bed264c9aa239efd693bae70330aa6f", + ), + ( + "key_market", + DataKey::Market(7).to_xdr(&env), + "9468d585aeb83df47ea506d9f44a1b86af6b3c46f85b5e876d9aaf5ac683ba3d", + ), + ( + "key_position", + DataKey::Position(7, Address::from_str(&env, G1)).to_xdr(&env), + "389be98ced1e5bda6c6fccc2fab7e688a757770474dd47023728dbe9abccaf99", + ), + ( + "key_fee_terms", + DataKey::FeeTerms(7).to_xdr(&env), + "49d98204299a993c50e4e168d913a24bba1082e97df589c97523ad3438d52564", + ), + ( + "key_lp_fees", + DataKey::LpFees(7).to_xdr(&env), + "382ddac8a43bcca866ff597742e3837f7361df84a7f37f11666d0713cb404761", + ), + ( + "key_breaker", + DataKey::Breaker(7).to_xdr(&env), + "cf71f552b57ae9706bed63643a832bb463163267063da6472d351f5f759fcc11", + ), + ( + "key_band", + DataKey::Band(7).to_xdr(&env), + "0cfc353eaf950432b76cc69f5c4b3c81125a9c027a736c8194839c3cfb37e04e", + ), + ( + "key_kind", + DataKey::Kind(7).to_xdr(&env), + "e024376fceb4ca0a8d2f4e24564361d9ede75ad0de688939ea36b23b4868ad35", + ), + ( + "key_cat_market", + DataKey::CatMarket(7).to_xdr(&env), + "ecc36fe74d9bb69ab421b662dc27d95863838a5c75718b11e696a1213d888a79", + ), + ( + "key_cat_position", + DataKey::CatPosition(7, Address::from_str(&env, G1)).to_xdr(&env), + "abe25f5135bf271f295ab53dea568e9ed8733b7b2e95d92daead139b3fdb6f83", + ), + ( + "key_scalar", + DataKey::Scalar(7).to_xdr(&env), + "c37848092184e76aa7e4f3ef72f6255068d91b57286a1f63bef9be9007620234", + ), + ( + "scalar_meta", + ScalarMeta { + lower: 100_000_000, + upper: 250_000_000, + } + .to_xdr(&env), + "0c8fa296ba44740c03754de99e5ce49eca58515891147d50ab2cbac7a6da98d0", + ), + ( + "key_fee_schedule", + DataKey::FeeSchedule(7).to_xdr(&env), + "28ad21a72d90132aa173d68e3b5fb58b2c75df13ae78e40ad87f0001b16288ad", + ), + ( + "key_default_fee_schedule", + DataKey::DefaultFeeSchedule.to_xdr(&env), + "50c22f14b3c7648ecff9a9a8a5d98a91c2dc2b833fb685790288c59689c2d220", + ), + ( + "fee_schedule", + FeeSchedule { + base_bps: 100, + max_bps: 1_000, + ramp_secs: 86_400, + skew_mult_bps: 500, + } + .to_xdr(&env), + "11b04b09c719862e0a9f2b8f9689c38c302f4ac02d7f9d92ab84e14f99c1a608", + ), + ]; + for (label, xdr, expected) in vectors { + assert_eq!(&hex_of(&env, xdr), expected, "{label} encoding moved"); + } + // identical tag+payload pairs legitimately hash the same; the six + // u64-payload keys above must all differ, proving no tag shifted + let u64_keys = [ + hex_of(&env, &DataKey::Market(7).to_xdr(&env)), + hex_of(&env, &DataKey::Band(7).to_xdr(&env)), + hex_of(&env, &DataKey::Kind(7).to_xdr(&env)), + hex_of(&env, &DataKey::CatMarket(7).to_xdr(&env)), + hex_of(&env, &DataKey::Scalar(7).to_xdr(&env)), + hex_of(&env, &DataKey::FeeSchedule(7).to_xdr(&env)), + ]; + for i in 0..u64_keys.len() { + for j in (i + 1)..u64_keys.len() { + assert_ne!(u64_keys[i], u64_keys[j], "DataKey tags collided"); + } + } + } } diff --git a/oracledesk-stellar/contracts/market-core/src/math.rs b/oracledesk-stellar/contracts/market-core/src/math.rs index a09574c..4d3cd7c 100644 --- a/oracledesk-stellar/contracts/market-core/src/math.rs +++ b/oracledesk-stellar/contracts/market-core/src/math.rs @@ -19,6 +19,13 @@ pub const BPS: i128 = 10_000; pub const MAX_SETS: i128 = 1_000_000_000_000_000; // 1e15 = 100M USDC (7 dp) pub const MIN_PROB_BPS: u32 = 100; // 1% pub const MAX_PROB_BPS: u32 = 9_900; // 99% +/// Longest dynamic-fee ramp: 30 days. Caps how far before close an admin +/// can start the time component. +pub const MAX_RAMP_SECS: u64 = 30 * 86_400; +/// Largest skew multiplier: 1_000 means the skew term grows 1 bp per +/// 1_000 bps (10 points) of price distance from 50%, up to 5x the +/// multiplier at the extreme prices. +pub const MAX_SKEW_MULT_BPS: u32 = 1_000; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum MathError { @@ -32,6 +39,10 @@ pub enum Settlement { Yes, No, Void, + /// Scalar market: LONG pays `floor(yes * p / BPS)`, SHORT pays + /// `floor(no * (BPS - p) / BPS)`, `p` in bps in `0..=BPS`. + /// `Scalar(BPS)` is equivalent to `Yes`, `Scalar(0)` to `No`. + Scalar(u32), } #[derive(Debug, PartialEq, Eq, Clone, Copy)] @@ -113,6 +124,76 @@ pub fn price_after( } } +/// Pure mirror of the contract's `FeeSchedule` (see `lib.rs`), phrased with +/// plain numbers so `model/fpmm_model.py` and `agents/core/fpmm.ts` can +/// reproduce `effective_fee_bps` bit-for-bit. Field order mirrors the XDR. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct FeeScheduleM { + pub base_bps: u32, + pub max_bps: u32, + pub ramp_secs: u64, + pub skew_mult_bps: u32, +} + +/// The fee rate a trade pays under `s` at time `now` with the pool at +/// `(r_yes, r_no)`: `min(max, base + t + skew)` where +/// +/// - `t` ramps linearly from 0 to `max - base` over the last `ramp_secs` +/// before `close` (0 when `ramp_secs == 0` or the window has not opened; +/// `max - base` once `now >= close`), and +/// - `skew = |p - 5_000| * skew_mult_bps / 1_000` with `p` the floored +/// pre-trade YES price — so a balanced pool pays `base` and a one-sided +/// pool pays up to `base + 5 * skew_mult_bps`. +/// +/// Both floors round **down**, i.e. towards the trader: unlike the pool +/// math (`calc_buy`/`calc_sell` always round for the pool) the fee only +/// needs to be deterministic across the contract, the Python model and the +/// TS mirror, and a sub-bp gift to the trader cannot break the invariant. +/// The pre-trade price keeps the fee independent of the trade being priced +/// (a post-trade price would be circular with `calc_buy`). +/// +/// Guarantees `base <= result <= max` for every schedule with +/// `base <= max < BPS`; any other schedule is rejected. `now >= close` +/// (possible in quotes) clamps to the ramp end. All arithmetic is `i128`, +/// so no input pair can overflow. +pub fn effective_fee_bps( + s: &FeeScheduleM, + now: u64, + close: u64, + r_yes: i128, + r_no: i128, +) -> Result { + if s.base_bps > s.max_bps || s.max_bps as i128 >= BPS { + return Err(MathError::InvalidInput); + } + let base = s.base_bps as i128; + let max = s.max_bps as i128; + let delta = max - base; + let t = if s.ramp_secs == 0 { + 0 + } else { + let ramp = s.ramp_secs as i128; + let into_ramp = if now >= close { + ramp + } else { + s.ramp_secs.saturating_sub(close - now) as i128 + }; + mul_div_floor(into_ramp, delta, ramp)? + }; + let p = price_yes_bps(r_yes, r_no)?; + let d = (p - 5_000).abs(); + let skew = mul_div_floor(d, s.skew_mult_bps as i128, 1_000)?; + let total = base + .checked_add(t) + .and_then(|v| v.checked_add(skew)) + .ok_or(MathError::Overflow)?; + Ok(if total > max { + max as u32 + } else { + total as u32 + }) +} + /// Pool opens at `yes_bps` probability with `seed` complete sets minted. /// The pool keeps fewer of the *likelier* outcome; the creator keeps the rest. pub fn seed_reserves(seed: i128, yes_bps: u32) -> Result { @@ -327,9 +408,25 @@ pub fn settle_value(kind: Settlement, yes: i128, no: i128) -> i128 { Settlement::Yes => yes, Settlement::No => no, Settlement::Void => (yes + no) / 2, + Settlement::Scalar(p) => scalar_payout(p, yes, no), } } +/// Collateral value of a (yes, no) share pair in a scalar market whose +/// resolved value maps to `payout_long_bps` LONG per complete set. Both legs +/// floor independently, so no holder ever receives more than their exact +/// rational share, and since `Σyes == Σno == sets_minted` across holders and +/// the pool the total paid out never exceeds `sets_minted`. +/// +/// `payout_long_bps` must be `<= BPS`; the market layer validates that in +/// `resolve_scalar` before any payout can be settled. +pub fn scalar_payout(payout_long_bps: u32, yes: i128, no: i128) -> i128 { + let p = payout_long_bps as i128; + let long = yes * p / BPS; + let short = no * (BPS - p) / BPS; + long + short +} + #[cfg(test)] mod tests { extern crate std; @@ -395,6 +492,73 @@ mod tests { assert!(settle_value(Settlement::Void, 5, 5) <= 5); } + #[test] + fn scalar_extremes_match_binary() { + for (yes, no) in [(0i128, 7i128), (3, 4), (1_000_000, 250_000)] { + assert_eq!( + settle_value(Settlement::Scalar(BPS as u32), yes, no), + settle_value(Settlement::Yes, yes, no) + ); + assert_eq!( + settle_value(Settlement::Scalar(0), yes, no), + settle_value(Settlement::No, yes, no) + ); + } + } + + #[test] + fn scalar_rounding_favours_pool() { + let mut x: u64 = 0x0DDC_0FFE_E0DD_F00D; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (x >> 33) as i128 + }; + for _ in 0..2_000 { + let yes = next() % 1_000_000_000_000; + let no = next() % 1_000_000_000_000; + let p = (next() % (BPS + 1)) as u32; + let paid = scalar_payout(p, yes, no); + // floor + floor never exceeds the exact rational amount + assert!(paid * BPS <= yes * p as i128 + no * (BPS - p as i128)); + } + } + + #[test] + fn scalar_payout_never_exceeds_sets() { + let mut x: u64 = 0x5EED_5EED_5EED_5EED; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (x >> 33) as i128 + }; + for _ in 0..2_000 { + let sets = 1 + next() % 1_000_000_000_000; + let p = (next() % (BPS + 1)) as u32; + // three holders split `sets` complete sets independently per leg + let (mut rest_y, mut rest_n) = (sets, sets); + let mut total: i128 = 0; + for h in 0..3 { + let take_y = if h == 2 { + rest_y + } else { + next() % (rest_y + 1) + }; + let take_n = if h == 2 { + rest_n + } else { + next() % (rest_n + 1) + }; + rest_y -= take_y; + rest_n -= take_n; + total += scalar_payout(p, take_y, take_n); + } + assert!(total <= sets, "scalar overpaid: {total} > {sets} at p={p}"); + } + } + #[test] fn split_fee_sums_exactly() { let mut x: u64 = 0x9E37_79B9_7F4A_7C15; @@ -560,4 +724,155 @@ mod tests { price_yes_bps(1_100, 910).unwrap() ); } + + fn sched(base_bps: u32, max_bps: u32, ramp_secs: u64, skew_mult_bps: u32) -> FeeScheduleM { + FeeScheduleM { + base_bps, + max_bps, + ramp_secs, + skew_mult_bps, + } + } + + #[test] + fn effective_fee_within_bounds() { + let mut x: u64 = 0xFEE1_0BEE_FEE1_0BEE; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + x >> 33 + }; + for _ in 0..2_000 { + let base = (next() % 1_001) as u32; + let max = base + (next() % (1_001 - base as u64)) as u32; + let s = sched( + base, + max, + next() % (MAX_RAMP_SECS + 1), + (next() % (MAX_SKEW_MULT_BPS as u64 + 1)) as u32, + ); + let close = 1_000_000 + next() % 1_000_000_000; + let now = next(); + let r_yes = 1 + (next() % 1_000_000_000_000) as i128; + let r_no = 1 + (next() % 1_000_000_000_000) as i128; + let fee = effective_fee_bps(&s, now, close, r_yes, r_no).unwrap(); + assert!(fee >= base, "fee {fee} < base {base}"); + assert!(fee <= max, "fee {fee} > max {max}"); + assert!(max <= 1_000); + } + } + + #[test] + fn effective_fee_ramp_monotonic_in_time() { + let s = sched(100, 800, 10_000, 250); + let (close, r_yes, r_no) = (500_000u64, 1_000i128, 3_000i128); + let mut prev = 0u32; + let mut t = close - 20_000; + while t <= close + 5_000 { + let fee = effective_fee_bps(&s, t, close, r_yes, r_no).unwrap(); + assert!(fee >= prev, "fee fell from {prev} to {fee} at t={t}"); + prev = fee; + t += 137; + } + assert_eq!(prev, 800, "past close the fee clamps to max"); + } + + #[test] + fn effective_fee_symmetric_in_skew() { + let s = sched(200, 1_000, 0, 100); + // exactly representable ratios: p and 10_000 - p both hold, so the + // floored distance d is identical on either side and the fee must + // be exactly equal + for (a, b) in [ + (1_000i128, 3_000i128), + (900, 1_100), + (5_000, 5_000), + (1, 4), + (7, 3), + (1, 1), + ] { + assert_eq!( + effective_fee_bps(&s, 0, 1, a, b).unwrap(), + effective_fee_bps(&s, 0, 1, b, a).unwrap(), + "asymmetric at {a}/{b}" + ); + } + // inexact ratios can shift the floored price distance by 1, which + // the floored scaling bounds by one bp while skew_mult <= 1_000 + let mut x: u64 = 0x5CE1_5CE1_5CE1_5CE1; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + x >> 33 + }; + for _ in 0..2_000 { + let a = 1 + (next() % 1_000_000_000_000) as i128; + let b = 1 + (next() % 1_000_000_000_000) as i128; + let f1 = effective_fee_bps(&s, 0, 1, a, b).unwrap(); + let f2 = effective_fee_bps(&s, 0, 1, b, a).unwrap(); + assert!((f1 as i64 - f2 as i64).abs() <= 1, "{a}/{b}: {f1} vs {f2}"); + } + } + + #[test] + fn effective_fee_zero_ramp() { + let s = sched(120, 900, 0, 300); + let far = effective_fee_bps(&s, 0, u64::MAX, 1_000, 1_000).unwrap(); + let at_close = effective_fee_bps(&s, 5_000_000, 5_000_000, 1_000, 1_000).unwrap(); + let past = effective_fee_bps(&s, 6_000_000, 5_000_000, 1_000, 1_000).unwrap(); + assert_eq!(far, 120); + assert_eq!(far, at_close); + assert_eq!(far, past); + } + + #[test] + fn effective_fee_rejects_unbounded_schedule() { + assert_eq!( + effective_fee_bps(&sched(500, 400, 0, 0), 0, 1, 1, 1), + Err(MathError::InvalidInput) + ); + // max must stay strictly below BPS (calc_sell requires fee < BPS) + assert_eq!( + effective_fee_bps(&sched(0, 10_000, 0, 0), 0, 1, 1, 1), + Err(MathError::InvalidInput) + ); + } + + #[test] + fn effective_fee_matches_fixtures() { + // Shared cross-language table (also read by model/fpmm_model.py and + // agents/core/fpmm.ts): one row per fixture, nine numbers — + // [base_bps, max_bps, ramp_secs, skew_mult_bps, + // now, close, r_yes, r_no, expected_fee_bps] + let raw = include_str!("../../../model/fee_fixtures.json"); + let mut nums = [0i128; 512]; + let mut n = 0usize; + let mut cur: Option = None; + for b in raw.bytes() { + if b.is_ascii_digit() { + let d = (b - b'0') as i128; + cur = Some(cur.map_or(d, |v| v * 10 + d)); + } else if let Some(v) = cur.take() { + assert!(n < 512, "fee_fixtures.json holds too many numbers"); + nums[n] = v; + n += 1; + } + } + if let Some(v) = cur.take() { + assert!(n < 512, "fee_fixtures.json holds too many numbers"); + nums[n] = v; + n += 1; + } + assert!( + n > 0 && n.is_multiple_of(9), + "expected 9 numbers per row, got {n}" + ); + for row in nums[..n].chunks(9) { + let s = sched(row[0] as u32, row[1] as u32, row[2] as u64, row[3] as u32); + let fee = effective_fee_bps(&s, row[4] as u64, row[5] as u64, row[6], row[7]).unwrap(); + assert_eq!(fee, row[8] as u32, "fixture {row:?}"); + } + } } diff --git a/oracledesk-stellar/contracts/market-core/src/math_categorical.rs b/oracledesk-stellar/contracts/market-core/src/math_categorical.rs new file mode 100644 index 0000000..4b87987 --- /dev/null +++ b/oracledesk-stellar/contracts/market-core/src/math_categorical.rs @@ -0,0 +1,605 @@ +//! Pure integer math for a categorical N-outcome fixed-product market maker +//! (FPMM): the generalisation of `math.rs` from 2 outcomes to `n` in +//! `2..=MAX_MARKET_OUTCOMES`. +//! +//! No Soroban types in here (no `Env`, no `alloc`), so `cargo test` exercises +//! it natively and `model/fpmm_model.py` / `agents/core/fpmm.ts` mirror it +//! for property testing. +//! +//! Conventions +//! - Same units and pool-favouring rounding as `math.rs`: everything that can +//! round rounds so the pool never loses. +//! - The binary invariant `k = r_yes * r_no` generalises to the product of +//! all reserves, `k = r_0 * ... * r_{n-1}`. Buys and sells never decrease +//! `k`, which is what guarantees no sequence of trades can drain the pool. +//! - Reserves arrive as a slice of length `n` and results are fixed-size +//! arrays whose first `n` entries are meaningful, so nothing here needs a +//! heap. +//! - Every multiplication is `checked_*`. A reachable but extreme pool state +//! (n = 8 with reserves near `MAX_SETS`, drained almost to zero by a huge +//! sell) can exceed `i128` in the iterative loops; such a trade fails with +//! `Overflow` rather than wrapping. + +use crate::math::{mul_div_ceil, mul_div_floor, MathError, BPS, MIN_PROB_BPS}; + +/// Maximum number of outcomes a categorical market can have. Bounds every +/// loop here, so a trade's cost is linear in the outcome count at most. +pub const MAX_MARKET_OUTCOMES: u32 = 8; +const MAX_N: usize = MAX_MARKET_OUTCOMES as usize; + +/// Reciprocal scale for `prices_bps`: `rec_i = PRICE_SCALE / r_i`. Chosen so +/// `rec_i * n` and `BPS * rec_i` stay far inside `i128` for every reachable +/// reserve (`r_i <= MAX_SETS = 1e15`), keeping the reciprocals' relative +/// rounding below ~1e-9 for any realistic pool while prices stay exact at +/// the ratios that matter. +const PRICE_SCALE: i128 = 1_000_000_000_000_000_000; // 1e18 + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct CatBuyQuote { + pub shares_out: i128, + pub fee: i128, + /// Collateral that actually mints complete sets (`collateral_in - fee`). + pub net: i128, + /// Reserves after the trade; the first `n` entries are meaningful. + pub new_reserves: [i128; MAX_N], +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct CatSellQuote { + pub shares_in: i128, + pub fee: i128, + /// Complete sets burned from the pool (`collateral_out + fee`). + pub gross: i128, + /// Reserves after the trade; the first `n` entries are meaningful. + pub new_reserves: [i128; MAX_N], +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct CatSeed { + /// Reserves after seeding; the first `n` entries are meaningful. + pub reserves: [i128; MAX_N], + /// Outcome shares handed back to the creator (per outcome) so the pool + /// opens at the requested probabilities. `reserves[i] + creator_shares[i] + /// == seed` for every i, mirroring `SeedReserves` at n = 2. + pub creator_shares: [i128; MAX_N], +} + +fn check_reserves(reserves: &[i128]) -> Result { + let n = reserves.len(); + if !(2..=MAX_N).contains(&n) { + return Err(MathError::InvalidInput); + } + if reserves.iter().any(|r| *r <= 0) { + return Err(MathError::InvalidInput); + } + Ok(n) +} + +fn check_index(n: usize, outcome_index: u32) -> Result { + let j = outcome_index as usize; + if j >= n { + return Err(MathError::InvalidInput); + } + Ok(j) +} + +/// Buy `collateral_in` worth of outcome `outcome_index`. +/// +/// 1. fee = ceil(in * fee_bps / BPS); net = in - fee +/// 2. mint `net` complete sets: r_i' = r_i + net for every i != j +/// 3. the pool keeps r_j' = r_j * Π_{i≠j} r_i / (r_i + net) (k preserved) +/// 4. the buyer receives r_j + net - r_j' +/// +/// Step 3 is evaluated incrementally: `acc = r_j`, then for each i != j, +/// `acc = ceil(acc * r_i / (r_i + net))`. +/// +/// Rounding proof. Write E_k for the exact real product after k factors and +/// q = r_i / (r_i + net) < 1 for the next one. Ceil is never below its +/// argument and multiplying by q preserves `acc >= E`, so the computed +/// reserve never drops below the ideal E. For the upper side, if +/// `acc <= E + d` then +/// `ceil(acc * q) <= acc * q + 1 <= (E + d) * q + 1 = E' + d*q + 1`, +/// and `d * q < d` because q < 1 and d >= 0: the ceil adds back at most 1 +/// while the accumulated deviation shrinks. Starting from `acc = r_j = E_0` +/// (d = 0), after the n-1 steps `E <= r_j' <= E + (n-1)`. The pool therefore +/// keeps between 0 and n-1 units more than exact — the buyer can lose the +/// rounding but never gain it. +pub fn calc_buy( + reserves: &[i128], + outcome_index: u32, + collateral_in: i128, + fee_bps: u32, +) -> Result { + let n = check_reserves(reserves)?; + let j = check_index(n, outcome_index)?; + if collateral_in <= 0 { + return Err(MathError::InvalidInput); + } + let fee = mul_div_ceil(collateral_in, fee_bps as i128, BPS)?; + let net = collateral_in - fee; + if net <= 0 { + return Err(MathError::InvalidInput); + } + + let mut new_reserves = [0i128; MAX_N]; + let mut acc = reserves[j]; + for i in 0..n { + if i == j { + continue; + } + let new_other = reserves[i].checked_add(net).ok_or(MathError::Overflow)?; + new_reserves[i] = new_other; + acc = mul_div_ceil(acc, reserves[i], new_other)?; + } + new_reserves[j] = acc; + let shares_out = reserves[j].checked_add(net).ok_or(MathError::Overflow)? - acc; + if shares_out <= 0 { + return Err(MathError::InvalidInput); + } + Ok(CatBuyQuote { + shares_out, + fee, + net, + new_reserves, + }) +} + +/// Sell outcome `outcome_index` for an exact `collateral_out` +/// (Gnosis-style, so no square root on-chain). +/// +/// 1. gross = ceil(out * BPS / (BPS - fee_bps)); fee = gross - out +/// 2. burn `gross` complete sets: r_i' = r_i - gross for every i != j +/// 3. the pool must end at r_j' = r_j * Π_{i≠j} r_i / (r_i - gross) +/// 4. the seller gives r_j' + gross - r_j shares +/// +/// Step 3 uses the same ceil-iterated accumulation as `calc_buy` with factor +/// `r_i / (r_i - gross) > 1`, so by the same argument `r_j'` is between the +/// ideal value and ideal + (n-1): the seller pays a rounding margin, never +/// the pool. Every other reserve must exceed `gross`, so no reserve can +/// reach zero and the product stays positive. +pub fn calc_sell( + reserves: &[i128], + outcome_index: u32, + collateral_out: i128, + fee_bps: u32, +) -> Result { + let n = check_reserves(reserves)?; + let j = check_index(n, outcome_index)?; + if collateral_out <= 0 || (fee_bps as i128) >= BPS { + return Err(MathError::InvalidInput); + } + let gross = mul_div_ceil(collateral_out, BPS, BPS - fee_bps as i128)?; + let fee = gross - collateral_out; + + let mut new_reserves = [0i128; MAX_N]; + let mut acc = reserves[j]; + for i in 0..n { + if i == j { + continue; + } + if reserves[i] <= gross { + return Err(MathError::InsufficientLiquidity); + } + let new_other = reserves[i] - gross; + new_reserves[i] = new_other; + acc = mul_div_ceil(acc, reserves[i], new_other)?; + } + new_reserves[j] = acc; + let shares_in = acc.checked_add(gross).ok_or(MathError::Overflow)? - reserves[j]; + if shares_in <= 0 { + return Err(MathError::InvalidInput); + } + Ok(CatSellQuote { + shares_in, + fee, + gross, + new_reserves, + }) +} + +/// Spot price of every outcome in bps. The categorical price relation +/// `p_j = (1/r_j) / Σ_i (1/r_i)` (the binary formula `r_no / (r_yes + r_no)` +/// generalised) is evaluated on integer reciprocals so no division chain can +/// overflow: `rec_i = PRICE_SCALE / r_i` (floor), then +/// `p_j = floor(BPS * rec_j / Σ rec)`. +/// +/// Every floor discards a fraction, so each price is at most its exact value +/// and the n prices sum to at most BPS; each floor loses less than 1, so the +/// sum is at least `BPS - n`. Clients should treat the prices as display +/// fractions (they never enter trade math) and tolerate that slack. +pub fn prices_bps(reserves: &[i128]) -> Result<[u32; MAX_N], MathError> { + let n = check_reserves(reserves)?; + let mut recs = [0i128; MAX_N]; + let mut sum: i128 = 0; + for i in 0..n { + recs[i] = PRICE_SCALE / reserves[i]; + sum = sum.checked_add(recs[i]).ok_or(MathError::Overflow)?; + } + if sum <= 0 { + return Err(MathError::InsufficientLiquidity); + } + let mut prices = [0u32; MAX_N]; + for i in 0..n { + prices[i] = mul_div_floor(BPS, recs[i], sum)? as u32; + } + Ok(prices) +} + +/// Pool opens at the requested probabilities with `seed` complete sets +/// minted. Each reserve is `seed * p_min / p_i` (floored), so reserve +/// *ratios* are `1/p_i` and the derived prices approximate `probs_bps`. +/// +/// The creator keeps `seed - r_i` shares of every outcome — only the likelier +/// outcomes have any, exactly as `math::seed_reserves` does at n = 2 — so +/// `r_i + creator_shares_i == seed` and the per-outcome holder total stays +/// `sets_minted` from the first trade. +/// +/// Validation: `2 <= n <= MAX_MARKET_OUTCOMES`, `seed > 0`, every probability +/// in `MIN_PROB_BPS..=BPS` (each individual check) and the probabilities +/// summing to exactly BPS. +pub fn seed_reserves(seed: i128, probs_bps: &[u32]) -> Result { + let n = probs_bps.len(); + if !(2..=MAX_N).contains(&n) || seed <= 0 { + return Err(MathError::InvalidInput); + } + let mut sum: u32 = 0; + let mut p_min = u32::MAX; + for p in probs_bps { + if *p < MIN_PROB_BPS { + return Err(MathError::InvalidInput); + } + sum = sum.checked_add(*p).ok_or(MathError::Overflow)?; + p_min = p_min.min(*p); + } + if sum != BPS as u32 { + return Err(MathError::InvalidInput); + } + + let mut reserves = [0i128; MAX_N]; + let mut creator_shares = [0i128; MAX_N]; + for (i, p) in probs_bps.iter().enumerate() { + let r = mul_div_floor(seed, p_min as i128, *p as i128)?; + if r <= 0 { + return Err(MathError::InvalidInput); + } + reserves[i] = r; + creator_shares[i] = seed - r; + } + Ok(CatSeed { + reserves, + creator_shares, + }) +} + +/// Collateral value of one holder's per-outcome balances once final. +/// `Some(w)` pays the winning balance; `None` (void) pays +/// `floor(Σ balances / n)`, the N-way generalisation of the binary void's +/// `(yes + no) / 2`. +/// +/// Every complete set is worth exactly 1 and every rounding is a floor, so +/// the total paid out across all holders never exceeds `sets_minted`. +pub fn settle_value(resolved: Option, balances: &[i128]) -> Result { + let n = balances.len(); + if !(2..=MAX_N).contains(&n) { + return Err(MathError::InvalidInput); + } + match resolved { + Some(w) => { + let j = check_index(n, w)?; + Ok(balances[j]) + } + None => { + let mut sum: i128 = 0; + for b in balances { + if *b < 0 { + return Err(MathError::InvalidInput); + } + sum = sum.checked_add(*b).ok_or(MathError::Overflow)?; + } + Ok(sum / n as i128) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::calc_buy as binary_calc_buy; + + struct Lcg(u64); + + impl Lcg { + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 33 + } + } + + /// Π reserves, the categorical pool invariant. Test-sized reserves keep + /// this inside i128 (production uses checked math instead). + fn k(reserves: &[i128], n: usize) -> i128 { + let mut acc: i128 = 1; + for r in &reserves[..n] { + acc = acc.checked_mul(*r).unwrap(); + } + acc + } + + #[test] + fn cat_buy_and_sell_examples_match_model() { + // 1000/1000/1000 pool, buy 100 of outcome 0 with no fee: + // acc = ceil(1000*1000/1100) = 910; ceil(910*1000/1100) = 828 + // shares = 1000 + 100 - 828 = 272; reserves [828, 1100, 1100] + let q = calc_buy(&[1_000, 1_000, 1_000], 0, 100, 0).unwrap(); + assert_eq!(q.shares_out, 272); + assert_eq!(q.new_reserves[..3], [828, 1_100, 1_100]); + + // sell 100 out of the same pool: + // 1000 -> ceil(1000*1000/900) = 1112 -> ceil(1112*1000/900) = 1236 + // shares = 1236 + 100 - 1000 = 336; reserves [1236, 900, 900] + let s = calc_sell(&[1_000, 1_000, 1_000], 0, 100, 0).unwrap(); + assert_eq!(s.shares_in, 336); + assert_eq!(s.new_reserves[..3], [1_236, 900, 900]); + } + + #[test] + fn cat_buy_k_never_decreases() { + let mut rng = Lcg(0x9E37_79B9_7F4A_7C15); + for _ in 0..2_000 { + let n = 2 + (rng.next() % 7) as usize; // 2..=8 + let mut reserves = [0i128; MAX_N]; + for r in reserves.iter_mut().take(n) { + *r = 100 + (rng.next() % 10_000) as i128; + } + let j = (rng.next() % n as u64) as u32; + let amt = 100 + (rng.next() % 100) as i128; + let fee = (rng.next() % 300) as u32; + + let k0 = k(&reserves, n); + let q = calc_buy(&reserves[..n], j, amt, fee).unwrap(); + assert!( + k(&q.new_reserves, n) >= k0, + "buy decreased k: n={n} j={j} amt={amt} fee={fee}" + ); + // the bought outcome's reserve can shrink; every other grows by net + for (i, r) in reserves.iter().enumerate().take(n) { + if i != j as usize { + assert_eq!(q.new_reserves[i], r + q.net); + } + } + } + } + + #[test] + fn cat_round_trip_never_profits() { + let mut rng = Lcg(0x0DDB_1A5E_5BAD_5EED); + for _ in 0..2_000 { + let n = 2 + (rng.next() % 7) as usize; + let mut reserves = [0i128; MAX_N]; + for r in reserves.iter_mut().take(n) { + *r = 1_000 + (rng.next() % 100_000) as i128; + } + let j = (rng.next() % n as u64) as u32; + let amt = 100 + (rng.next() % 400) as i128; + let fee = (rng.next() % 300) as u32; + + let buy = calc_buy(&reserves[..n], j, amt, fee).unwrap(); + // selling the same collateral back out must cost at least the + // shares the buy paid (A -> B -> A is never a profit) + if let Ok(sell) = calc_sell(&buy.new_reserves[..n], j, amt, fee) { + assert!( + sell.shares_in >= buy.shares_out, + "free money: n={n} j={j} amt={amt} fee={fee} \ + bought={} sold={}", + buy.shares_out, + sell.shares_in + ); + } + } + } + + #[test] + fn cat_prices_sum_within_n_of_bps() { + let mut rng = Lcg(0xB1A5_E5B1_A5B1_A5B1); + for _ in 0..2_000 { + let n = 2 + (rng.next() % 7) as usize; + let mut reserves = [0i128; MAX_N]; + for r in reserves.iter_mut().take(n) { + *r = 1_000 + (rng.next() % 1_000_000_000_000) as i128; + } + let prices = prices_bps(&reserves[..n]).unwrap(); + let sum: u32 = prices[..n].iter().sum(); + assert!(sum <= BPS as u32, "prices sum above BPS: n={n} sum={sum}"); + assert!( + sum + n as u32 >= BPS as u32, + "prices sum below BPS - n: n={n} sum={sum}" + ); + } + // equal reserves -> equal prices, each exactly floor(BPS / n) + let flat = [7_777i128; MAX_N]; + for n in 2..=MAX_N { + let prices = prices_bps(&flat[..n]).unwrap(); + let sum: u32 = prices[..n].iter().sum(); + assert!( + sum + n as u32 >= BPS as u32 && sum <= BPS as u32, + "n={n} sum={sum}" + ); + for p in &prices[..n] { + assert_eq!(*p, (BPS as u32) / n as u32); + } + } + } + + #[test] + fn cat_n2_matches_binary_calc_buy_within_1() { + let mut rng = Lcg(0x5EED_5EED_5EED_5EED); + for _ in 0..2_000 { + let r_bought = 1_000 + (rng.next() % 1_000_000_000) as i128; + let r_other = 1_000 + (rng.next() % 1_000_000_000) as i128; + let amt = 1 + (rng.next() % (r_bought.min(r_other) / 2) as u64) as i128; + let fee = (rng.next() % 300) as u32; + + // a 2-outcome categorical pool IS the binary FPMM + let bin = binary_calc_buy(r_bought, r_other, amt, fee).unwrap(); + let cat = calc_buy(&[r_bought, r_other], 0, amt, fee).unwrap(); + assert!( + (cat.shares_out - bin.shares_out).abs() <= 1, + "shares drift: cat={} bin={}", + cat.shares_out, + bin.shares_out + ); + assert_eq!(cat.net, bin.net); + assert_eq!(cat.fee, bin.fee); + // outcome 0 is the "bought" side, outcome 1 the "other" + assert_eq!(cat.new_reserves[0], bin.new_reserve_bought); + assert_eq!(cat.new_reserves[1], bin.new_reserve_other); + } + } + + #[test] + fn cat_void_never_overpays() { + let mut rng = Lcg(0xDEAD_BEEF_DEAD_BEEF); + for _ in 0..2_000 { + let n = 2 + (rng.next() % 7) as usize; + let sets: i128 = 1_000_000; + // three holders share each outcome's `sets` shares with the pool; + // generated column-major: held_t[outcome][holder] + let mut held_t = [[0i128; 3]; MAX_N]; + for col in held_t.iter_mut().take(n) { + let mut rest = sets; + for (h, take) in col.iter_mut().enumerate() { + *take = if h == 2 { + rest + } else { + (rng.next() % (rest as u64 + 1)) as i128 + }; + rest -= *take; + } + assert!(rest >= 0); + } + let row_of = |held_t: &[[i128; 3]; MAX_N], h: usize| -> [i128; MAX_N] { + let mut row = [0i128; MAX_N]; + for (i, col) in held_t.iter().enumerate().take(n) { + row[i] = col[h]; + } + row + }; + + // resolved(w): total paid = Σ_h bal[h][w] <= sets + let w = (rng.next() % n as u64) as u32; + let mut void_total: i128 = 0; + let mut resolved_total: i128 = 0; + for h in 0..3 { + let row = row_of(&held_t, h); + void_total += settle_value(None, &row[..n]).unwrap(); + resolved_total += settle_value(Some(w), &row[..n]).unwrap(); + } + assert!(void_total <= sets, "void overpaid: {void_total} > {sets}"); + assert!(resolved_total <= sets); + } + } + + #[test] + fn cat_seed_matches_requested_probs() { + let cases: [[u32; 8]; 4] = [ + [5000, 5000, 0, 0, 0, 0, 0, 0], + [6800, 3200, 0, 0, 0, 0, 0, 0], + [5000, 3000, 2000, 0, 0, 0, 0, 0], + [9000, 500, 400, 100, 0, 0, 0, 0], + ]; + let lens = [2usize, 2, 3, 4]; + for (probs, len) in cases.iter().zip(lens) { + let s = seed_reserves(1_000_000_000, &probs[..len]).unwrap(); + let prices = prices_bps(&s.reserves[..len]).unwrap(); + for i in 0..len { + let drift = (prices[i] as i32 - probs[i] as i32).abs(); + assert!(drift <= 2, "price drift {drift} bps at i={i}"); + // every outcome splits exactly one set: reserve + creator = seed + assert_eq!(s.reserves[i] + s.creator_shares[i], 1_000_000_000); + } + // the least likely outcome is fully held by the pool + let min_i = (0..len).min_by_key(|i| probs[*i]).unwrap(); + assert_eq!(s.creator_shares[min_i], 0); + } + // 8 equal outcomes: prices are exactly equal in total + let flat = [1250u32; 8]; + let s = seed_reserves(1_000_000_000, &flat).unwrap(); + let prices = prices_bps(&s.reserves).unwrap(); + let sum: u32 = prices.iter().sum(); + assert_eq!(sum, BPS as u32); + for (i, p) in prices.iter().enumerate() { + assert_eq!(s.reserves[i], 1_000_000_000); + assert_eq!(s.creator_shares[i], 0); + assert_eq!(*p, 1250); + } + + // validation + assert_eq!( + seed_reserves(1_000, &[6000, 3000]), + Err(MathError::InvalidInput) // sum != BPS + ); + assert_eq!( + seed_reserves(1_000, &[9950, 50]), + Err(MathError::InvalidInput) // below MIN_PROB_BPS + ); + assert_eq!( + seed_reserves(1_000, &[10_000]), + Err(MathError::InvalidInput) // n < 2 + ); + assert_eq!( + seed_reserves(1_000, &[1250; 9]), + Err(MathError::InvalidInput) // n > MAX_MARKET_OUTCOMES + ); + assert_eq!( + seed_reserves(0, &[5000, 5000]), + Err(MathError::InvalidInput) + ); + } + + #[test] + fn cat_settle_rejects_bad_shapes() { + assert_eq!( + settle_value(Some(2), &[10, 10]), + Err(MathError::InvalidInput) + ); + assert_eq!(settle_value(None, &[10]), Err(MathError::InvalidInput)); + assert_eq!(settle_value(None, &[-1, 5]), Err(MathError::InvalidInput)); + // resolved pays exactly the winning balance + assert_eq!(settle_value(Some(1), &[3, 7, 11]).unwrap(), 7); + // void floors toward the pool + assert_eq!(settle_value(None, &[3, 7, 11]).unwrap(), 7); // 21 / 3 + assert_eq!(settle_value(None, &[1, 1, 1, 1, 1, 1, 1]).unwrap(), 1); + } + + #[test] + fn cat_buy_and_sell_reject_bad_arguments() { + let r = [1_000i128, 2_000, 3_000]; + assert_eq!( + calc_buy(&r, 3, 100, 0), + Err(MathError::InvalidInput) // index out of range + ); + assert_eq!(calc_buy(&r, 0, 0, 0), Err(MathError::InvalidInput)); + assert_eq!( + calc_buy(&[1_000, 0], 0, 100, 0), + Err(MathError::InvalidInput) + ); + // fee larger than the trade leaves nothing to mint + assert_eq!( + calc_buy(&[1_000, 1_000], 0, 100, 10_000), + Err(MathError::InvalidInput) + ); + // selling more than an "other" reserve holds is illiquid + assert_eq!( + calc_sell(&[1_000, 2_000, 3_000], 0, 5_000, 0), + Err(MathError::InsufficientLiquidity) + ); + // a sell leaves the sold reserve strictly larger (shares_in > 0) + let q = calc_sell(&[1_000, 2_000, 3_000], 0, 100, 0).unwrap(); + assert!(q.shares_in > 0 && q.new_reserves[0] > 1_000); + assert_eq!(q.new_reserves[1], 1_900); + assert_eq!(q.new_reserves[2], 2_900); + } +} diff --git a/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs b/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs index 4662608..2b984b6 100644 --- a/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs +++ b/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs @@ -24,7 +24,7 @@ //! `contracts/resolver`'s `shared_types_are_byte_identical_to_imported` test //! pins that byte-for-byte. -use soroban_sdk::{Address, BytesN, Symbol, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, Symbol, Vec}; // --------------------------------------------------------------------------- // market-core outcome surface @@ -57,6 +57,11 @@ pub enum MarketStatus { Resolved(Outcome), /// Invalid market. Every share redeems at 0.5. Void, + /// Scalar (range) market, final: LONG shares pay `payout_long_bps / + /// 10_000` and SHORT shares the complement. Appended last so stored + /// `Resolved`/`Void` values keep their encoding; readers compiled + /// against older bindings fail to decode this one (see the release note). + ResolvedScalar(u32), } // --------------------------------------------------------------------------- @@ -150,6 +155,38 @@ pub struct WeightedKeyConfig { pub dispute_window: u64, } +/// Categorical counterpart of [`SignerConfig`]: signers attest an outcome +/// *index* of a categorical market instead of Yes/No. `outcome_count` +/// binds the market's outcome set to the commitment (2..=8, mirroring +/// market-core's cap), so a reveal cannot silently change how many outcomes +/// signers are voting on. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatSignerConfig { + pub signers: Vec
, + pub threshold: u32, + pub dispute_window: u64, + pub outcome_count: u32, +} + +/// Scalar (range) counterpart of [`PriceConfig`]. The market settles on the +/// fractional position of `price` inside `[lower, upper]`: +/// `payout_long_bps = (clamped_price - lower) / (upper - lower)`, and the +/// SHORT side pays the complement. A reading below the range clamps to +/// `lower` (LONG pays 0), above to `upper` (LONG pays 1.0); a non-positive +/// reading is an invalid price, never a clamp. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScalarPriceConfig { + pub reflector: Address, + pub asset: OracleAsset, + /// Bottom of the staked range. Validated `0 <= lower < upper`, with + /// `upper` capped so `upper * BPS` cannot overflow. + pub lower: i128, + pub upper: i128, + pub max_staleness: u64, +} + /// Binds a market to a reusable committee registry subject to rotation bounds. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -182,6 +219,11 @@ pub enum ResolutionSpec { SignersDisputable(DisputableSignerConfig), Committee(CommitteeRef), Hybrid(HybridConfig), + /// Categorical counterpart of `Signers` (appended, tag 6). + CategoricalSigners(CatSignerConfig), + /// Scalar counterpart of `Price` (appended, tag 7): the binary pool + /// settles at a fractional LONG payout instead of a side. + Scalar(ScalarPriceConfig), } /// Versioned commitment envelope. Unlike a bare [`ResolutionSpec`] it binds the diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index edfcc9b..7c173b1 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -23,6 +23,7 @@ mod market_core { soroban_sdk::contractimport!(file = "market_core.wasm"); } use market_core::Client as MarketCoreClient; +use market_core::MarketKind; // The resolution-spec surface is XDR-consensus-critical (`resolution_hash = // sha256(spec.to_xdr())`), so its definitions now live in the shared @@ -30,9 +31,9 @@ use market_core::Client as MarketCoreClient; // them so existing `resolver::PriceConfig` etc. paths (and `scripts/spec-hash`) // keep working. pub use oracledesk_types::{ - CommitteeRef, DisputableSignerConfig, Escalation, HybridConfig, OracleAsset, Outcome, - PriceConfig, PriceData, PriceDirection, ResolutionSpec, ResolutionSpecV2, SignerConfig, - WeightedKey, WeightedKeyConfig, + CatSignerConfig, CommitteeRef, DisputableSignerConfig, Escalation, HybridConfig, OracleAsset, + Outcome, PriceConfig, PriceData, PriceDirection, ResolutionSpec, ResolutionSpecV2, + ScalarPriceConfig, SignerConfig, WeightedKey, WeightedKeyConfig, }; #[allow(clippy::too_many_arguments)] @@ -57,6 +58,12 @@ pub const MAX_WEIGHTED_SIGNERS: u32 = 32; /// (`max_sigs_per_call_budget`) shows this size stays far below the 400M /// instruction / 40 MiB transaction limits. pub const MAX_SIGS_PER_CALL: u32 = MAX_WEIGHTED_SIGNERS; +/// Outcome-count cap of a categorical market, mirroring market-core's +/// `MAX_MARKET_OUTCOMES`. Duplicated (not imported) because market-core is +/// consumed through `contractimport!`, which exposes no constants. +pub const MAX_CAT_OUTCOMES: u32 = 8; +/// Basis-point denominator shared by scalar payouts and the categorical caps. +pub const BPS: i128 = 10_000; /// Domain tag of the message weighted-key signers sign (20 bytes). const ATTEST_DOMAIN: &[u8] = b"oracledesk-attest-v1"; @@ -107,6 +114,8 @@ pub enum ExpiryMode { Unrevealed, Price, Signers, + /// Scalar counterpart of `Price` (appended). + Scalar, } /// Legacy proposal shape, stored under [`DataKey::Proposal`]. Only read (as a @@ -195,6 +204,16 @@ pub struct Dispute { pub payee: Option
, } +/// A categorical winner once `threshold` signers have attested the index. +/// Resolver-local storage only (never hashed), so it stays here while the +/// committed `CatSignerConfig` lives in `oracledesk-types`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatProposal { + pub outcome_index: u32, + pub proposed_at: u64, +} + /// Reusable committee managed by an admin under time-locked rotation rules. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -335,6 +354,9 @@ pub enum Error { NoPendingAdmin = 42, /// A proposed admin handover was not accepted within its `expires_at`. PendingAdminExpired = 43, + + /// `attest_cat` outcome index not below the committed `outcome_count`. + InvalidOutcomeIndex = 44, } #[contracttype] @@ -382,6 +404,18 @@ pub enum DataKey { CountV2(u64, AttestedOutcome), ProposalV2(u64), Snapshot(u64), + + // appended (new variants only ever go at the end — the XDR tag is the + // variant position) + /// Registered `CatSignerConfig` of a categorical market. + CatSigners(u64), + /// A signer's attestation of one categorical outcome index. + CatAttestation(u64, u32, Address), + /// Attestation count for (market, outcome index). + CatCount(u64, u32), + CatProposal(u64), + /// Registered `ScalarPriceConfig` of a scalar market. + Scalar(u64), } /// The registered signer group/authority, whichever kind the market committed to. @@ -531,6 +565,38 @@ pub struct MarketExpired { pub mode: ExpiryMode, } +/// Categorical counterpart of `ResolutionProposed`. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatResolutionProposed { + #[topic] + pub market_id: u64, + pub outcome_index: u32, + pub proposed_at: u64, +} + +/// Categorical counterpart of `ResolutionFinalized`. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatResolutionFinalized { + #[topic] + pub market_id: u64, + pub outcome_index: u32, + pub finalized_at: u64, +} + +/// Scalar counterpart of `PriceResolution`: the settled market carries a +/// fractional LONG payout instead of a side. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScalarPriceResolution { + #[topic] + pub market_id: u64, + pub price: i128, + pub price_timestamp: u64, + pub payout_long_bps: u32, +} + #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct CommitteeCreated { @@ -749,6 +815,32 @@ impl Resolver { Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule)) } + /// See `register_price_spec` — same commit/reveal rule, categorical + /// outcome-index committee. Only meaningful for markets whose + /// `resolution_hash` commits to `ResolutionSpec::CategoricalSigners` with + /// this exact `outcome_count`; the reveal target's `resolution_hash` is + /// read from whichever market kind owns the id. + pub fn register_cat_signer_spec( + env: Env, + market_id: u64, + spec: CatSignerConfig, + ) -> Result<(), Error> { + let rule = ResolutionSpec::CategoricalSigners(spec); + Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule)) + } + + /// See `register_price_spec` — same commit/reveal rule, scalar mode: + /// the market settles fractionally from the feed price clamped into + /// `[lower, upper]` (see `resolve_scalar_price`). + pub fn register_scalar_spec( + env: Env, + market_id: u64, + spec: ScalarPriceConfig, + ) -> Result<(), Error> { + let rule = ResolutionSpec::Scalar(spec); + Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule)) + } + /// Reveals a rule committed to through a [`ResolutionSpecV2`] envelope. /// The envelope must name this network and this resolver's market-core /// (`DomainMismatch`), use a non-zero salt and the current `version` @@ -1086,7 +1178,8 @@ impl Resolver { /// Works the same for an `Invalid` proposal. Only the proposal is removed: /// attestations and per-outcome counts stay, so the next attestation that /// brings any outcome to threshold (for example a further `Yes` once - /// `Yes` already has quorum) proposes again. + /// `Yes` already has quorum) proposes again. A categorical market keeps its + /// pending winner in a separate slot, cleared the same way. pub fn cancel_proposal(env: Env, market_id: u64, reason: GuardianReason) -> Result<(), Error> { Self::require_admin(&env)?; if reason != GuardianReason::DisputeUpheld { @@ -1094,11 +1187,20 @@ impl Resolver { } Self::assert_not_disputed(&env, market_id)?; let storage = env.storage().persistent(); - let Some(proposal) = Self::load_proposal(&env, market_id) else { - return Err(Error::NoProposal); + let cat_key = DataKey::CatProposal(market_id); + let cat_proposal: Option = storage.get(&cat_key); + let proposed_at = match (Self::load_proposal(&env, market_id), &cat_proposal) { + (Some(p), _) => p.proposed_at, + (None, Some(p)) => p.proposed_at, + (None, None) => return Err(Error::NoProposal), }; - let dispute_window = Self::signer_authority(&env, market_id)?.dispute_window(); - if env.ledger().timestamp() >= proposal.proposed_at.saturating_add(dispute_window) { + let dispute_window = + if let Some(cfg) = storage.get::<_, CatSignerConfig>(&DataKey::CatSigners(market_id)) { + cfg.dispute_window + } else { + Self::signer_authority(&env, market_id)?.dispute_window() + }; + if env.ledger().timestamp() >= proposed_at.saturating_add(dispute_window) { return Err(Error::DisputeWindowClosed); } let cancel_key = DataKey::Cancels(market_id); @@ -1108,6 +1210,7 @@ impl Resolver { } Self::persist(&env, &cancel_key, &(cancels + 1)); Self::remove_proposal(&env, market_id); + storage.remove(&cat_key); if storage.has(&DataKey::WeightedKeys(market_id)) { Self::persist( &env, @@ -1316,9 +1419,35 @@ impl Resolver { .get(&DataKey::Snapshot(market_id)) } + /// Resolves a scalar market from its Reflector feed. The reading is + /// clamped into the committed `[lower, upper]` range and settled as the + /// fractional LONG payout `(clamped - lower) / (upper - lower)` in bps + /// (SHORT pays the complement). Returns the payout. + /// + /// A non-positive reading is `InvalidPrice` rather than a clamp: it means + /// the feed is broken, not that the value sits at the bottom of the + /// range. Same close-time, staleness and terminal-state rules as + /// `resolve_price`. + pub fn resolve_scalar_price(env: Env, market_id: u64) -> Result { + let config: ScalarPriceConfig = Self::load(&env, DataKey::Scalar(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), + )?; + if env.ledger().timestamp() < market.close_time { + return Err(Error::MarketNotClosed); + } + if Self::resolution_state(&env, market_id).is_terminal() { + return Err(Error::AlreadyFinalized); + } + let (payout, data) = Self::try_scalar_payout(&env, &config, market.close_time)?; + Self::settle_scalar(&env, &market_core, market_id, payout, &data); + Ok(payout) + } + /// When `expire_market` becomes callable for `market_id`: `close_time` plus /// [`RESOLUTION_GRACE_SECS`]. Needs only market-core's `close_time`, so it - /// is known before (and without) a spec reveal. + /// is known before (and without) a spec reveal, for either market kind. pub fn resolution_deadline(env: Env, market_id: u64) -> Result { let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; let market = core_call( @@ -1334,8 +1463,8 @@ impl Resolver { /// Resolution is preferred over voiding. A price market whose feed is /// valid right now is *resolved* instead (so a stalled caller can never /// force a void on a resolvable market), and a market with a pending - /// signer proposal is refused with `ResolutionAvailable` until - /// `finalize_signers` settles it. + /// signer or categorical proposal is refused with `ResolutionAvailable` + /// until it is finalized. pub fn expire_market(env: Env, market_id: u64) -> Result<(), Error> { Self::assert_not_disputed(&env, market_id)?; if Self::resolution_state(&env, market_id).is_terminal() { @@ -1371,7 +1500,23 @@ impl Resolver { Err(other) => return Err(other), } } - ExpiryMode::Signers if Self::has_valid_proposal(&env, market_id)? => { + ExpiryMode::Scalar => { + let config: ScalarPriceConfig = Self::load(&env, DataKey::Scalar(market_id))?; + match Self::try_scalar_payout(&env, &config, market.close_time) { + Ok((payout, data)) => { + Self::settle_scalar(&env, &market_core, market_id, payout, &data); + return Ok(()); + } + // A broken feed (missing, stale or non-positive) lets the + // market expire; any other error is a real fault. + Err(Error::PriceUnavailable | Error::StalePrice | Error::InvalidPrice) => {} + Err(other) => return Err(other), + } + } + ExpiryMode::Signers + if Self::has_valid_proposal(&env, market_id)? + || Self::has_valid_cat_proposal(&env, market_id)? => + { return Err(Error::ResolutionAvailable); } ExpiryMode::Signers | ExpiryMode::Unrevealed => {} @@ -1543,6 +1688,95 @@ impl Resolver { Ok(proposal.outcome) } + /// Categorical counterpart of `attest`: `signer` attests that + /// `outcome_index` is the winner. `outcome_index` must be below the + /// `outcome_count` the market's commitment fixed. Once `threshold` + /// members have attested an index it becomes the proposal and the + /// dispute window opens. + pub fn attest_cat( + env: Env, + market_id: u64, + signer: Address, + outcome_index: u32, + ) -> Result<(), Error> { + signer.require_auth(); + let config: CatSignerConfig = Self::load(&env, DataKey::CatSigners(market_id))?; + if !Self::contains(&config.signers, &signer) { + return Err(Error::NotSigner); + } + Self::assert_not_disputed(&env, market_id)?; + if Self::resolution_state(&env, market_id).is_terminal() { + return Err(Error::AlreadyFinalized); + } + if outcome_index >= config.outcome_count { + return Err(Error::InvalidOutcomeIndex); + } + if env.storage().persistent().has(&DataKey::CatAttestation( + market_id, + outcome_index, + signer.clone(), + )) { + return Err(Error::AlreadyAttested); + } + if env.ledger().timestamp() < Self::market_close_time(&env, market_id)? { + return Err(Error::MarketNotClosed); + } + Self::persist( + &env, + &DataKey::CatAttestation(market_id, outcome_index, signer), + &true, + ); + let count_key = DataKey::CatCount(market_id, outcome_index); + let count = Self::cat_attestation_count(&env, market_id, outcome_index) + 1; + Self::persist(&env, &count_key, &count); + if count >= config.threshold + && !env + .storage() + .persistent() + .has(&DataKey::CatProposal(market_id)) + { + let proposal = CatProposal { + outcome_index, + proposed_at: env.ledger().timestamp(), + }; + Self::persist(&env, &DataKey::CatProposal(market_id), &proposal); + CatResolutionProposed { + market_id, + outcome_index, + proposed_at: proposal.proposed_at, + } + .publish(&env); + } + Ok(()) + } + + /// Categorical counterpart of `finalize_signers`: once the dispute window + /// of a proposal has passed and its index still has `threshold` + /// attestations, resolves the market in market-core and returns the + /// winning index. + pub fn finalize_cat(env: Env, market_id: u64) -> Result { + Self::assert_not_disputed(&env, market_id)?; + let config: CatSignerConfig = Self::load(&env, DataKey::CatSigners(market_id))?; + let proposal: CatProposal = + Self::load(&env, DataKey::CatProposal(market_id)).map_err(|_| Error::NoProposal)?; + if env.ledger().timestamp() < proposal.proposed_at + config.dispute_window { + return Err(Error::DisputeWindowOpen); + } + if Self::cat_attestation_count(&env, market_id, proposal.outcome_index) < config.threshold { + return Err(Error::ThresholdNotReached); + } + let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; + MarketCoreClient::new(&env, &market_core).resolve_cat(&market_id, &proposal.outcome_index); + Self::set_state(&env, market_id, ResolutionState::Finalized); + CatResolutionFinalized { + market_id, + outcome_index: proposal.outcome_index, + finalized_at: env.ledger().timestamp(), + } + .publish(&env); + Ok(proposal.outcome_index) + } + /// Challenges the current proposal of a `SignersDisputable` market while /// its dispute window is still open (strictly before `proposed_at + /// dispute_window`, the instant `finalize_signers` becomes callable). The @@ -1742,8 +1976,31 @@ impl Resolver { .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); } - fn deadline(market: &market_core::Market) -> u64 { - market.close_time.saturating_add(RESOLUTION_GRACE_SECS) + /// `close_time` of either market kind, so spec-independent entrypoints + /// (`expire_market`, `resolution_deadline`, `attest_cat`) work for + /// categorical markets too. + fn market_close_time(env: &Env, market_id: u64) -> Result { + let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; + let core = MarketCoreClient::new(env, &market_core); + Ok(match core.market_kind(&market_id) { + MarketKind::Binary => core.get_market(&market_id).close_time, + MarketKind::Categorical(_) => core.get_cat_market(&market_id).close_time, + }) + } + + /// Committed `resolution_hash` of either market kind. + fn market_resolution_hash(env: &Env, market_id: u64) -> Result, Error> { + let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; + let core = MarketCoreClient::new(env, &market_core); + Ok(match core.market_kind(&market_id) { + MarketKind::Binary => core.get_market(&market_id).resolution_hash, + MarketKind::Categorical(_) => core.get_cat_market(&market_id).resolution_hash, + }) + } + + /// `close_time + RESOLUTION_GRACE_SECS` for either market kind. + fn market_deadline(env: &Env, market_id: u64) -> Result { + Ok(Self::market_close_time(env, market_id)?.saturating_add(RESOLUTION_GRACE_SECS)) } fn expiry_mode(env: &Env, market_id: u64) -> ExpiryMode { @@ -1753,9 +2010,12 @@ impl Resolver { && Self::resolution_state(env, market_id) != ResolutionState::HybridFallbackOpen) { ExpiryMode::Price + } else if storage.has(&DataKey::Scalar(market_id)) { + ExpiryMode::Scalar } else if storage.has(&DataKey::Signers(market_id)) || storage.has(&DataKey::Disputable(market_id)) || storage.has(&DataKey::WeightedKeys(market_id)) + || storage.has(&DataKey::CatSigners(market_id)) || storage.has(&DataKey::CommitteeSpec(market_id)) || (storage.has(&DataKey::Hybrid(market_id)) && Self::resolution_state(env, market_id) == ResolutionState::HybridFallbackOpen) @@ -1989,6 +2249,32 @@ impl Resolver { )) } + fn cat_attestation_count(env: &Env, market_id: u64, outcome_index: u32) -> u32 { + env.storage() + .persistent() + .get(&DataKey::CatCount(market_id, outcome_index)) + .unwrap_or(0) + } + + /// Categorical counterpart of `has_valid_proposal`: a `CatProposal` exists + /// whose attested count still meets the configured threshold, so + /// `finalize_cat` will succeed once the dispute window elapses. + fn has_valid_cat_proposal(env: &Env, market_id: u64) -> Result { + let Some(proposal) = env + .storage() + .persistent() + .get::<_, CatProposal>(&DataKey::CatProposal(market_id)) + else { + return Ok(false); + }; + let config: CatSignerConfig = env + .storage() + .persistent() + .get(&DataKey::CatSigners(market_id)) + .ok_or(Error::MarketNotConfigured)?; + Ok(Self::cat_attestation_count(env, market_id, proposal.outcome_index) >= config.threshold) + } + /// Sets a persistent entry and extends its TTL. fn persist>( env: &Env, @@ -2200,6 +2486,50 @@ impl Resolver { Ok(()) } + /// Scalar counterpart of `try_price_outcome`: same feed read and + /// staleness rules, then the clamped in-range position as bps. The + /// multiply cannot overflow `i128` because `validate_rule` caps `upper` + /// at `i128::MAX / BPS` and `lower >= 0`, so + /// `0 <= clamped - lower <= upper - lower <= i128::MAX / BPS`. + fn try_scalar_payout( + env: &Env, + config: &ScalarPriceConfig, + close_time: u64, + ) -> Result<(u32, PriceData), Error> { + let Ok(Ok(Some(data))) = + ReflectorPriceClient::new(env, &config.reflector).try_price(&config.asset, &close_time) + else { + return Err(Error::PriceUnavailable); + }; + if data.timestamp > close_time || close_time - data.timestamp > config.max_staleness { + return Err(Error::StalePrice); + } + if data.price <= 0 { + return Err(Error::InvalidPrice); + } + let clamped = data.price.clamp(config.lower, config.upper); + let payout = (clamped - config.lower) * BPS / (config.upper - config.lower); + Ok((payout as u32, data)) + } + + fn settle_scalar( + env: &Env, + market_core: &Address, + market_id: u64, + payout_long_bps: u32, + data: &PriceData, + ) { + MarketCoreClient::new(env, market_core).resolve_scalar(&market_id, &payout_long_bps); + Self::set_state(env, market_id, ResolutionState::Finalized); + ScalarPriceResolution { + market_id, + price: data.price, + price_timestamp: data.timestamp, + payout_long_bps, + } + .publish(env); + } + fn contains(list: &Vec
, address: &Address) -> bool { list.iter().any(|candidate| candidate == *address) } @@ -2213,6 +2543,8 @@ impl Resolver { || storage.has(&DataKey::Signers(market_id)) || storage.has(&DataKey::WeightedKeys(market_id)) || storage.has(&DataKey::Disputable(market_id)) + || storage.has(&DataKey::CatSigners(market_id)) + || storage.has(&DataKey::Scalar(market_id)) || storage.has(&DataKey::CommitteeSpec(market_id)) || storage.has(&DataKey::Hybrid(market_id)) { @@ -2246,6 +2578,22 @@ impl Resolver { } ResolutionSpec::WeightedKeys(cfg) => Self::weighted_config_is_valid(cfg), ResolutionSpec::SignersDisputable(cfg) => Self::disputable_config_is_valid(cfg), + ResolutionSpec::CategoricalSigners(cfg) => { + !cfg.signers.is_empty() + && cfg.threshold != 0 + && cfg.threshold <= cfg.signers.len() + && cfg.dispute_window != 0 + && (2..=MAX_CAT_OUTCOMES).contains(&cfg.outcome_count) + } + // `upper <= i128::MAX / BPS` keeps `try_scalar_payout`'s multiply + // provably overflow-free. + ResolutionSpec::Scalar(cfg) => { + cfg.lower >= 0 + && cfg.lower < cfg.upper + && cfg.upper <= i128::MAX / BPS + && cfg.max_staleness != 0 + } + ResolutionSpec::Committee(cfg) => cfg.dispute_window != 0, ResolutionSpec::Hybrid(cfg) => { cfg.price.threshold >= 0 @@ -2288,6 +2636,17 @@ impl Resolver { storage.set(&key, cfg); (key, ResolutionState::SignersPending) } + ResolutionSpec::CategoricalSigners(cfg) => { + let key = DataKey::CatSigners(market_id); + storage.set(&key, cfg); + (key, ResolutionState::SignersPending) + } + ResolutionSpec::Scalar(cfg) => { + let key = DataKey::Scalar(market_id); + storage.set(&key, cfg); + (key, ResolutionState::Unconfigured) + } + ResolutionSpec::Committee(cfg) => { let key = DataKey::CommitteeSpec(market_id); storage.set(&key, cfg); @@ -2499,7 +2858,8 @@ impl Resolver { Ok(()) } - /// Verifies `commitment` equals the market's `resolution_hash`. + /// Verifies `commitment` equals the market's `resolution_hash`, read from + /// whichever market kind owns the id (binary or categorical). fn assert_matches_commitment( env: &Env, market_id: u64, @@ -2519,13 +2879,14 @@ impl Resolver { #[cfg(test)] mod test { use super::*; - use market_core::{Category, MarketStatus}; + use market_core::{CatStatus, Category, MarketStatus}; use soroban_sdk::{ contract, contractimpl, testutils::{Address as _, Events as _, Ledger, MockAuth, MockAuthInvoke}, token::{self, StellarAssetClient}, BytesN, IntoVal, }; + use std::format; // ---- dual registration of market-core (coverage, issue #75) --------------- // @@ -2696,126 +3057,669 @@ mod test { &5000u32, ); - env.register_at( - &resolver_id, - Resolver, - ResolverArgs::__constructor(&admin, &market_core_id), + env.register_at( + &resolver_id, + Resolver, + ResolverArgs::__constructor(&admin, &market_core_id), + ); + let resolver = ResolverClient::new(&env, &resolver_id); + + Fixture { + env, + admin, + resolver, + resolver_id, + market_core, + market_id, + signer_a, + signer_b, + reflector_id, + spec, + } + } + + fn price_spec(env: &Env, reflector: &Address) -> PriceConfig { + PriceConfig { + reflector: reflector.clone(), + asset: OracleAsset::Other(Symbol::new(env, "XLM")), + threshold: 2_000, + direction: PriceDirection::Above, + max_staleness: 10, + } + } + + fn signer_spec(env: &Env, a: &Address, b: &Address) -> SignerConfig { + SignerConfig { + signers: Vec::from_array(env, [a.clone(), b.clone()]), + threshold: 2, + dispute_window: 100, + } + } + + fn v2_envelope( + env: &Env, + market_core: &Address, + salt: [u8; 32], + rule: &ResolutionSpec, + ) -> ResolutionSpecV2 { + ResolutionSpecV2 { + version: 2, + network_id: BytesN::from_array(env, &NETWORK_ID), + market_core: market_core.clone(), + salt: BytesN::from_array(env, &salt), + rule: rule.clone(), + } + } + + fn envelope(f: &Fixture, salt: [u8; 32]) -> ResolutionSpecV2 { + v2_envelope(&f.env, &f.market_core.address, salt, &f.spec) + } + + /// A fixture whose market commits to the V2 envelope of the rule `spec_fn` + /// returns, salted with `salt`. + fn v2_fixture( + salt: [u8; 32], + spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> ResolutionSpec, + ) -> Fixture { + fixture_with(spec_fn, move |env, rule, market_core| { + spec_v2_hash(env, &v2_envelope(env, market_core, salt, rule)) + }) + } + + fn v2_signer_fixture(salt: [u8; 32]) -> Fixture { + v2_fixture(salt, |env, _r, a, b| { + ResolutionSpec::Signers(signer_spec(env, a, b)) + }) + } + + fn cat_signer_spec(env: &Env, a: &Address, b: &Address, outcome_count: u32) -> CatSignerConfig { + CatSignerConfig { + signers: Vec::from_array(env, [a.clone(), b.clone()]), + threshold: 2, + dispute_window: 100, + outcome_count, + } + } + + /// A fixture whose market is categorical (3 outcomes, seeded from + /// [5000, 3000, 2000] bps) and whose commitment is the legacy hash of the + /// `CategoricalSigners` rule `spec_fn` returns. + fn cat_fixture(spec_fn: impl FnOnce(&Env, &Address, &Address) -> CatSignerConfig) -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + env.ledger().set_network_id(NETWORK_ID); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let signer_a = Address::generate(&env); + let signer_b = Address::generate(&env); + let treasury = Address::generate(&env); + let resolver_id = Address::generate(&env); + let reflector_id = env.register(MockReflector, ()); + + let collateral = env.register_stellar_asset_contract_v2(admin.clone()); + let token = StellarAssetClient::new(&env, &collateral.address()); + token.mint(&creator, &(1_000 * 10_000_000_i128)); + + let spec = ResolutionSpec::CategoricalSigners(spec_fn(&env, &signer_a, &signer_b)); + let market_core_id = env.register( + market_core::WASM, + ( + admin.clone(), + collateral.address(), + treasury.clone(), + resolver_id.clone(), + 100u32, + 10_000u32, + ), + ); + let resolution_hash = legacy_spec_hash(&env, &spec); + let market_core = MarketCoreClient::new(&env, &market_core_id); + let market_id = market_core.create_categorical_market( + &creator, + &BytesN::from_array(&env, &[42u8; 32]), + &resolution_hash, + &soroban_sdk::String::from_str(&env, "ipfs://resolver-cat-test"), + &Category::Crypto, + &2_000, + &1_000_000_000_i128, + &Vec::from_array(&env, [5_000u32, 3_000, 2_000]), + ); + + env.register_at( + &resolver_id, + Resolver, + ResolverArgs::__constructor(&admin, &market_core_id), + ); + let resolver = ResolverClient::new(&env, &resolver_id); + + Fixture { + env, + admin, + resolver, + resolver_id, + market_core, + market_id, + signer_a, + signer_b, + reflector_id, + spec, + } + } + + fn scalar_spec(env: &Env, reflector: &Address) -> ScalarPriceConfig { + ScalarPriceConfig { + reflector: reflector.clone(), + asset: OracleAsset::Other(Symbol::new(env, "XLM")), + lower: 100, + upper: 250, + max_staleness: 10, + } + } + + /// A fixture whose market is scalar over `[100, 250]` and whose commitment + /// is the legacy hash of the `Scalar` rule `spec_fn` returns. + fn scalar_fixture(spec_fn: impl FnOnce(&Env, &Address) -> ScalarPriceConfig) -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + env.ledger().set_network_id(NETWORK_ID); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let signer_a = Address::generate(&env); + let signer_b = Address::generate(&env); + let treasury = Address::generate(&env); + let resolver_id = Address::generate(&env); + let reflector_id = env.register(MockReflector, ()); + + let collateral = env.register_stellar_asset_contract_v2(admin.clone()); + let token = StellarAssetClient::new(&env, &collateral.address()); + token.mint(&creator, &(1_000 * 10_000_000_i128)); + + let spec = ResolutionSpec::Scalar(spec_fn(&env, &reflector_id)); + let market_core_id = env.register( + market_core::WASM, + ( + admin.clone(), + collateral.address(), + treasury.clone(), + resolver_id.clone(), + 100u32, + 10_000u32, + ), + ); + let resolution_hash = legacy_spec_hash(&env, &spec); + let market_core = MarketCoreClient::new(&env, &market_core_id); + let ResolutionSpec::Scalar(cfg) = &spec else { + unreachable!() + }; + let market_id = market_core.create_scalar_market( + &creator, + &BytesN::from_array(&env, &[42u8; 32]), + &resolution_hash, + &soroban_sdk::String::from_str(&env, "ipfs://resolver-scalar-test"), + &Category::Crypto, + &2_000, + &1_000_000_000_i128, + &5000u32, + &cfg.lower, + &cfg.upper, + ); + + env.register_at( + &resolver_id, + Resolver, + ResolverArgs::__constructor(&admin, &market_core_id), + ); + let resolver = ResolverClient::new(&env, &resolver_id); + + Fixture { + env, + admin, + resolver, + resolver_id, + market_core, + market_id, + signer_a, + signer_b, + reflector_id, + spec, + } + } + + #[test] + fn price_mode_resolves_from_historical_reflector_price() { + let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); + let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); + reflector.set_price(&2_500_i128, &1_999_u64); + + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_price_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + + assert_eq!(f.resolver.resolve_price(&f.market_id), Outcome::Yes); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::Resolved(Outcome::Yes) + ); + } + + #[test] + fn signer_mode_requires_threshold_and_waits_for_dispute_window() { + let f = fixture(|env, _r, a, b| ResolutionSpec::Signers(signer_spec(env, a, b))); + 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); + f.resolver + .attest(&f.market_id, &f.signer_b, &AttestedOutcome::No); + + assert_eq!( + f.resolver.try_finalize_signers(&f.market_id), + Err(Ok(Error::DisputeWindowOpen)) + ); + f.env.ledger().set_timestamp(2_100); + 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 cat_signer_mode_requires_threshold_and_waits_for_dispute_window() { + let f = cat_fixture(|env, a, b| cat_signer_spec(env, a, b, 3)); + let ResolutionSpec::CategoricalSigners(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_cat_signer_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + + f.resolver.attest_cat(&f.market_id, &f.signer_a, &2u32); + f.resolver.attest_cat(&f.market_id, &f.signer_b, &2u32); + + assert_eq!( + f.resolver.try_finalize_cat(&f.market_id), + Err(Ok(Error::DisputeWindowOpen)) + ); + f.env.ledger().set_timestamp(2_100); + assert_eq!(f.resolver.finalize_cat(&f.market_id), 2u32); + assert_eq!( + f.market_core.get_cat_market(&f.market_id).status, + CatStatus::Resolved(2) + ); + } + + #[test] + fn cat_attest_validations() { + let f = cat_fixture(|env, a, b| cat_signer_spec(env, a, b, 3)); + + // Nothing revealed yet: attestations cannot be recorded. + assert_eq!( + f.resolver.try_attest_cat(&f.market_id, &f.signer_a, &0u32), + Err(Ok(Error::MarketNotConfigured)) + ); + + let ResolutionSpec::CategoricalSigners(spec) = f.spec.clone() else { + unreachable!() + }; + // Outcome counts outside 2..=8 and a zero threshold are rejected by + // validation before the commitment is checked. + for bad_count in [1u32, 9] { + let mut bad = spec.clone(); + bad.outcome_count = bad_count; + assert_eq!( + f.resolver.try_register_cat_signer_spec(&f.market_id, &bad), + Err(Ok(Error::InvalidConfig)) + ); + } + let mut bad = spec.clone(); + bad.threshold = 0; + assert_eq!( + f.resolver.try_register_cat_signer_spec(&f.market_id, &bad), + Err(Ok(Error::InvalidConfig)) + ); + // A valid-but-different rule does not match the committed hash. + let mut wrong = spec.clone(); + wrong.threshold = 1; + assert_eq!( + f.resolver + .try_register_cat_signer_spec(&f.market_id, &wrong), + Err(Ok(Error::SpecHashMismatch)) + ); + f.resolver.register_cat_signer_spec(&f.market_id, &spec); + // One reveal per market. + assert_eq!( + f.resolver.try_register_cat_signer_spec(&f.market_id, &spec), + Err(Ok(Error::MarketAlreadyConfigured)) + ); + + // Attesting before close is rejected. + f.env.ledger().set_timestamp(1_500); + assert_eq!( + f.resolver.try_attest_cat(&f.market_id, &f.signer_a, &0u32), + Err(Ok(Error::MarketNotClosed)) + ); + f.env.ledger().set_timestamp(2_000); + // Only configured signers can attest. + let outsider = Address::generate(&f.env); + assert_eq!( + f.resolver.try_attest_cat(&f.market_id, &outsider, &0u32), + Err(Ok(Error::NotSigner)) + ); + // Indices at or above the committed outcome count are rejected. + assert_eq!( + f.resolver.try_attest_cat(&f.market_id, &f.signer_a, &3u32), + Err(Ok(Error::InvalidOutcomeIndex)) + ); + // An attestation is recorded at most once per signer and index. + f.resolver.attest_cat(&f.market_id, &f.signer_a, &1u32); + assert_eq!( + f.resolver.try_attest_cat(&f.market_id, &f.signer_a, &1u32), + Err(Ok(Error::AlreadyAttested)) + ); + } + + #[test] + fn cat_proposal_opens_with_quorum_and_guards_expiry() { + let f = cat_fixture(|env, a, b| cat_signer_spec(env, a, b, 3)); + let ResolutionSpec::CategoricalSigners(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_cat_signer_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + + // No proposal until `threshold` signers attest the same index. + assert_eq!( + f.resolver.try_finalize_cat(&f.market_id), + Err(Ok(Error::NoProposal)) + ); + f.resolver.attest_cat(&f.market_id, &f.signer_a, &0u32); + f.resolver.attest_cat(&f.market_id, &f.signer_b, &1u32); + assert_eq!( + f.resolver.try_finalize_cat(&f.market_id), + Err(Ok(Error::NoProposal)) ); - let resolver = ResolverClient::new(&env, &resolver_id); - Fixture { - env, - admin, - resolver, - resolver_id, - market_core, - market_id, - signer_a, - signer_b, - reflector_id, - spec, - } + // The quorum that opens the proposal lands past the grace deadline, so + // the pending winner blocks expiry… + f.env + .ledger() + .set_timestamp(2_000 + RESOLUTION_GRACE_SECS + 1); + f.resolver.attest_cat(&f.market_id, &f.signer_b, &0u32); + assert_eq!( + f.resolver.try_expire_market(&f.market_id), + Err(Ok(Error::ResolutionAvailable)) + ); + // …and the guardian can still withdraw it, because `cancel_proposal` + // is bounded by the proposal's own dispute window. Afterwards the + // market voids. + f.resolver + .cancel_proposal(&f.market_id, &GuardianReason::DisputeUpheld); + f.resolver.expire_market(&f.market_id); + assert_eq!( + f.market_core.get_cat_market(&f.market_id).status, + CatStatus::Void + ); } - fn price_spec(env: &Env, reflector: &Address) -> PriceConfig { - PriceConfig { - reflector: reflector.clone(), - asset: OracleAsset::Other(Symbol::new(env, "XLM")), - threshold: 2_000, - direction: PriceDirection::Above, - max_staleness: 10, - } - } + #[test] + fn scalar_price_mode_resolves_mid_range() { + let f = scalar_fixture(scalar_spec); + let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); + reflector.set_price(&176_i128, &1_999_u64); - fn signer_spec(env: &Env, a: &Address, b: &Address) -> SignerConfig { - SignerConfig { - signers: Vec::from_array(env, [a.clone(), b.clone()]), - threshold: 2, - dispute_window: 100, - } - } + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_scalar_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); - fn v2_envelope( - env: &Env, - market_core: &Address, - salt: [u8; 32], - rule: &ResolutionSpec, - ) -> ResolutionSpecV2 { - ResolutionSpecV2 { - version: 2, - network_id: BytesN::from_array(env, &NETWORK_ID), - market_core: market_core.clone(), - salt: BytesN::from_array(env, &salt), - rule: rule.clone(), - } + // (176 - 100) / (250 - 100) in bps: 760_000 / 150 = 5066.67 -> 5066. + assert_eq!(f.resolver.resolve_scalar_price(&f.market_id), 5_066); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::ResolvedScalar(5_066) + ); + // One-shot: the market is terminal after resolution. + assert_eq!( + f.resolver.try_resolve_scalar_price(&f.market_id), + Err(Ok(Error::AlreadyFinalized)) + ); } - fn envelope(f: &Fixture, salt: [u8; 32]) -> ResolutionSpecV2 { - v2_envelope(&f.env, &f.market_core.address, salt, &f.spec) - } + #[test] + fn scalar_price_clamps_below_lower() { + let f = scalar_fixture(scalar_spec); + let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); + // 50 is below `lower` (100): the payout clamps to zero instead of + // reverting, so the market always resolves. + reflector.set_price(&50_i128, &1_999_u64); - /// A fixture whose market commits to the V2 envelope of the rule `spec_fn` - /// returns, salted with `salt`. - fn v2_fixture( - salt: [u8; 32], - spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> ResolutionSpec, - ) -> Fixture { - fixture_with(spec_fn, move |env, rule, market_core| { - spec_v2_hash(env, &v2_envelope(env, market_core, salt, rule)) - }) - } + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_scalar_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); - fn v2_signer_fixture(salt: [u8; 32]) -> Fixture { - v2_fixture(salt, |env, _r, a, b| { - ResolutionSpec::Signers(signer_spec(env, a, b)) - }) + assert_eq!(f.resolver.resolve_scalar_price(&f.market_id), 0); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::ResolvedScalar(0) + ); } #[test] - fn price_mode_resolves_from_historical_reflector_price() { - let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); + fn scalar_price_clamps_above_upper() { + let f = scalar_fixture(scalar_spec); let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); - reflector.set_price(&2_500_i128, &1_999_u64); + // 400 is above `upper` (250): the payout saturates at 10_000 bps. + reflector.set_price(&400_i128, &1_999_u64); - let ResolutionSpec::Price(spec) = f.spec.clone() else { + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { unreachable!() }; - f.resolver.register_price_spec(&f.market_id, &spec); + f.resolver.register_scalar_spec(&f.market_id, &spec); f.env.ledger().set_timestamp(2_000); - assert_eq!(f.resolver.resolve_price(&f.market_id), Outcome::Yes); + assert_eq!(f.resolver.resolve_scalar_price(&f.market_id), 10_000); assert_eq!( f.market_core.get_market(&f.market_id).status, - MarketStatus::Resolved(Outcome::Yes) + MarketStatus::ResolvedScalar(10_000) ); } #[test] - fn signer_mode_requires_threshold_and_waits_for_dispute_window() { - let f = fixture(|env, _r, a, b| ResolutionSpec::Signers(signer_spec(env, a, b))); - let ResolutionSpec::Signers(spec) = f.spec.clone() else { + fn scalar_rejects_non_positive_price() { + let f = scalar_fixture(scalar_spec); + let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); + + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { unreachable!() }; - f.resolver.register_signer_spec(&f.market_id, &spec); + f.resolver.register_scalar_spec(&f.market_id, &spec); f.env.ledger().set_timestamp(2_000); - f.resolver - .attest(&f.market_id, &f.signer_a, &AttestedOutcome::No); - f.resolver - .attest(&f.market_id, &f.signer_b, &AttestedOutcome::No); + // A non-positive reading is a broken feed, not a value at the bottom + // of the range, so it must not clamp silently. + for bad in [0_i128, -5] { + reflector.set_price(&bad, &1_999_u64); + assert_eq!( + f.resolver.try_resolve_scalar_price(&f.market_id), + Err(Ok(Error::InvalidPrice)) + ); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::Open + ); + } + + // The rejection is recoverable: a healthy reading resolves normally. + reflector.set_price(&200_i128, &1_999_u64); + assert_eq!(f.resolver.resolve_scalar_price(&f.market_id), 6_666); + } + + #[test] + fn scalar_rejects_stale_price() { + let f = scalar_fixture(scalar_spec); + let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); + + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_scalar_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + // One second older than `max_staleness` (10) is stale... + reflector.set_price(&176_i128, &1_989_u64); assert_eq!( - f.resolver.try_finalize_signers(&f.market_id), - Err(Ok(Error::DisputeWindowOpen)) + f.resolver.try_resolve_scalar_price(&f.market_id), + Err(Ok(Error::StalePrice)) ); - f.env.ledger().set_timestamp(2_100); + // ...a future-dated reading is stale too... + reflector.set_price(&176_i128, &2_001_u64); assert_eq!( - f.resolver.finalize_signers(&f.market_id), - AttestedOutcome::No + f.resolver.try_resolve_scalar_price(&f.market_id), + Err(Ok(Error::StalePrice)) ); + // ...and exactly `max_staleness` old is the last accepted age. + reflector.set_price(&176_i128, &1_990_u64); + assert_eq!(f.resolver.resolve_scalar_price(&f.market_id), 5_066); + } + + #[test] + fn scalar_spec_registration_validations() { + let f = scalar_fixture(scalar_spec); + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { + unreachable!() + }; + + // The reveal is validated before the commitment is checked. + for (lower, upper, max_staleness) in [ + (100_i128, 100_i128, 10u64), // empty range + (250, 100, 10), // inverted range + (-1, 250, 10), // negative lower + (0, i128::MAX / BPS + 1, 10), + (100, 250, 0), // staleness window disabled + ] { + let mut bad = spec.clone(); + bad.lower = lower; + bad.upper = upper; + bad.max_staleness = max_staleness; + assert_eq!( + f.resolver.try_register_scalar_spec(&f.market_id, &bad), + Err(Ok(Error::InvalidConfig)) + ); + } + + // A valid-but-different rule does not match the committed hash. + let mut wrong = spec.clone(); + wrong.upper = 251; assert_eq!( - f.market_core.get_market(&f.market_id).status, - MarketStatus::Resolved(Outcome::No) + f.resolver.try_register_scalar_spec(&f.market_id, &wrong), + Err(Ok(Error::SpecHashMismatch)) + ); + + f.resolver.register_scalar_spec(&f.market_id, &spec); + // One reveal per market, even with the identical rule. + assert_eq!( + f.resolver.try_register_scalar_spec(&f.market_id, &spec), + Err(Ok(Error::MarketAlreadyConfigured)) + ); + } + + #[test] + fn scalar_spec_hash_disjoint_from_price() { + // A price rule revealed against a scalar commitment mismatches... + let f = scalar_fixture(scalar_spec); + let as_price = PriceConfig { + reflector: f.reflector_id.clone(), + asset: OracleAsset::Other(Symbol::new(&f.env, "XLM")), + threshold: 100, + direction: PriceDirection::Above, + max_staleness: 10, + }; + assert_eq!( + f.resolver.try_register_price_spec(&f.market_id, &as_price), + Err(Ok(Error::SpecHashMismatch)) + ); + + // ...and a scalar rule cannot be revealed against a price commitment. + let g = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); + let as_scalar = ScalarPriceConfig { + reflector: g.reflector_id.clone(), + asset: OracleAsset::Other(Symbol::new(&g.env, "XLM")), + lower: 100, + upper: 250, + max_staleness: 10, + }; + assert_eq!( + g.resolver + .try_register_scalar_spec(&g.market_id, &as_scalar), + Err(Ok(Error::SpecHashMismatch)) + ); + } + + /// Appending `ResolutionSpec::Scalar` must not move the commitment of any + /// variant that existed before it. These hashes pre-date scalar support; + /// they are re-pinned here through the production `legacy_spec_hash` path + /// (the `golden` module pins the same bytes via raw `sha256(to_xdr)`). + #[test] + fn existing_spec_hashes_unchanged() { + let env = Env::default(); + let hex = |h: &BytesN<32>| -> std::string::String { + h.to_array().iter().map(|b| format!("{b:02x}")).collect() + }; + let g1 = Address::from_str( + &env, + "GBAKBHXK7SV7C3HGY3DDYB4QWUEGL52SEF6OO77LAD672JVXD4QCYJLF", + ); + + let price = ResolutionSpec::Price(PriceConfig { + reflector: Address::from_str( + &env, + "CC3KU5TWAATOBXLWIV4ZTXNEM44VIOY3ZTR7KTJTBOOBP7YIXMCDSWDJ", + ), + asset: OracleAsset::Other(Symbol::new(&env, "XLM")), + threshold: 2_000, + direction: PriceDirection::Above, + max_staleness: 10, + }); + assert_eq!( + hex(&legacy_spec_hash(&env, &price)), + "b6f2458326d5f76bd9b53a93a103ea6c0fed7129c5496e91e3faef9a9a23d469", + ); + + let signers = ResolutionSpec::Signers(SignerConfig { + signers: Vec::from_array(&env, [g1.clone()]), + threshold: 1, + dispute_window: 5, + }); + assert_eq!( + hex(&legacy_spec_hash(&env, &signers)), + "b935a3beeebdbf7b9defc916410f19a1740a3920623230152e41a812b6f141c4", + ); + + let cat = ResolutionSpec::CategoricalSigners(CatSignerConfig { + signers: Vec::from_array(&env, [g1]), + threshold: 1, + dispute_window: 5, + outcome_count: 2, + }); + assert_eq!( + hex(&legacy_spec_hash(&env, &cat)), + "16db57d51742d266dccd1a70539afc160971c942605a0ae8f15b5a18cd1b2837", ); } @@ -5349,6 +6253,25 @@ mod golden { }) } + fn cat_signers( + env: &Env, + keys: &[&str], + outcome_count: u32, + threshold: u32, + dispute_window: u64, + ) -> ResolutionSpec { + let mut signers = Vec::new(env); + for key in keys { + signers.push_back(Address::from_str(env, key)); + } + ResolutionSpec::CategoricalSigners(CatSignerConfig { + signers, + threshold, + dispute_window, + outcome_count, + }) + } + fn price( env: &Env, asset: OracleAsset, @@ -5365,11 +6288,55 @@ mod golden { }) } + fn scalar( + env: &Env, + asset: OracleAsset, + lower: i128, + upper: i128, + max_staleness: u64, + ) -> ResolutionSpec { + ResolutionSpec::Scalar(ScalarPriceConfig { + reflector: Address::from_str(env, C1), + asset, + lower, + upper, + max_staleness, + }) + } + fn hex_of(env: &Env, spec: &ResolutionSpec) -> String { let hash: BytesN<32> = env.crypto().sha256(&spec.clone().to_xdr(env)).into(); hash.to_array().iter().map(|b| format!("{b:02x}")).collect() } + /// Shared byte-for-byte with `golden_scalar_vectors` in + /// `scripts/spec-hash`: the scalar variant (tag 5) mirrors the price + /// fields, so it must be pinned against drifting into the price hash + /// space. + #[test] + fn golden_legacy_scalar_spec_hashes() { + let env = Env::default(); + let xlm = || OracleAsset::Other(Symbol::new(&env, "XLM")); + let asset = |id: &str| OracleAsset::Stellar(Address::from_str(&env, id)); + let vectors = [ + ( + scalar(&env, xlm(), 100, 250, 10), + "e97c4bccc39a4538850b22e703019c3b69971a00b99bc73117cc67e044e26822", + ), + ( + scalar(&env, asset(C2), 0, 1_000_000_000_000, 300), + "1d94380e9112c6f80f7542106dcc75ffc1426a634da2a07440fac72d2033fa08", + ), + ]; + for (spec, expected) in vectors { + assert_eq!(hex_of(&env, &spec), expected); + } + assert_ne!( + hex_of(&env, &scalar(&env, xlm(), 2_000, 2_000, 10)), + hex_of(&env, &price(&env, xlm(), 2_000, PriceDirection::Above, 10)), + ); + } + #[test] fn golden_legacy_signer_spec_hashes() { let env = Env::default(); @@ -5388,6 +6355,32 @@ mod golden { } } + /// Shared byte-for-byte with `golden_cat_signers_vectors` in + /// `scripts/spec-hash`: the CLI parsers and the contract types must produce + /// byte-identical commitments, and the appended variant must not collide + /// with any existing one. + #[test] + fn golden_legacy_cat_signer_spec_hashes() { + let env = Env::default(); + let vectors = [ + ( + cat_signers(&env, &[G1], 2, 1, 5), + "16db57d51742d266dccd1a70539afc160971c942605a0ae8f15b5a18cd1b2837", + ), + ( + cat_signers(&env, &[G1, G2, G3], 3, 2, 3_600), + "21b750237b5cb3f54bdc71ec55a1d8073a3b860f6cb3cb3b9717570dc42092e7", + ), + ]; + for (spec, expected) in vectors { + assert_eq!(hex_of(&env, &spec), expected); + } + assert_ne!( + hex_of(&env, &cat_signers(&env, &[G1], 2, 1, 5)), + hex_of(&env, &signers(&env, &[G1], 1, 5)), + ); + } + /// `Vec` order is part of the XDR, so the same signer set in another /// order is a different commitment. Off-chain tooling (the market-maker's /// `agents/core/spec.ts`, `scripts/seed-market.sh`) must hash and reveal @@ -5397,7 +6390,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/treasury/src/lib.rs b/oracledesk-stellar/contracts/treasury/src/lib.rs index e4c475d..9627a62 100644 --- a/oracledesk-stellar/contracts/treasury/src/lib.rs +++ b/oracledesk-stellar/contracts/treasury/src/lib.rs @@ -1873,6 +1873,40 @@ impl Treasury { ) } + /// Categorical counterpart of `agent_buy`: buys `outcome_index` shares of + /// a categorical market using treasury capital (`ROLE_TRADE`). The binary + /// price band is a Yes/No concept so it does not apply here, but the + /// per-trade / per-market / daily caps, the reserved-capital check, the + /// net-exposure reserve and the nested transfer pre-authorization all do + /// (see `do_agent_buy`). + pub fn agent_buy_cat( + env: Env, + agent: Address, + market_id: u64, + outcome_index: u32, + collateral_in: i128, + min_shares_out: i128, + ) -> Result { + Self::assert_not_paused(&env)?; + let global = Self::load_global(&env)?; + let policy = Self::require_agent(&env, &agent, ROLE_TRADE)?; + Self::check_trade_size(&global, &policy, collateral_in)?; + Self::ensure_unreserved(&env, &global, collateral_in)?; + + let treasury_addr = env.current_contract_address(); + Self::authorize_token_pull(&env, &global, &treasury_addr, collateral_in); + let shares = MarketCoreClient::new(&env, &global.market_core).buy_cat( + &treasury_addr, + &market_id, + &outcome_index, + &collateral_in, + &min_shares_out, + ); + Self::reserve_capacity(&env, &global, &agent, &policy, market_id, collateral_in)?; + Self::reserve_net(&env, &global, market_id, collateral_in)?; + Ok(shares) + } + /// Sells `outcome` shares back into the pool for exactly /// `collateral_out` (`ROLE_TRADE`). This returns capital to the treasury /// (market-core pays out of its own balance), so it is not capped and @@ -3381,6 +3415,12 @@ impl Treasury { match status { MarketStatus::Resolved(Outcome::Yes) => yes, MarketStatus::Resolved(Outcome::No) => no, + MarketStatus::ResolvedScalar(payout_long_bps) => { + // Mirror of market-core's `math::scalar_payout`: each leg floors + // independently at its fractional share of the complete set. + let p = *payout_long_bps as i128; + yes.saturating_mul(p) / BPS + no.saturating_mul(BPS - p) / BPS + } MarketStatus::Void => yes.saturating_add(no) / 2, MarketStatus::Open => 0, } @@ -3942,7 +3982,7 @@ mod test { use soroban_sdk::{ testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}, token::StellarAssetClient, - BytesN, String, + BytesN, String, Vec, }; // ---- dual registration of market-core (coverage, issue #75) --------------- @@ -4748,6 +4788,78 @@ mod test { assert!(shares > 0); } + /// Creates a 3-outcome categorical market seeded by the fixture creator. + fn create_cat_market(f: &Fixture, nonce: u8) -> u64 { + let creator = f.market_core.get_market(&f.market_id).creator; + f.market_core.create_categorical_market( + &creator, + &BytesN::from_array(&f.env, &[nonce; 32]), + &BytesN::from_array(&f.env, &[nonce + 100; 32]), + &String::from_str(&f.env, "ipfs://treasury-cat-market"), + &Category::Crypto, + &MARKET_CLOSE, + &(100 * 10_000_000_i128), + &Vec::from_array(&f.env, [5_000u32, 3_000, 2_000]), + ) + } + + #[test] + fn agent_buy_cat_respects_trade_and_market_caps() { + let f = fixture(); + let cat_market = create_cat_market(&f, 18); + + // Same single-trade cap as the binary path. + assert_eq!( + f.treasury + .try_agent_buy_cat(&f.agent, &cat_market, &0u32, &(21 * 10_000_000_i128), &0), + Err(Ok(Error::TradeCapExceeded)) + ); + // Two capped buys fill the per-market cap; a third — even on another + // outcome — is rejected. + f.treasury + .agent_buy_cat(&f.agent, &cat_market, &0u32, &(20 * 10_000_000_i128), &0); + f.treasury + .agent_buy_cat(&f.agent, &cat_market, &2u32, &(20 * 10_000_000_i128), &0); + assert_eq!( + f.treasury + .try_agent_buy_cat(&f.agent, &cat_market, &1u32, &(20 * 10_000_000_i128), &0), + Err(Ok(Error::MarketCapExceeded)) + ); + assert_eq!( + f.treasury.try_close_trade(&f.agent, &cat_market), + Ok(Ok(40 * 10_000_000_i128)) + ); + } + + /// The categorical twin of the `agent_buy` pitfall test: `buy_cat`'s + /// nested token transfer must be pre-authorized too (`mock_auths` for + /// only the agent, so a regression in `authorize_token_pull` fails here). + #[test] + fn pitfall_agent_buy_cat_forwards_nested_transfer_auth() { + let f = fixture(); + let cat_market = create_cat_market(&f, 19); + let agent = f.treasury.get_config().agent; + let outcome_index = 1u32; + let amount = 20 * 10_000_000_i128; + let min_out = 0_i128; + + f.env.set_auths(&[]); + let shares = f + .treasury + .mock_auths(&[MockAuth { + address: &agent, + invoke: &MockAuthInvoke { + contract: &f.treasury_id, + fn_name: "agent_buy_cat", + args: (agent.clone(), cat_market, outcome_index, amount, min_out) + .into_val(&f.env), + sub_invokes: &[], + }, + }]) + .agent_buy_cat(&agent, &cat_market, &outcome_index, &amount, &min_out); + assert!(shares > 0); + } + #[test] fn migrate_market_moves_position_and_lp_to_successor() { let f = fixture_with_caps( diff --git a/oracledesk-stellar/docs/STATUS.md b/oracledesk-stellar/docs/STATUS.md index cbe6c5b..5bca97a 100644 --- a/oracledesk-stellar/docs/STATUS.md +++ b/oracledesk-stellar/docs/STATUS.md @@ -99,6 +99,48 @@ The work above landed on `develop` as merge commit `581a3ec` (PR #153, merged 2026-10-16), so the feature-branch reference above is historical and `develop` is the source of truth for #8, #10, #12 and #13. +## Contributor addendum: 2026-10-16 — categorical (#6), scalar (#7), dynamic fees (#9) + +Implemented together on `feat/categorical-scalar-dynamic-fees` (PR against +`develop`): n-outcome categorical markets (2..=8 — +`create_categorical_market` / `buy_cat` / `sell_cat` / `resolve_cat` / +`redeem_cat` / `claim_pool_remainder_cat`), scalar range markets +(`create_scalar_market` / `resolve_scalar` / `get_scalar_meta`, settled from +a Reflector feed via `resolver.resolve_scalar_price`), and per-market +dynamic fee schedules (`FeeSchedule` / `effective_fee_bps`, +`create_market_with_fees`, `quote_buy_at`, `current_fee_bps`); the resolver +also gained categorical signer resolution (`register_cat_signer_spec` / +`attest_cat` / `finalize_cat`) and the treasury an `agent_buy_cat` +forwarder. Design and invariants: `docs/architecture.md`; storage and +append-only-XDR decision: [ADR 0006](adr/0006-market-kind-storage-split.md). + +Verified in the contributor session, re-measured after merging `develop`: +`cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, +`cargo test --workspace` (now **305** tests: market-core 97, resolver 107, +treasury 95, reasoning-registry 6 — superseding the 87-test count above), the +`scripts/spec-hash` crate (11 tests), `python3 model/fpmm_model.py`, agents (54 +tests) and x402 (5 tests), plus `make build` (vendored wasm regenerated) and +`make bindings` with only the intended diffs. The new math is mirrored in all three +languages against one shared fixture file (`model/fee_fixtures.json`) that +Python, Rust and TypeScript all read. Worst-case categorical cost was +measured, not estimated: `buy_cat` at `n = 8` takes 444,604 instructions / +64,092 B memory / 1,640 write bytes across 5 write entries — under 60% of +the network limits, asserted by the `cat_eight_outcomes_resource_budget` +test. + +**Release note for bindings consumers:** `MarketStatus` and +`ResolutionSpec` gained *appended* variants (`ResolvedScalar`, +`CategoricalSigners`, `Scalar`). Values using them cannot be decoded by +bindings generated before this change — regenerate; the committed +`packages/bindings` in this PR are regenerated. + +**Not** (re)verified: no testnet redeploy — the deployed contracts and +`deployments/testnet.json` predate these changes, so the new entrypoints +have contract-level and off-chain tests only, not a real-ledger +transaction. Categorical markets keep a flat fee (the dynamic schedule +applies to binary and scalar markets) and neither new kind has a live +testnet transaction yet. + ## Contributor addendum: 2026-10-18 Signer-key rotation via committee registry (#35), hybrid price feed with attested diff --git a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md index f81c2d1..5c41540 100644 --- a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md +++ b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md @@ -56,6 +56,14 @@ The commitment format is extended by [ADR 0004](0004-spec-versioning.md): a salted, versioned, domain-separated `ResolutionSpecV2` envelope accepted alongside the legacy format described above. +The `ResolutionSpec` enum gains two **appended** variants — +`CategoricalSigners` (tag 4, for n-outcome markets) and `Scalar` (tag 5, a +price spec that resolves to a payout in basis points rather than an +outcome). Appending preserves the disjointness argument above: existing +variants keep their tags, so every already-committed hash is unchanged, and +each new rule lives in its own hash space. See +[ADR 0006](0006-market-kind-storage-split.md). + ## Liveness fallback A market whose spec is never revealed, whose committed feed is permanently diff --git a/oracledesk-stellar/docs/adr/0006-market-kind-storage-split.md b/oracledesk-stellar/docs/adr/0006-market-kind-storage-split.md new file mode 100644 index 0000000..dbc4006 --- /dev/null +++ b/oracledesk-stellar/docs/adr/0006-market-kind-storage-split.md @@ -0,0 +1,116 @@ +# ADR 0006: Market kinds as side storage, append-only XDR variants + +## Status + +Accepted. + +## Context + +Categorical (n-outcome, #6) and scalar (range, #7) markets arrived after +`market-core` had shipped binary markets and after the resolution commitment +of [ADR 0001](0001-resolution-commitment.md) had pinned the hash of every +committed `ResolutionSpec`. Adding them raised a storage question: where do +the new market shapes live? + +Three forces collide: + +- **`Market`'s XDR is consensus-critical material.** It is hashed into + nothing directly, but it is embedded in events (`MarketCreated` and + friends), read by deployed bindings, and mirrored off-chain. Appending a + field (say `kind`) changes the encoding of every existing value — structs + encode as maps sorted by field name, so even an appended field reorders + nothing but changes the map length and content. Deployed readers would + fail to decode, and the change would be indistinguishable from a rename + for any consumer that pins bytes. +- **The `market_id` space is shared.** Markets are created against one + monotonic id counter; a categorical market does not get, and should not + spend, a second id. +- **New enum variants are unavoidable somewhere.** A resolution rule and a + status must express "categorical" and "scalar", and an audit of ADR 0001's + hash spaces shows both enums are in scope (see below). + +## Decision + +Never change the binary `Market` struct. Each new market kind is a +**separate persistent entry keyed by the same `market_id`**: + +- `DataKey::Kind(market_id) -> MarketKind` — `Binary`, `Categorical(u32)`, + or the scalar kind; the registry of what a given id is. `market_kind` + exposes it (returning `Binary` for ids created before the key existed — + the old shape is the default, so no migration transaction). +- `CatMarket` / `CatPosition` — categorical state and holdings + (`reserves: Vec`, one vec entry per outcome). +- `ScalarMeta` — scalar display metadata (`lower`, `upper`); the scalar + market's pool *is* the binary `Market` (LONG = YES, SHORT = NO), so + trading, fees, redemption safety and the solvency invariant are reused + unchanged rather than reimplemented. +- `FeeSchedule` (#9) — a per-market dynamic fee side entry; absent means the + binary default (flat `fee_bps`), so again no migration. + +Entrypoints dispatch on `market_kind`: `get_market` returns +`MarketNotFound` for a categorical id (its shape is `get_cat_market`), +`buy_cat` / `sell_cat` / `resolve_cat` / `redeem_cat` refuse binary ids and +vice versa. + +Enum changes are **append-only**: + +- `MarketStatus` gains `ResolvedScalar(u32)` as the *last* variant (tag 3). + Scalar resolution does not reuse `Resolved(Outcome)` because a scalar + payout is not a binary outcome, and reusing it would silently make old + readers interpret a 0–10000 payout as Yes/No. +- `ResolutionSpec` gains `CategoricalSigners(CatSignerConfig)` (tag 4) and + `Scalar(ScalarPriceConfig)` (tag 5), appended after the four variants + ADR 0001 and ADR 0004 already fixed. The spec hash spaces stay disjoint: + a scalar spec hashes as the `Scalar` envelope, never as a bare + `PriceConfig`. +- `MarketKind` itself is a fresh type; only its `Binary` tag is load-bearing + (it is the fallback for absent keys), and it is encoded with `Binary` + first from the start. + +The categorical pool reuses the binary FPMM's exact math shape — the +`calc_buy` round-trip from the multi-asset constant-product formulation — +with per-outcome reserves renormalized by `Π(r_others)`, so the same +floor/ceil directions (traders never gain a stroop from rounding, `k` never +decreases) carry over; the invariants are property-tested in all three +mirrors rather than re-proven on paper here. + +## Consequences + +- **Deployed binary readers keep working.** Old bindings and indexers see + byte-identical `Market`, `MarketStatus` (for tags 0–2), and `ResolutionSpec` + (tags 0–3) encodings. The cost: a reader that does not know about appended + variants will fail to decode *new* values (a scalar-resolved market's + status has tag 3) — that is the intended, visible failure mode, and it is a + release note, not a compatibility shim. `docs/STATUS.md` and the PR carry + it. +- **Readers must dispatch.** Anything fetching a market from an id alone + reads `market_kind` first. This is a modest off-chain burden and it is + uniform across the three kinds. +- **Golden vectors pin the encodings.** `ResolutionSpec` XDR hashes are + pinned in contract golden modules and `scripts/spec-hash` (3-way verified + against Python and `stellar xdr decode`), so an accidental reorder of the + variant list — which would change every committed hash — fails a test + instead of a market. +- **`n` is bounded at 8** (`MAX_MARKET_OUTCOMES`). The measured worst case + stays under 60% of Soroban limits (see `docs/architecture.md`), and quoting + an n=8 buy touches 5 storage entries — the cap is a transaction-footprint + cap, not just a gas estimate. +- The categorical market keeps a **flat** `fee_bps` (snapshotted like the + binary one). The dynamic `FeeSchedule` currently applies only to binary + and scalar markets; extending it is additive via the same side entry. + +## Alternatives considered + +- **Extend `Market` with a `kind` field / optional vecs.** Breaks the XDR of + every existing market and every event that carries one; rejected outright + under the "consensus-critical" constraint. +- **A separate `market-core-cat` contract.** Splits the id space (categorical + ids would collide with binary ones or require a global counter anyone can + increment — a griefing vector), duplicates fee/treasury/resolver wiring, + and forces the resolver to know two addresses for "the market with id N". +- **A wrapper enum `MarketValue { Binary(Market), Categorical(...) }` under + the existing key.** Same XDR breakage as extending `Market`: the stored + value's encoding changes for every existing market. +- **Reusing `MarketStatus::Resolved(Outcome)` for scalar payouts.** Loses the + payout value entirely (a binary outcome cannot carry 0–10000 bps) or + overloads the variant's meaning for old readers; rejected. diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index 1f1110a..3c78417 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -8,21 +8,33 @@ responsibilities and the invariants that hold across them. ### market-core -The hub: every binary market lives here as one entry in persistent storage, -keyed by `market_id`. Pricing is a Gnosis-style constant-product FPMM over -internal YES/NO share accounting — no external LP token, no AMM pool -contract per market. `contracts/market-core/src/math.rs` is the entire -pricing engine, pure integer arithmetic, no `Env` dependency, so it's +The hub: every market — binary, categorical, scalar — lives here as one +entry in persistent storage, keyed by `market_id`. Pricing is a Gnosis-style +constant-product FPMM over internal share accounting — no external LP token, +no AMM pool contract per market. `contracts/market-core/src/math.rs` is the +entire pricing engine, pure integer arithmetic, no `Env` dependency, so it's natively unit-testable and mirrored line-for-line in [`model/fpmm_model.py`](../model/fpmm_model.py) and `agents/core/fpmm.ts` for property testing and off-chain quoting. +**Three market kinds, one id space.** A market is binary (the original +`Market`), categorical (#6, 2–8 outcomes), or scalar (#7, a range bet on a +price feed). The XDR of the original `Market` struct is never changed: each +new kind is *side storage* keyed by the same `market_id` (`DataKey::Kind` +records which kind an id is; `market_kind` exposes it), and new enum +variants are appended after the existing tags so deployed readers keep +decoding old values. Readers dispatch on `market_kind` first: `get_market` +returns `MarketNotFound` for a categorical id, whose shape lives in +`get_cat_market`. See [ADR 0006](adr/0006-market-kind-storage-split.md) for +the full argument and the alternatives rejected. + **Solvency invariant** (holds after every state transition — see the test suite for the ones exercised): the contract's own SEP-41 token balance for a market equals `sets_minted + fees_accrued + lp_fees` (`fees_accrued` is the protocol's pending share of trading fees, `lp_fees` the creator's — see the fee split below), and `reserve_yes + Σ(user yes positions) == sets_minted` -(symmetrically for `no`). +(symmetrically for `no`). The same invariant holds for categorical markets +on `CatMarket`. **Fee split and recipient snapshot.** `create_market` snapshots a `FeeTerms { recipient, protocol_share_bps }` side entry per market, so @@ -42,6 +54,30 @@ LP balance (rather than adding fees to the reserves) is deliberate: it does not move `k` or the price and maps directly onto the per-LP fee accumulator of the future multi-LP design ([wave-issue 02](wave-issues/02-multi-lp-liquidity.md)). +**Dynamic fee schedules (#9).** A market can charge a scheduled fee instead +of a flat one. `FeeSchedule { base_bps, max_bps, ramp_secs, skew_mult_bps }` +is snapshotted at creation — from `DataKey::DefaultFeeSchedule` (set by the +admin's `set_default_fee_schedule`, which only affects markets created after +it) or from an explicit `create_market_with_fees`, whose schedule is +validated against the default's `max_bps` cap before a market id is +allocated. The effective fee is recomputed per trade by +`math::effective_fee_bps(schedule, now, close_time, r_yes, r_no)`: +`min(max_bps, base_bps + ramp + skew)`, where `ramp` rises linearly from +`base_bps` to `max_bps` over the last `ramp_secs` before `close_time` (and +stays at `max_bps` afterwards), and `skew` adds `skew_mult_bps` per 1000 bps +the pre-trade YES price sits away from 50/50. Every floor rounds in the +trader's favour; the contract layer bounds `max_bps <= MAX_FEE_BPS`, +`ramp_secs <= MAX_RAMP_SECS` (30 days) and `skew_mult_bps <= MAX_SKEW_MULT_BPS` +(1000), while the pure math only requires `base_bps <= max_bps < BPS`. +`buy` / `sell` / `quote_buy` / `quote_sell`, the `_v2` twins, and the fee +split all use the effective fee; `current_fee_bps` exposes it now, and +`quote_buy_at(market_id, outcome, collateral_in, at_timestamp)` quotes with +the fee that will apply at a *future* timestamp — the client-side latency +hedge described in [frontend-integration.md](frontend-integration.md). +`TradeV2` carries the realised `fee_bps` next to the existing `Trade` event. +Legacy markets have no schedule and keep their flat `fee_bps`; categorical +markets always charge the flat fee. + **Trade bounds and circuit breakers.** Two layers of protection beyond the share-count slippage parameters: @@ -86,6 +122,39 @@ emits `LpTransferred`. Both preserve the solvency invariant by construction; the position mapping is unchanged down to the "remove zero entries" rule. +**Categorical markets (#6).** `create_categorical_market` opens an +n-outcome market (`2 <= n <= 8`, `MAX_MARKET_OUTCOMES`) seeded from +`probs_bps` — one probability per outcome, each within +`MIN_PROB_BPS..=MAX_PROB_BPS` and summing to exactly 10000; the creator/LP +receives the leftover shares of the likelier outcomes. The pool is the +generalized FPMM: reserves `[r_0..r_n]` with the binary invariant +`k = r_yes * r_no` replaced by `k = Π(r_i)`; a `buy_cat` of outcome `i` +moves `r_i` in and renormalizes the other reserves down so the product is +preserved (`sell_cat` is the inverse). The math uses the same iterative +floor/ceil scheme as the binary path — rounding never pays the trader and +`k` never decreases — and is property-tested in all three mirrors. +`get_prices_cat` reports implied probabilities that sum to within `n` of +10000 bps; settlement pays one unit per winning share and a void pays half +per share, exactly like the binary path. Categorical markets keep a flat +snapshotted `fee_bps` (no dynamic schedule) and no price-based breakers; +collateral, pause switch, `FeeTerms` and the `resolution_hash` commitment +are shared with `create_market`. Worst case (`n = 8`) `buy_cat` costs +444,604 instructions / 64,092 B memory / 1,640 write bytes across 5 storage +entries — under 60% of the network limits, asserted by +`cat_eight_outcomes_resource_budget`. Events: `CatTrade`, +`CatMarketResolved`. + +**Scalar markets (#7).** `create_scalar_market` is a binary market +(LONG = YES, SHORT = NO) plus a `ScalarMeta { lower, upper }` display side +entry — trading, fees (dynamic schedules included), breakers and the whole +solvency story are inherited unchanged. Resolution writes +`MarketStatus::ResolvedScalar(payout_long_bps)` (an appended status variant; +0 = full SHORT, 10000 = full LONG) via `resolve_scalar`, and redemption +pays each holder `floor(yes·p / 10000) + floor(no·(10000 − p) / 10000)` — +both legs floor, so total payouts stay within `sets_minted` and a scalar +market cannot overpay. `get_scalar_meta` exposes the bounds; the meta is +display-only and never affects payouts. + **Economics.** LP worst-case loss, the rounding argument and the no-arbitrage theorem for this exact integer math are in [fpmm-economics.md](fpmm-economics.md) (executable checks: `model/fpmm_bounds.py`). @@ -93,11 +162,14 @@ theorem for this exact integer math are in [fpmm-economics.md](fpmm-economics.md **Resolution commitment**: `resolution_hash` is fixed at `create_market` time — see [ADR 0001](adr/0001-resolution-commitment.md) for why. -Lifecycle: `create_market` → `buy`/`sell` (while open; `buy_v2`/`sell_v2` -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` / +Lifecycle: `create_market` / `create_market_with_fees` / +`create_categorical_market` / `create_scalar_market` → `buy`/`sell` +(`buy_v2`/`sell_v2` with bounds; `buy_cat`/`sell_cat`) while open → +`resolve` / `resolve_cat` / `resolve_scalar` or `void_market` +(resolver-only) → `redeem` / `redeem_cat` per holder, +`claim_pool_remainder` / `claim_pool_remainder_cat` 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`. ### resolver @@ -134,6 +206,21 @@ Independent resolution modes, all ultimately calling signatures to `submit_signatures` in one transaction; the weight threshold starts the same dispute window. `cancel_proposal` moves the market to a new round, invalidating earlier signatures. Message format: ADR 0001. +- **Categorical signers**: the n-outcome counterpart of Attested. + `register_cat_signer_spec` reveals a `CategoricalSigners` spec (signer + set, `threshold`, `dispute_window`, and the committed `outcome_count`); + signers call `attest_cat(market_id, outcome_index)` — the first index to + reach `threshold` attestations becomes the proposal and opens the dispute + window — and anyone calls `finalize_cat` after the window to resolve the + winning index through market-core's `resolve_cat`. The guardian's + `cancel_proposal` clears a categorical proposal too. +- **Scalar price**: `register_scalar_spec` reveals a `Scalar` spec + (`ScalarPriceConfig`: a Reflector feed plus `lower` / `upper`). After + `close_time`, `resolve_scalar_price` reads the feed — a non-positive + reading is `InvalidPrice` (a broken feed, not a value at the bottom of the + range), with the same staleness window as `resolve_price` — clamps it into + `[lower, upper]` and settles the fractional LONG payout + `(clamped − lower) / (upper − lower)` in bps, flooring toward the pool. - **Committee with time-locked key rotation** (#35): binds resolution to a reusable committee registry rather than raw addresses. The admin can propose membership and threshold rotations with a mandatory delay (`rotation_delay >= 86_400`). @@ -150,11 +237,13 @@ The resolution lifecycle (both modes, the guardian, expiry and market-core's status) is model-checked in TLA+; see [formal/README.md](formal/README.md) for the properties, the findings and the conformance tests that replay the traces. -None of these modes can be configured until the revealed spec's hash matches the -market's commitment (`register_price_spec`, `register_signer_spec`, -`register_committee_spec`, or `register_hybrid_spec` — see ADR 0001). -A guardian can also `guardian_void_market` an unresolvable market directly, -subject to time windows and typed audit reasons. +None of these modes can be configured until the revealed spec's hash matches +the market's commitment, via the `register_*_spec` entrypoint matching the +committed variant (`register_price_spec`, `register_signer_spec`, +`register_committee_spec`, `register_hybrid_spec`, `register_cat_signer_spec`, +or `register_scalar_spec` — see ADR 0001). A guardian can also +`guardian_void_market` an unresolvable market directly, subject to time +windows and typed audit reasons. #### Oracle history retention @@ -258,10 +347,11 @@ off. Holds the agents' pooled USDC and is the *only* path that spends it: an agent calls `agent_create_market` / `agent_buy` / `agent_sell` / -`agent_buy_v2` / `agent_sell_v2` (always naming itself as the first -`agent` argument), the treasury checks the agent's role and its own -per-trade/per-market/daily caps plus the price limits, then calls -`market-core` itself as the trader/creator. This means the caps are a real on-chain limit on what the +`agent_buy_v2` / `agent_sell_v2` (and `agent_buy_cat`, the categorical +forwarder; always naming itself as the first `agent` argument), the treasury +checks the agent's role, its own per-trade/per-market/daily caps plus the +price limits, then calls `market-core` itself as the trader/creator. This +means the caps are a real on-chain limit on what the automated agent can deploy — an agent's own key calling `market-core` directly would just be trading its own separate funds, not bypassing a cap on the treasury's money. The `*_v2` twins forward the same caps plus @@ -709,7 +799,9 @@ no admin. is the only part that knows about Soroban, wrapping the generated bindings with a dry-run/live switch. The trader always quotes (`quote_buy`) immediately before submitting and derives non-zero - `min_shares_out` / `limit_price_bps` bounds plus a deadline from it. + `min_shares_out` / `limit_price_bps` bounds plus a deadline from it. In + live mode it reads `get_market` and `current_fee_bps` first, and sizes the + trade under the market's price-impact breaker when one is configured. - **packages/bindings/**: generated TypeScript clients (`scripts/gen-bindings.sh`). diff --git a/oracledesk-stellar/docs/frontend-integration.md b/oracledesk-stellar/docs/frontend-integration.md index d4ee674..99a11df 100644 --- a/oracledesk-stellar/docs/frontend-integration.md +++ b/oracledesk-stellar/docs/frontend-integration.md @@ -107,6 +107,50 @@ const tx = await contract.buy_v2({ `buy` / `sell` remain for backward compatibility but carry no deadline or limit; the share-count bound alone does not stop a stale transaction. +## Dynamic fees: quote at the fee you will pay + +A market may charge a **dynamic fee** (#9): `base_bps` ramping toward +`max_bps` over the last `ramp_secs` before `close_time`, plus a skew +surcharge the further the pre-trade price sits from 50/50. The fee is fixed +inside the trade, so plain `quote_buy` (fee as of *now*) can understate what +a slightly later submission pays — near the close, or with a skewed pool, +the drift can exceed a few bps of slippage. Quote with the timestamp you +expect the transaction to land at instead: + +```ts +const now = BigInt(Math.floor(Date.now() / 1000)); +const at = now + 30n; // expected simulation + signing + inclusion latency +const quote = (await contract.quote_buy_at({ + market_id, outcome, collateral_in, at_timestamp: at, +})).result; +``` + +- `quote_buy_at(market_id, outcome, collateral_in, at_timestamp)` — the + shares a buy would get with the schedule's fee at `at_timestamp`; + it still uses the reserves read at call time, so keep your usual slippage + margin on `min_shares_out`. +- `current_fee_bps(market_id)` — the effective fee right now, for display. +- `fee_schedule(market_id)` — the snapshotted schedule, or `null` for + legacy flat-fee markets. + +`TradeV2` carries the fee actually paid (`fee_bps`) per trade — index that +for "realised fee" displays rather than recomputing it. There is no +`quote_sell_at`, so a sell should either quote as late as possible or pad +`max_shares_in` to absorb the drift. + +## Market kinds + +`get_market(market_id)` reads binary and scalar markets only. Check +`market_kind(market_id)` when the id's kind is not already known: a +categorical id answers `MarketNotFound` there — read +`get_cat_market(market_id)` for reserves/status, `get_prices_cat(market_id)` +for implied probabilities (they sum to ~10000 bps), trade with +`buy_cat` / `sell_cat` (the same share-count bound, no `_v2` variant yet), +and settle with `redeem_cat` / `claim_pool_remainder_cat`. Scalar markets +are binary markets plus `get_scalar_meta(market_id)` (`{ lower, upper }`, +display only); their resolution arrives in `get_market`'s status as +`ResolvedScalar(payout_long_bps)`. + ## Circuit breakers Markets can carry per-market breakers (snapshotted at creation): a maximum diff --git a/oracledesk-stellar/model/fee_fixtures.json b/oracledesk-stellar/model/fee_fixtures.json new file mode 100644 index 0000000..d2a8521 --- /dev/null +++ b/oracledesk-stellar/model/fee_fixtures.json @@ -0,0 +1,15 @@ +[ + [100, 1000, 86400, 500, 1000000, 2000000, 1000, 1000, 100], + [100, 1000, 86400, 500, 1056800, 1100000, 1000, 1000, 550], + [100, 1000, 86400, 500, 1100000, 1100000, 1000, 1000, 1000], + [100, 1000, 0, 500, 1100000, 1100000, 1000, 3000, 1000], + [100, 1000, 0, 500, 0, 31622400, 9900, 10000, 112], + [50, 1000, 10000, 100, 495000, 500000, 3000, 5000, 650], + [50, 1000, 10000, 100, 485000, 500000, 3000, 5000, 175], + [50, 1000, 10000, 100, 600000, 500000, 3000, 5000, 1000], + [200, 900, 0, 100, 400000, 500000, 1000, 3000, 450], + [200, 900, 0, 100, 400000, 500000, 3000, 1000, 450], + [0, 500, 0, 1000, 400000, 500000, 9900, 100, 500], + [0, 500, 0, 1, 400000, 500000, 5000, 5001, 0], + [150, 150, 0, 0, 77, 88, 1000000000000, 1000000000000, 150] +] diff --git a/oracledesk-stellar/model/fpmm_model.py b/oracledesk-stellar/model/fpmm_model.py index 4b9c9f1..24ce8ed 100644 --- a/oracledesk-stellar/model/fpmm_model.py +++ b/oracledesk-stellar/model/fpmm_model.py @@ -18,8 +18,9 @@ """ from dataclasses import dataclass -from math import isqrt import json +import os +from math import isqrt import random from pathlib import Path @@ -27,6 +28,8 @@ MAX_SETS = 1_000_000_000_000_000 # 1e15 = 100M USDC (7 dp) MIN_PROB_BPS = 100 MAX_PROB_BPS = 9_900 +MAX_RAMP_SECS = 30 * 86_400 +MAX_SKEW_MULT_BPS = 1_000 class MathError(Exception): @@ -217,6 +220,217 @@ def sell_within_limit(collateral_out: int, shares_in: int, limit_bps: int) -> bo return collateral_out * BPS >= shares_in * limit_bps +# --------------------------------------------------------------------------- +# Categorical (N-outcome) FPMM — mirrors math_categorical.rs. +# The binary invariant k = r_yes * r_no generalises to the product of all +# reserves; the iterative ceil accumulation keeps k non-decreasing with at +# most n-1 units of pool-favouring drift per trade (see the rounding proof in +# math_categorical.rs::calc_buy). +# --------------------------------------------------------------------------- + +MAX_MARKET_OUTCOMES = 8 +PRICE_SCALE = 1_000_000_000_000_000_000 # 1e18, same reciprocal scale as math_categorical.rs + + +def _cat_n(reserves) -> int: + n = len(reserves) + if not 2 <= n <= MAX_MARKET_OUTCOMES: + raise MathError("invalid input") + if any(r <= 0 for r in reserves): + raise MathError("invalid input") + return n + + +def _cat_index(n: int, outcome_index: int) -> int: + if not 0 <= outcome_index < n: + raise MathError("invalid input") + return outcome_index + + +@dataclass(frozen=True) +class CatBuyQuote: + shares_out: int + fee: int + net: int + new_reserves: list + + +def cat_calc_buy(reserves, outcome_index: int, collateral_in: int, fee_bps: int) -> CatBuyQuote: + """Buy `collateral_in` of outcome `outcome_index`: mint `net` sets into + every other reserve and shrink the bought reserve so the product of all + reserves holds. Mirrors math_categorical.rs::calc_buy.""" + n = _cat_n(reserves) + j = _cat_index(n, outcome_index) + if collateral_in <= 0: + raise MathError("invalid input") + fee = mul_div_ceil(collateral_in, fee_bps, BPS) + net = collateral_in - fee + if net <= 0: + raise MathError("invalid input") + new_reserves = list(reserves) + acc = reserves[j] + for i in range(n): + if i == j: + continue + new_other = reserves[i] + net + new_reserves[i] = new_other + acc = mul_div_ceil(acc, reserves[i], new_other) + new_reserves[j] = acc + shares_out = reserves[j] + net - acc + if shares_out <= 0: + raise MathError("invalid input") + return CatBuyQuote(shares_out, fee, net, new_reserves) + + +@dataclass(frozen=True) +class CatSellQuote: + shares_in: int + fee: int + gross: int + new_reserves: list + + +def cat_calc_sell(reserves, outcome_index: int, collateral_out: int, fee_bps: int) -> CatSellQuote: + """Sell `outcome_index` for an exact `collateral_out` (Gnosis-style, so + no square root on-chain). Mirrors math_categorical.rs::calc_sell.""" + n = _cat_n(reserves) + j = _cat_index(n, outcome_index) + if collateral_out <= 0 or fee_bps >= BPS: + raise MathError("invalid input") + gross = mul_div_ceil(collateral_out, BPS, BPS - fee_bps) + fee = gross - collateral_out + new_reserves = list(reserves) + acc = reserves[j] + for i in range(n): + if i == j: + continue + if reserves[i] <= gross: + raise MathError("insufficient liquidity") + new_other = reserves[i] - gross + new_reserves[i] = new_other + acc = mul_div_ceil(acc, reserves[i], new_other) + new_reserves[j] = acc + shares_in = acc + gross - reserves[j] + if shares_in <= 0: + raise MathError("invalid input") + return CatSellQuote(shares_in, fee, gross, new_reserves) + + +def cat_prices(reserves) -> list: + """Spot price of every outcome in bps, `p_j = (1/r_j) / Σ(1/r_i)` + evaluated on integer reciprocals. Each price floors, so the n prices sum + to at most BPS and at least BPS - n (display-only; never trade math). + Mirrors math_categorical.rs::prices_bps.""" + n = _cat_n(reserves) + recs = [PRICE_SCALE // reserves[i] for i in range(n)] + total = sum(recs) + if total <= 0: + raise MathError("insufficient liquidity") + return [mul_div_floor(BPS, rec, total) for rec in recs] + + +@dataclass(frozen=True) +class CatSeed: + reserves: list + creator_shares: list + + +def cat_seed(seed: int, probs_bps) -> CatSeed: + """Open a pool at the requested probabilities: each reserve is + `seed * p_min / p_i` (floored), the creator keeps `seed - r_i` of every + outcome. `probs_bps` must have length 2..=MAX_MARKET_OUTCOMES, every + entry >= MIN_PROB_BPS, and the entries must sum to exactly BPS. + Mirrors math_categorical.rs::seed_reserves.""" + n = len(probs_bps) + if not 2 <= n <= MAX_MARKET_OUTCOMES or seed <= 0: + raise MathError("invalid input") + if any(p < MIN_PROB_BPS for p in probs_bps): + raise MathError("invalid input") + if sum(probs_bps) != BPS: + raise MathError("invalid input") + p_min = min(probs_bps) + reserves = [] + creator_shares = [] + for p in probs_bps: + r = mul_div_floor(seed, p_min, p) + if r <= 0: + raise MathError("invalid input") + reserves.append(r) + creator_shares.append(seed - r) + return CatSeed(reserves, creator_shares) + + +def cat_settle_value(resolved, balances) -> int: + """Collateral value of one holder's per-outcome balances once final. + `resolved=w` pays the winning balance; `resolved=None` (void) pays + `floor(Σ balances / n)`. Every complete set is worth exactly 1 and every + rounding floors, so the total paid across holders never exceeds + sets_minted. Mirrors math_categorical.rs::settle_value.""" + n = len(balances) + if not 2 <= n <= MAX_MARKET_OUTCOMES: + raise MathError("invalid input") + if resolved is None: + if any(b < 0 for b in balances): + raise MathError("invalid input") + return sum(balances) // n + _cat_index(n, resolved) + return balances[resolved] + + +# --------------------------------------------------------------------------- +# Scalar (range) market settlement — mirrors math.rs::scalar_payout, which +# backs the Rust `Settlement::Scalar(p)` variant. Kept as its own function +# here because the Python `settle_value(kind, yes, no)` has no slot for the +# resolved payout; `scalar_payout(BPS, ...)` == yes and `(0, ...)` == no. +# --------------------------------------------------------------------------- + + +def scalar_payout(payout_long_bps: int, yes: int, no: int) -> int: + """Collateral value of a (yes, no) share pair when the resolved value + maps to `payout_long_bps` LONG per complete set. Both legs floor + independently, so no holder ever beats their exact rational share.""" + return (yes * payout_long_bps) // BPS + (no * (BPS - payout_long_bps)) // BPS + + +# --------------------------------------------------------------------------- +# Dynamic fee schedule — mirrors math.rs::effective_fee_bps. Deterministic +# integer math shared with the contract and agents/core/fpmm.ts via +# model/fee_fixtures.json; every floor rounds toward the trader (a lower fee +# never harms the pool, whose protection is calc_* rounding). +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class FeeSchedule: + base_bps: int + max_bps: int + ramp_secs: int + skew_mult_bps: int + + +def effective_fee_bps(schedule: FeeSchedule, now: int, close_time: int, r_yes: int, r_no: int) -> int: + """Fee in bps for a trade at `now` with the given pre-trade reserves: + `min(max, base + time_ramp + skew)`. The ramp adds up to `max - base` + linearly over the last `ramp_secs` before close (0 disables it, and any + timestamp at or past close clamps to the full ramp); the skew term adds + `skew_mult_bps` per 1000 bps of distance from a 50/50 price. Guaranteed + `base <= result <= max`, and `max < BPS` is validated so `calc_sell` is + always well-defined.""" + if schedule.base_bps > schedule.max_bps or schedule.max_bps >= BPS: + raise MathError("invalid input") + if schedule.ramp_secs == 0: + t = 0 + else: + if now >= close_time: + into_ramp = schedule.ramp_secs + else: + into_ramp = max(0, schedule.ramp_secs - (close_time - now)) + t = mul_div_floor(into_ramp, schedule.max_bps - schedule.base_bps, schedule.ramp_secs) + p = price_yes_bps(r_yes, r_no) + skew = mul_div_floor(abs(p - BPS // 2), schedule.skew_mult_bps, 1_000) + return min(schedule.max_bps, schedule.base_bps + t + skew) + + # --------------------------------------------------------------------------- # Property tests. No pytest/hypothesis dependency: run directly. # --------------------------------------------------------------------------- @@ -341,6 +555,202 @@ def _check_limits_boundary_and_symmetry(): assert not buy_within_limit(1, 1, 0) +def _check_cat_known_values(): + """Pins the same worked examples as math_categorical.rs's unit test: a + 3-outcome 1000/1000/1000 pool, buying/selling 100 of outcome 0 with no + fee.""" + q = cat_calc_buy([1000, 1000, 1000], 0, 100, 0) + assert (q.shares_out, q.new_reserves) == (272, [828, 1100, 1100]), q + s = cat_calc_sell([1000, 1000, 1000], 0, 100, 0) + assert (s.shares_in, s.new_reserves) == (336, [1236, 900, 900]), s + + # A 2-outcome categorical pool is the binary FPMM: same fee and net, and + # a bought-reserve result within one unit of rounding. + for amt, fee in ((100, 0), (100_000_000, 100), (7, 30)): + bin_q = calc_buy(1_000_000, 3_000_000, amt, fee) + cat_q = cat_calc_buy([1_000_000, 3_000_000], 0, amt, fee) + assert cat_q.fee == bin_q.fee and cat_q.net == bin_q.net + assert cat_q.new_reserves[1] == bin_q.new_reserve_other + assert abs(cat_q.shares_out - bin_q.shares_out) <= 1 + + +def _check_cat_k_never_decreases_and_round_trip_never_profits(trials: int = 2_000, seed: int = 13): + rng = random.Random(seed) + for _ in range(trials): + n = rng.randint(2, MAX_MARKET_OUTCOMES) + reserves = [100 + rng.randint(0, 9_999) for _ in range(n)] + j = rng.randint(0, n - 1) + amt = 100 + rng.randint(0, 99) + fee = rng.randint(0, 299) + + k0 = 1 + for r in reserves: + k0 *= r + q = cat_calc_buy(reserves, j, amt, fee) + k1 = 1 + for r in q.new_reserves: + k1 *= r + assert k1 >= k0, (reserves, j, amt, fee) + for i, r in enumerate(reserves): + if i != j: + assert q.new_reserves[i] == r + q.net + + try: + s = cat_calc_sell(q.new_reserves, j, amt, fee) + except MathError: + continue + assert s.shares_in >= q.shares_out, "free money" + + +def _check_cat_prices_sum_within_n_of_bps(trials: int = 2_000, seed: int = 17): + rng = random.Random(seed) + for _ in range(trials): + n = rng.randint(2, MAX_MARKET_OUTCOMES) + reserves = [1_000 + rng.randint(0, 999_999_999_999) for _ in range(n)] + prices = cat_prices(reserves) + assert BPS - n <= sum(prices) <= BPS, (reserves, prices) + # Equal reserves -> equal prices, each exactly floor(BPS / n). + for n in range(2, MAX_MARKET_OUTCOMES + 1): + prices = cat_prices([7_777] * n) + assert prices == [BPS // n] * n + + +def _check_cat_seed_matches_requested_probs(): + for probs in ([5000, 5000], [6800, 3200], [5000, 3000, 2000], [9000, 500, 400, 100]): + s = cat_seed(1_000_000_000, probs) + prices = cat_prices(s.reserves) + for i, p in enumerate(probs): + assert abs(prices[i] - p) <= 2, (probs, prices) + assert s.reserves[i] + s.creator_shares[i] == 1_000_000_000 + # The least likely outcome is fully held by the pool. + assert s.creator_shares[probs.index(min(probs))] == 0 + # 8 equal outcomes: prices sum to exactly BPS, creator holds nothing. + s = cat_seed(1_000_000_000, [1250] * 8) + assert cat_prices(s.reserves) == [1250] * 8 + assert s.reserves == [1_000_000_000] * 8 + assert s.creator_shares == [0] * 8 + # Validation: sum != BPS, below MIN_PROB_BPS, n out of range, seed 0. + for probs, seed in (([6000, 3000], 1_000), ([9950, 50], 1_000), ([10_000], 1_000), ([1250] * 9, 1_000), ([5000, 5000], 0)): + try: + cat_seed(seed, probs) + except MathError: + continue + raise AssertionError(f"cat_seed accepted {probs} seed={seed}") + + +def _check_cat_settle_never_overpays(trials: int = 1_000, seed: int = 23): + rng = random.Random(seed) + for _ in range(trials): + n = rng.randint(2, MAX_MARKET_OUTCOMES) + sets = 1_000_000 + # Three holders share each outcome's `sets` shares with the pool. + held = [[0] * 3 for _ in range(n)] + for i in range(n): + rest = sets + for h in range(3): + take = rest if h == 2 else rng.randint(0, rest) + held[i][h] = take + rest -= take + w = rng.randint(0, n - 1) + resolved_total = 0 + void_total = 0 + for h in range(3): + balances = [held[i][h] for i in range(n)] + resolved_total += cat_settle_value(w, balances) + void_total += cat_settle_value(None, balances) + assert resolved_total <= sets + assert void_total <= sets, (held, void_total) + # Worked shape checks, mirroring the Rust unit test. + assert cat_settle_value(1, [3, 7, 11]) == 7 + assert cat_settle_value(None, [3, 7, 11]) == 7 # 21 / 3 + assert cat_settle_value(None, [1] * 7) == 1 + + +def _check_scalar_payout_never_overpays(trials: int = 1_000, seed: int = 29): + rng = random.Random(seed) + # Extremes reduce to the binary legs. + assert scalar_payout(BPS, 5, 7) == settle_value("yes", 5, 7) + assert scalar_payout(0, 5, 7) == settle_value("no", 5, 7) + # A complete set is worth exactly 1 mid-range; rounding always floors. + assert scalar_payout(5_000, 1_000, 1_000) == 1_000 + assert scalar_payout(3_333, 1_000, 1_000) == 999 + for _ in range(trials): + sets = rng.randint(1, 1_000_000) + payout = rng.randint(0, BPS) + # Three holders split `sets` YES and `sets` NO shares among them + # (the last holder takes the remainder, so the split is exact). + total = 0 + yes_left = sets + no_left = sets + for h in range(3): + take_y = yes_left if h == 2 else rng.randint(0, yes_left) + take_n = no_left if h == 2 else rng.randint(0, no_left) + yes_left -= take_y + no_left -= take_n + total += scalar_payout(payout, take_y, take_n) + # Both legs floor, so Σ paid <= Σ(yes*p + no*(BPS-p))/BPS == sets. + assert total <= sets, (sets, payout, total) + + +def _check_effective_fee_bounds(trials: int = 2_000, seed: int = 31): + rng = random.Random(seed) + for _ in range(trials): + base = rng.randint(0, 999) + max_bps = rng.randint(base, 1_000) + ramp = rng.randint(0, MAX_RAMP_SECS) + skew_mult = rng.randint(0, MAX_SKEW_MULT_BPS) + close = rng.randint(1_000, 2_000_000_000) + now = rng.randint(0, close + 10_000) + r_yes = rng.randint(1, 10**15) + r_no = rng.randint(1, 10**15) + fee = effective_fee_bps(FeeSchedule(base, max_bps, ramp, skew_mult), now, close, r_yes, r_no) + assert base <= fee <= max_bps <= 1_000, (base, max_bps, ramp, skew_mult, now, close, fee) + # Ramp monotonic in time: the fee never decreases as close approaches. + sched = FeeSchedule(100, 800, 10_000, 0) + prev = -1 + for t in range(2_000_000 - 20_000, 2_000_000 + 5_000 + 1, 137): + fee = effective_fee_bps(sched, t, 2_000_000, 1_000, 1_000) + assert fee >= prev + prev = fee + assert prev == 800 # any timestamp at/past close applies the full ramp + # Zero ramp means "no ramp": the fee is flat all the way to close. + flat = FeeSchedule(100, 800, 0, 0) + assert effective_fee_bps(flat, 0, 2_000_000, 1_000, 1_000) == 100 + assert effective_fee_bps(flat, 2_000_000, 2_000_000, 1_000, 1_000) == 100 + # Skew symmetric around 50/50 for representable ratios. + for q in ((1000, 3000), (900, 1100), (5000, 5000), (1, 4), (7, 3), (1, 1)): + sched_s = FeeSchedule(100, 1_000, 0, 500) + left = effective_fee_bps(sched_s, 0, 1, q[0], q[1]) + right = effective_fee_bps(sched_s, 0, 1, q[1], q[0]) + assert left == right, (q, left, right) + # The pure function only requires base <= max < BPS; the tighter + # MAX_FEE_BPS cap is the contract layer's job (validate_schedule), so a + # (1_000, 1_000) schedule is valid here. + assert effective_fee_bps(FeeSchedule(1_000, 1_000, 0, 0), 0, 1, 1_000, 1_000) == 1_000 + for bad in (FeeSchedule(500, 100, 0, 0), FeeSchedule(0, 10_000, 0, 0)): + try: + effective_fee_bps(bad, 0, 1, 1_000, 1_000) + except MathError: + continue + raise AssertionError(f"effective_fee_bps accepted {bad}") + + +def _check_effective_fee_known_values(): + """Shared cross-language table (also read by math.rs's + effective_fee_matches_fixtures and agents/core/fpmm.test.ts): one row per + fixture, nine numbers — [base_bps, max_bps, ramp_secs, skew_mult_bps, + now, close, r_yes, r_no, expected_fee_bps].""" + fixtures = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fee_fixtures.json") + with open(fixtures, encoding="utf-8") as f: + rows = json.load(f) + assert rows, "fee_fixtures.json has no rows" + for row in rows: + assert len(row) == 9, row + sched = FeeSchedule(row[0], row[1], row[2], row[3]) + fee = effective_fee_bps(sched, row[4], row[5], row[6], row[7]) + assert fee == row[8], (row, fee) + + def _check_shared_vectors(): """Replays the committed cross-language vectors. @@ -388,6 +798,14 @@ def run_all(): _check_void_never_overpays() _check_price_after_maps_both_sides() _check_limits_boundary_and_symmetry() + _check_cat_known_values() + _check_cat_k_never_decreases_and_round_trip_never_profits() + _check_cat_prices_sum_within_n_of_bps() + _check_cat_seed_matches_requested_probs() + _check_cat_settle_never_overpays() + _check_scalar_payout_never_overpays() + _check_effective_fee_bounds() + _check_effective_fee_known_values() print("fpmm_model: all property checks passed") diff --git a/oracledesk-stellar/rust-toolchain.toml b/oracledesk-stellar/rust-toolchain.toml index 54b2a6b..405d497 100644 --- a/oracledesk-stellar/rust-toolchain.toml +++ b/oracledesk-stellar/rust-toolchain.toml @@ -2,7 +2,13 @@ # meaningful if two clean builds produce identical bytes, which requires a # fixed compiler. Bump this deliberately, in the same commit that refreshes # the vendored wasm and its .sha256 pins. +# +# 1.93.0 is the lowest compiler that satisfies both floors of this checkout: +# `soroban-sdk 27.0.6` (Cargo.lock) requires rustc >= 1.91.0, and CI installs +# `stellar-cli 27.1.0` (`stellar-cli.version`), which requires >= 1.93.0. The +# previous pin (1.90.0) could not build this lockfile or that CLI, so every +# Rust job failed before reaching a repo command. [toolchain] -channel = "1.90.0" +channel = "1.93.0" targets = ["wasm32v1-none"] components = ["rustfmt", "clippy"] diff --git a/oracledesk-stellar/scripts/spec-hash/src/lib.rs b/oracledesk-stellar/scripts/spec-hash/src/lib.rs index 2233ee6..ca85862 100644 --- a/oracledesk-stellar/scripts/spec-hash/src/lib.rs +++ b/oracledesk-stellar/scripts/spec-hash/src/lib.rs @@ -6,9 +6,9 @@ //! `spec_v2_hash`, so the tool cannot drift from the contract. use resolver::{ - legacy_spec_hash, spec_v2_hash, CommitteeRef, DisputableSignerConfig, Escalation, HybridConfig, - OracleAsset, PriceConfig, PriceDirection, ResolutionSpec, ResolutionSpecV2, SignerConfig, - WeightedKey, WeightedKeyConfig, + legacy_spec_hash, spec_v2_hash, CatSignerConfig, CommitteeRef, DisputableSignerConfig, Escalation, + HybridConfig, OracleAsset, PriceConfig, PriceDirection, ResolutionSpec, ResolutionSpecV2, + ScalarPriceConfig, SignerConfig, WeightedKey, WeightedKeyConfig, }; use soroban_sdk::{Address, Bytes, BytesN, Env, Symbol, Vec as SorobanVec}; @@ -22,9 +22,14 @@ usage: spec-hash [V2 FLAGS] signers-disputable ... is `guardian` or `committee::,...` + spec-hash [V2 FLAGS] cat-signers ... + is 2..=8, the number of outcomes of the categorical market + spec-hash [V2 FLAGS] scalar + is `stellar:` or `other:` + LONG pays `(price - lower) / (upper - lower)` in bps, clamped into the range spec-hash [V2 FLAGS] committee spec-hash [V2 FLAGS] hybrid -- ... - spec-hash [V2 FLAGS] verify ... + spec-hash [V2 FLAGS] verify ... spec-hash gen-salt V2 FLAGS (all four are required together; without them the legacy hash is printed): @@ -112,19 +117,23 @@ pub fn parse_signers(env: &Env, args: &[String]) -> Result Result { + if let Some(address) = value.strip_prefix("stellar:") { + Ok(OracleAsset::Stellar(parse_address(env, address)?)) + } else if let Some(symbol) = value.strip_prefix("other:") { + Ok(OracleAsset::Other(Symbol::new(env, symbol))) + } else { + Err("asset must be `stellar:
` or `other:`".into()) + } +} + pub fn parse_price(env: &Env, args: &[String]) -> Result { let [reflector, asset, threshold, direction, max_staleness] = args else { return Err( "price needs ".into(), ); }; - let asset = if let Some(address) = asset.strip_prefix("stellar:") { - OracleAsset::Stellar(parse_address(env, address)?) - } else if let Some(symbol) = asset.strip_prefix("other:") { - OracleAsset::Other(Symbol::new(env, symbol)) - } else { - return Err("asset must be `stellar:
` or `other:`".into()); - }; + let asset = parse_asset(env, asset)?; let direction = match direction.as_str() { "above" => PriceDirection::Above, "at-or-above" => PriceDirection::AtOrAbove, @@ -149,6 +158,25 @@ pub fn parse_price(env: &Env, args: &[String]) -> Result })) } +pub fn parse_scalar(env: &Env, args: &[String]) -> Result { + let [reflector, asset, lower, upper, max_staleness] = args else { + return Err("scalar needs ".into()); + }; + Ok(ResolutionSpec::Scalar(ScalarPriceConfig { + reflector: parse_address(env, reflector)?, + asset: parse_asset(env, asset)?, + lower: lower + .parse() + .map_err(|_| "lower must be an i128".to_string())?, + upper: upper + .parse() + .map_err(|_| "upper must be an i128".to_string())?, + max_staleness: max_staleness + .parse() + .map_err(|_| "max_staleness must be a u64".to_string())?, + })) +} + pub fn parse_weighted_keys(env: &Env, args: &[String]) -> Result { let [weight_threshold, dispute_window, keys @ ..] = args else { return Err( @@ -232,6 +260,33 @@ pub fn parse_signers_disputable(env: &Env, args: &[String]) -> Result Result { + let [outcome_count, threshold, dispute_window, signers @ ..] = args else { + return Err( + "cat-signers needs ...".into(), + ); + }; + if signers.is_empty() { + return Err("cat-signers needs at least one signer address".into()); + } + let mut list = SorobanVec::new(env); + for signer in signers { + list.push_back(parse_address(env, signer)?); + } + Ok(ResolutionSpec::CategoricalSigners(CatSignerConfig { + signers: list, + threshold: threshold + .parse() + .map_err(|_| "threshold must be a u32".to_string())?, + dispute_window: dispute_window + .parse() + .map_err(|_| "dispute_window must be a u64".to_string())?, + outcome_count: outcome_count + .parse() + .map_err(|_| "outcome_count must be a u32".to_string())?, + })) +} + pub fn parse_committee(args: &[String]) -> Result { let [committee_id, min_epoch, max_rotation_delta, dispute_window] = args else { return Err("committee needs ".into()); @@ -283,9 +338,13 @@ fn parse_rule(env: &Env, args: &[String]) -> Result { [kind, rest @ ..] if kind == "price" => parse_price(env, rest), [kind, rest @ ..] if kind == "weighted-keys" => parse_weighted_keys(env, rest), [kind, rest @ ..] if kind == "signers-disputable" => parse_signers_disputable(env, rest), + [kind, rest @ ..] if kind == "cat-signers" => parse_cat_signers(env, rest), + [kind, rest @ ..] if kind == "scalar" => parse_scalar(env, rest), [kind, rest @ ..] if kind == "committee" => parse_committee(rest), [kind, rest @ ..] if kind == "hybrid" => parse_hybrid(env, rest), - _ => Err("expected `signers`, `price`, `weighted-keys`, `signers-disputable`, `committee` or `hybrid`".into()), + _ => Err( + "expected `signers`, `price`, `weighted-keys`, `signers-disputable`, `cat-signers`, `scalar`, `committee` or `hybrid`".into(), + ), } } @@ -458,6 +517,26 @@ mod tests { ); } + /// The same vectors as `golden_legacy_cat_signer_spec_hashes` in + /// contracts/resolver: CLI parsing and the contract types must produce + /// byte-identical commitments. + #[test] + fn golden_cat_signers_vectors() { + let vectors: [(&[&str], &str); 2] = [ + ( + &["cat-signers", "2", "1", "5", G1], + "16db57d51742d266dccd1a70539afc160971c942605a0ae8f15b5a18cd1b2837", + ), + ( + &["cat-signers", "3", "2", "3600", G1, G2, G3], + "21b750237b5cb3f54bdc71ec55a1d8073a3b860f6cb3cb3b9717570dc42092e7", + ), + ]; + for (spec, expected) in vectors { + assert_eq!(hash_of(spec), expected); + } + } + /// Re-derived from hand-built XDR (no Soroban) when these vectors were added. #[test] fn golden_signers_disputable_vectors() { @@ -493,6 +572,38 @@ mod tests { } } + /// The same vectors as `golden_legacy_scalar_spec_hashes` in + /// contracts/resolver: CLI parsing and the contract types must produce + /// byte-identical commitments, and the scalar space must not collide with + /// the price space it mirrors. + #[test] + fn golden_scalar_vectors() { + let vectors: [(&[&str], &str); 2] = [ + ( + &["scalar", C1, "other:XLM", "100", "250", "10"], + "e97c4bccc39a4538850b22e703019c3b69971a00b99bc73117cc67e044e26822", + ), + ( + &[ + "scalar", + C1, + &format!("stellar:{C2}"), + "0", + "1000000000000", + "300", + ], + "1d94380e9112c6f80f7542106dcc75ffc1426a634da2a07440fac72d2033fa08", + ), + ]; + for (spec, expected) in vectors { + assert_eq!(hash_of(spec), expected); + } + assert_ne!( + hash_of(&["scalar", C1, "other:XLM", "100", "250", "10"]), + hash_of(&["price", C1, "other:XLM", "2000", "above", "10"]), + ); + } + const SALT: &str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; const V2_GOLDEN: &str = "27e995544f9f631be41177890b7a1be7e17c667e4e531aa08e5089dd899566ae"; @@ -580,6 +691,13 @@ mod tests { args(&["signers", "x", "5", G1]), args(&["signers", "1", "5", "not-an-address"]), args(&["price", C1, "bogus", "1", "above", "1"]), + args(&["cat-signers", "3", "2", "100"]), + args(&["cat-signers", "x", "2", "100", G1]), + args(&["cat-signers", "3", "2", "100", "not-an-address"]), + args(&["scalar", C1, "other:XLM", "100", "250"]), + args(&["scalar", C1, "bogus", "100", "250", "10"]), + args(&["scalar", C1, "other:XLM", "x", "250", "10"]), + args(&["scalar", C1, "other:XLM", "100", "250", "x"]), args(&["weighted-keys", "2", "60", "nocolon"]), args(&["weighted-keys", "2", "60", "zz:1"]), args(&[ diff --git a/oracledesk-stellar/scripts/verify-wasm-hash.rs b/oracledesk-stellar/scripts/verify-wasm-hash.rs index 128dd83..cc62c1c 100644 --- a/oracledesk-stellar/scripts/verify-wasm-hash.rs +++ b/oracledesk-stellar/scripts/verify-wasm-hash.rs @@ -1,17 +1,19 @@ -//! Shared `build.rs` helper that pins a vendored wasm artifact to a committed -//! sha256 (issue #58). -//! -//! `contractimport!` reads `market_core.wasm` / `reasoning_registry.wasm` at -//! the *dependent* crate's compile time, so a stale copy silently compiles -//! against an old interface. A plain `git diff --exit-code` in CI only catches -//! that after a full rebuild, and only if the build is bit-for-bit -//! reproducible. This makes the stale copy a **compile error**: the expected -//! digest lives in `.wasm.sha256`, and the build fails unless the bytes -//! on disk hash to it. Refreshing a vendored wasm (a deliberate, reviewable -//! act) requires updating both files together. -//! -//! Self-contained (no build-dependency) so pinning does not change -//! `Cargo.lock`; `include!`d by each contract's `build.rs`. +// Shared `build.rs` helper that pins a vendored wasm artifact to a committed +// sha256 (issue #58). +// +// `contractimport!` reads `market_core.wasm` / `reasoning_registry.wasm` at +// the *dependent* crate's compile time, so a stale copy silently compiles +// against an old interface. A plain `git diff --exit-code` in CI only catches +// that after a full rebuild, and only if the build is bit-for-bit +// reproducible. This makes the stale copy a **compile error**: the expected +// digest lives in `.wasm.sha256`, and the build fails unless the bytes +// on disk hash to it. Refreshing a vendored wasm (a deliberate, reviewable +// act) requires updating both files together. +// +// Self-contained (no build-dependency) so pinning does not change +// `Cargo.lock`; `include!`d by each contract's `build.rs`. Uses `//` rather +// than `//!` because rustc (>= 1.93) rejects an inner doc comment inside an +// `include!`ed fragment that is not the crate root. use std::fs; use std::path::Path;