diff --git a/oracledesk-stellar/.env.example b/oracledesk-stellar/.env.example index 9b10991..24b2e3d 100644 --- a/oracledesk-stellar/.env.example +++ b/oracledesk-stellar/.env.example @@ -5,6 +5,12 @@ STELLAR_NETWORK=testnet STELLAR_RPC_URL=https://soroban-testnet.stellar.org +# Portion of each trading fee kept by the protocol treasury (bps), set on +# market-core at deploy time. The remainder accrues to the market creator +# and is claimable before resolution (see docs/architecture.md). 10000 +# keeps everything protocol-side, matching pre-#8 deployments. +PROTOCOL_FEE_SHARE_BPS=10000 + # Identities used by scripts/deploy-testnet.sh, scripts/seed-market.sh, and # the agents (see agents/stellar/). Generate with: # stellar keys generate oracledesk-deployer --network testnet --fund @@ -40,3 +46,9 @@ AGENT_MODE=dry-run # Optional: an off-chain reference price source for the trader's fair-value # estimate. Read-only; never used for execution (see docs/architecture.md). POLYMARKET_READ_ONLY_API_URL= +# Execution bounds for real trades (agents/bin/trader.ts). The trader quotes +# first, then sends min_shares_out / limit_price_bps derived from the quote: +# SLIPPAGE_BPS is how far below the quoted average price the fill may land, +# DEADLINE_SECS is how long the signed trade stays valid after submission. +SLIPPAGE_BPS=50 +DEADLINE_SECS=60 diff --git a/oracledesk-stellar/README.md b/oracledesk-stellar/README.md index 85898ef..7cfdc6e 100644 --- a/oracledesk-stellar/README.md +++ b/oracledesk-stellar/README.md @@ -87,6 +87,29 @@ transactions using two throwaway identities (`oracledesk-deployer`, `oracledesk-agent`) that `scripts/deploy-testnet.sh` generates and funds via `stellar keys generate --fund`. Nothing here ever touches mainnet. +## Treasury migration + +The treasury has no upgrade path or config setters — fixing a bug, changing +caps, or rotating the agent means deploying a **new** treasury. Deploying +one strands nothing: the old treasury's positions and LP ownership can be +handed to a successor market by market: + +```bash +make build # treasury wasm must be current +scripts/migrate-treasury.sh # deploys a successor + migrates every market +``` + +The script reads `deployments/testnet.json` and the old treasury's live +config, deploys a new treasury with the same agent/collateral/caps, records +it as the old treasury's successor (`set_successor`, admin), then calls +`migrate_market(i)` for every `i` in `0..market_count`. Both steps are +admin-gated — the agent cannot invoke them — and the destination is fixed +to the recorded successor, so migration cannot send value anywhere else. +Re-running is safe. It deliberately does **not** rewire market-core +(`set_treasury`); that cutover changes the fee recipient for future markets +and stays a separate operator step (the script prints the exact command). +See `docs/architecture.md` for the full auth model. + ## Repository layout ``` @@ -96,8 +119,9 @@ x402/ the paid trace API (Express + @x402/*) agents/ core/ (chain-agnostic strategy + trace building), stellar/ (signing/submission adapter), bin/ (CLIs) packages/bindings/ generated TypeScript contract clients -scripts/ deploy-testnet.sh, seed-market.sh, demo.sh, - gen-bindings.sh, spec-hash/ (a small dev-tool crate) +scripts/ deploy-testnet.sh, migrate-treasury.sh, + seed-market.sh, demo.sh, gen-bindings.sh, + spec-hash/ (a small dev-tool crate) deployments/ testnet.json — the current live testnet addresses docs/ architecture, ADRs, frontend-integration, STATUS ``` diff --git a/oracledesk-stellar/agents/bin/trader.test.ts b/oracledesk-stellar/agents/bin/trader.test.ts new file mode 100644 index 0000000..3576a15 --- /dev/null +++ b/oracledesk-stellar/agents/bin/trader.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { StellarAdapter } from "../stellar/adapter.js"; +import { main } from "./trader.js"; + +/** Placeholder market/fair-value pair the trader reads (yesBps 4000 vs + * fair 6000 => a "buy Yes" decision at the default 500bps edge). */ +const FAIR_VALUE_BPS = 6000; +const QUOTE_SHARES = 123_456n; + +function fakeAdapter(quoteShares: bigint, calls: Record[]): StellarAdapter { + return { + mode: "dry-run" as const, + quoteBuy: async () => quoteShares, + agentBuyV2: async (args: Record) => { + calls.push(args); + return { dryRun: true as const, contractId: "CTREASURY", method: "agent_buy_v2", args }; + }, + } as unknown as StellarAdapter; +} + +test("trader computes non-zero min_shares_out and limit_price_bps from a fresh quote", async () => { + const calls: Record[] = []; + const env = { TRACE_STORE_DIR: await mkdtemp(join(tmpdir(), "trader-test-")) }; + await main(fakeAdapter(QUOTE_SHARES, calls), env, () => 1_000_000); + + assert.equal(calls.length, 1); + const args = calls[0]; + // Default SLIPPAGE_BPS = 50: quote * (10000 - 50) / 10000, floored. + const minSharesOut = (QUOTE_SHARES * 9_950n) / 10_000n; + assert.equal(args.min_shares_out, minSharesOut); + assert.ok((args.min_shares_out as bigint) > 0n); + // Never pay more than fair for the outcome being bought: this is a YES + // buy, so the limit is the fair YES value itself. + assert.equal(args.limit_price_bps, FAIR_VALUE_BPS); + // Default DEADLINE_SECS = 60, measured from the injected clock. + assert.equal(args.deadline, 1_000_060n); +}); + +test("trader honours SLIPPAGE_BPS and DEADLINE_SECS overrides", async () => { + const calls: Record[] = []; + const env = { + TRACE_STORE_DIR: await mkdtemp(join(tmpdir(), "trader-test-")), + SLIPPAGE_BPS: "100", + DEADLINE_SECS: "30", + }; + await main(fakeAdapter(QUOTE_SHARES, calls), env, () => 2_000_000); + + const args = calls[0]; + assert.equal(args.min_shares_out, (QUOTE_SHARES * 9_900n) / 10_000n); + assert.equal(args.deadline, 2_000_030n); +}); diff --git a/oracledesk-stellar/agents/bin/trader.ts b/oracledesk-stellar/agents/bin/trader.ts index dca8740..6e40bab 100644 --- a/oracledesk-stellar/agents/bin/trader.ts +++ b/oracledesk-stellar/agents/bin/trader.ts @@ -4,11 +4,19 @@ * fair-value estimate, and trades the mispricing if the edge clears the * configured threshold. Every trade is preceded by a recorded trace. * + * Bounds are never zero: the trade size comes from the strategy, then a + * fresh `quote_buy` immediately before submission turns into + * `min_shares_out` (quote minus SLIPPAGE_BPS) and a `limit_price_bps` + * backstop, plus a `deadline` — so a stale or sandwiched transaction + * reverts on-chain instead of executing at an arbitrary price. + * * Defaults to dry-run. Set AGENT_MODE=live and AGENT_SECRET_KEY to submit * real testnet transactions — see .env.example. */ import "dotenv/config"; +import { pathToFileURL } from "node:url"; import { buildTrace, LocalTraceStore } from "../core/trace.js"; +import { BPS } from "../core/fpmm.js"; import { decideTrade, type MarketState } from "../core/strategy.js"; import { loadDeployments, StellarAdapter } from "../stellar/adapter.js"; @@ -22,23 +30,22 @@ async function fetchMarketAndFairValue(): Promise<{ market: MarketState; fairVal }; } -async function main() { - const mode = (process.env.AGENT_MODE as "dry-run" | "live") ?? "dry-run"; - const deployments = loadDeployments(); - 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", - agentSecret: process.env.AGENT_SECRET_KEY, - deployments, - }); - const traceStore = new LocalTraceStore(process.env.TRACE_STORE_DIR ?? "./.traces"); +/** + * Exported (and injectable) so tests can drive the whole trade flow with a + * 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), +): Promise { + const traceStore = new LocalTraceStore(env.TRACE_STORE_DIR ?? "./.traces"); - console.log(`[trader] mode=${mode}`); + console.log(`[trader] mode=${adapter.mode}`); const { market, fairValueBps } = await fetchMarketAndFairValue(); const decision = decideTrade(market, fairValueBps, { - minEdgeBps: Number(process.env.MIN_EDGE_BPS ?? 500), - tradeSizeStroops: BigInt(process.env.TRADE_SIZE_STROOPS ?? "100000000"), + 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; @@ -53,17 +60,45 @@ async function main() { const { cid } = await traceStore.put(built.hash, built.json); console.log(`[trader] trace ${built.hash} -> ${cid}`); - const result = await adapter.agentBuy({ + 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: { tag: decision.outcome!, values: undefined }, - collateral_in: decision.collateralInStroops!, - min_shares_out: 0n, + outcome, + collateral_in: collateralInStroops, }); - console.log("[trader] agent_buy:", result); + 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); if (!result.dryRun) { const traceResult = await adapter.publishTrace({ - agent: deployments.agent, + agent: loadDeployments().agent, market_id: BigInt(market.marketId), action: "buy", trace_hash: Buffer.from(built.hash, "hex"), @@ -73,7 +108,20 @@ async function main() { } } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +const isMain = + process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + const mode = (process.env.AGENT_MODE as "dry-run" | "live") ?? "dry-run"; + const deployments = loadDeployments(); + 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", + agentSecret: process.env.AGENT_SECRET_KEY, + deployments, + }); + main(adapter).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/oracledesk-stellar/agents/core/fpmm.test.ts b/oracledesk-stellar/agents/core/fpmm.test.ts index fd97898..e4422d9 100644 --- a/oracledesk-stellar/agents/core/fpmm.test.ts +++ b/oracledesk-stellar/agents/core/fpmm.test.ts @@ -2,11 +2,16 @@ import assert from "node:assert/strict"; import test from "node:test"; import { BPS, + buyWithinLimit, calcBuy, calcSell, collateralOutForShares, + priceAfter, + priceAfterBuy, + priceAfterSell, priceYesBps, seedReserves, + sellWithinLimit, settleValue, } from "./fpmm.js"; @@ -108,3 +113,38 @@ test("collateralOutForShares round-trips through calcSell within one unit of rou test("BPS matches the contract's basis-point scale", () => { assert.equal(BPS, 10_000n); }); + +test("priceAfter maps both outcomes to the same YES price", () => { + assert.equal(priceAfter(true, 1000n, 1000n), 5000n); + assert.equal(priceAfter(false, 1000n, 1000n), 5000n); + assert.equal(priceAfter(true, 910n, 1100n), priceYesBps(910n, 1100n)); + assert.equal(priceAfter(false, 910n, 1100n), priceYesBps(1100n, 910n)); +}); + +test("priceAfterBuy matches the reference example shared with math.rs and the Python model", () => { + // Buying 10 USDC of YES in a 100-sets pool at 50% with a 1% fee moves + // YES from 5000 to 5470 bps. + const q = calcBuy(1_000_000_000n, 1_000_000_000n, 100_000_000n, 100n); + assert.equal(priceYesBps(1_000_000_000n, 1_000_000_000n), 5000n); + assert.equal(priceAfterBuy(true, q), 5470n); + // Selling right back must land below the pre-trade price (two fees). + const s = calcSell(q.newReserveBought, q.newReserveOther, 100_000_000n, 100n); + assert.ok(priceAfterSell(true, s) < 5000n); +}); + +test("buyWithinLimit and sellWithinLimit mirror the on-chain boundaries", () => { + // Exact equality passes, one unit over fails, in both directions. + assert.equal(buyWithinLimit(100n, 100n, 10_000n), true); + assert.equal(buyWithinLimit(100n, 101n, 10_000n), true); + assert.equal(buyWithinLimit(101n, 100n, 10_000n), false); + assert.equal(buyWithinLimit(100n, 200n, 5_000n), true); + assert.equal(buyWithinLimit(101n, 200n, 5_000n), false); + assert.equal(sellWithinLimit(100n, 100n, 10_000n), true); + assert.equal(sellWithinLimit(99n, 100n, 10_000n), false); + assert.equal(sellWithinLimit(100n, 200n, 5_000n), true); + assert.equal(sellWithinLimit(100n, 201n, 5_000n), false); + // A zero limit is trivially satisfied for sells and binds a buy only at + // a zero average price. + assert.equal(sellWithinLimit(0n, 1n, 0n), true); + assert.equal(buyWithinLimit(1n, 1n, 0n), false); +}); diff --git a/oracledesk-stellar/agents/core/fpmm.ts b/oracledesk-stellar/agents/core/fpmm.ts index 4bf976b..ed027f7 100644 --- a/oracledesk-stellar/agents/core/fpmm.ts +++ b/oracledesk-stellar/agents/core/fpmm.ts @@ -180,3 +180,53 @@ export function collateralOutForShares( const gross = grossForShares(rSold, rOther, sharesIn, feeBps); return mulDivFloor(gross, BPS - feeBps, BPS); } + +/** + * YES price in bps of the pool **after** applying an outcome-relative + * reserve update (the `newReserve*` pair of a BuyQuote/SellQuote). Mirrors + * math.rs::price_after: pass `outcomeIsYes = false` for a NO trade, where + * `rAfter` is the NO reserve. + */ +export function priceAfter(outcomeIsYes: boolean, rAfter: bigint, rOtherAfter: bigint): bigint { + return outcomeIsYes ? priceYesBps(rAfter, rOtherAfter) : priceYesBps(rOtherAfter, rAfter); +} + +/** + * Post-trade YES price of a `calcBuy` quote for the outcome the buyer + * receives (`takenIsYes = false` for a NO buy). This is what the per-market + * impact breaker measures against the pre-trade price. + */ +export function priceAfterBuy(takenIsYes: boolean, quote: BuyQuote): bigint { + return priceAfter(takenIsYes, quote.newReserveBought, quote.newReserveOther); +} + +/** + * Post-trade YES price of a `calcSell` quote for the outcome the seller + * gave up (`takenIsYes = false` for a NO sell). + */ +export function priceAfterSell(takenIsYes: boolean, quote: SellQuote): bigint { + return priceAfter(takenIsYes, quote.newReserveSold, quote.newReserveOther); +} + +/** + * True when a buy's average execution price `collateralIn / sharesOut` + * (fees included) is at most `limitBps` per share. Compared by + * cross-multiplication so rounding cannot flip the decision; exact + * equality passes. Mirrors math.rs::buyWithinLimit / the on-chain + * `buy_v2` limit check. + */ +export function buyWithinLimit(collateralIn: bigint, sharesOut: bigint, limitBps: bigint): boolean { + if (collateralIn < 0n || sharesOut <= 0n) throw new MathError("invalid input"); + return collateralIn * BPS <= sharesOut * limitBps; +} + +/** + * True when a sell's average execution price `collateralOut / sharesIn` + * is at least `limitBps` per share. Exact equality passes; a zero limit is + * trivially satisfied, which buys the caller no protection. Mirrors + * math.rs::sellWithinLimit / the on-chain `sell_v2` limit check. + */ +export function sellWithinLimit(collateralOut: bigint, sharesIn: bigint, limitBps: bigint): boolean { + if (collateralOut < 0n || sharesIn <= 0n) throw new MathError("invalid input"); + return collateralOut * BPS >= sharesIn * limitBps; +} diff --git a/oracledesk-stellar/agents/core/strategy.test.ts b/oracledesk-stellar/agents/core/strategy.test.ts index bdc7442..59db18f 100644 --- a/oracledesk-stellar/agents/core/strategy.test.ts +++ b/oracledesk-stellar/agents/core/strategy.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { calcBuy, priceAfterBuy } from "./fpmm.js"; import { decideMarketProposal, decideTrade } from "./strategy.js"; test("decideMarketProposal skips a signal with no actionable estimate", () => { @@ -68,3 +69,71 @@ test("decideTrade buys No when the market overprices Yes", () => { assert.equal(d.action, "buy"); assert.equal(d.outcome, "No"); }); + +// The 100-sets pool at 50% with a 1% fee used by the calcBuy reference +// example: 10 USDC moves the price 470bps, so a 200bps cap must clamp well +// below the requested size. +const deepMarket = { + marketId: 1, + question: "q", + yesBps: 5000, + closeTime: 0, + reserveYesStroops: 1_000_000_000n, + reserveNoStroops: 1_000_000_000n, + feeBps: 100, +}; + +test("decideTrade clamps the size under maxImpactBps", () => { + const opts = { minEdgeBps: 100, tradeSizeStroops: 100_000_000n, maxImpactBps: 200 }; + const d = decideTrade(deepMarket, 6000, opts); + assert.equal(d.action, "buy"); + const size = d.collateralInStroops!; + assert.ok(size > 0n && size < opts.tradeSizeStroops, `size=${size}`); + // The clamped size respects the cap... + const q = calcBuy(deepMarket.reserveYesStroops, deepMarket.reserveNoStroops, size, 100n); + assert.ok(priceAfterBuy(true, q) - 5000n <= 200n); + // ...and the clamp was actually needed. + const full = calcBuy(deepMarket.reserveYesStroops, deepMarket.reserveNoStroops, opts.tradeSizeStroops, 100n); + assert.ok(priceAfterBuy(true, full) - 5000n > 200n); +}); + +test("decideTrade leaves the size alone when maxImpactBps is 0 (disabled)", () => { + const d = decideTrade(deepMarket, 6000, { + minEdgeBps: 100, + tradeSizeStroops: 100_000_000n, + maxImpactBps: 0, + }); + assert.equal(d.action, "buy"); + assert.equal(d.collateralInStroops, 100_000_000n); +}); + +test("decideTrade under maxImpactBps requires reserves on the market state", () => { + assert.throws( + () => + decideTrade( + { marketId: 1, question: "q", yesBps: 5000, closeTime: 0 }, + 6000, + { minEdgeBps: 100, tradeSizeStroops: 1_000_000n, maxImpactBps: 200 }, + ), + /reserveYesStroops/, + ); +}); + +test("decideTrade skips when every size would trip the impact cap", () => { + // A 100-stroop pool: even the smallest executable buy moves the floored + // price by tens of bps, so a 10bps cap leaves no size at all. + const d = decideTrade( + { + marketId: 1, + question: "q", + yesBps: 5000, + closeTime: 0, + reserveYesStroops: 100n, + reserveNoStroops: 100n, + feeBps: 100, + }, + 6000, + { minEdgeBps: 100, tradeSizeStroops: 50n, maxImpactBps: 10 }, + ); + assert.equal(d.action, "skip"); +}); diff --git a/oracledesk-stellar/agents/core/strategy.ts b/oracledesk-stellar/agents/core/strategy.ts index eab6f51..3443cfb 100644 --- a/oracledesk-stellar/agents/core/strategy.ts +++ b/oracledesk-stellar/agents/core/strategy.ts @@ -10,6 +10,8 @@ * implementation of the roles described in the design doc, not a port. */ +import { calcBuy, priceAfterBuy, priceYesBps } from "./fpmm.js"; + export interface MarketSignal { /** Free-text description of what was observed (a news event, an oracle * feed update, a Polymarket price move, etc). */ @@ -84,6 +86,13 @@ export interface MarketState { question: string; yesBps: number; // current market-implied probability closeTime: number; + /** Pool reserves in stroops, if known. Required when the caller sets + * `maxImpactBps` on `decideTrade`. */ + reserveYesStroops?: bigint; + reserveNoStroops?: bigint; + /** The market's snapshotted trading fee, if known. Required when the + * caller sets `maxImpactBps` on `decideTrade`. */ + feeBps?: number; } export interface TraderDecision { @@ -93,15 +102,67 @@ export interface TraderDecision { collateralInStroops?: bigint; } +/** + * Largest `size <= maxSize` whose estimated price impact stays at or below + * `maxImpactBps`, found by binary search over `calcBuy` (impact is + * monotone in size for buys). Sizes whose fee consumes the whole amount + * are treated as infeasible. Returns 0n when even the smallest executable + * size would move the price too far. + */ +function clampSizeToImpact( + market: MarketState, + outcome: "Yes" | "No", + maxSize: bigint, + maxImpactBps: number, +): bigint { + const { reserveYesStroops: rYes, reserveNoStroops: rNo, feeBps } = market; + if (rYes === undefined || rNo === undefined || feeBps === undefined) { + throw new Error( + "decideTrade: maxImpactBps requires reserveYesStroops, reserveNoStroops and feeBps on the market state", + ); + } + const takenIsYes = outcome === "Yes"; + const rBought = takenIsYes ? rYes : rNo; + const rOther = takenIsYes ? rNo : rYes; + const pre = priceYesBps(rYes, rNo); + const cap = BigInt(maxImpactBps); + const impact = (size: bigint): bigint | null => { + if (size <= 0n) return 0n; + let post: bigint; + try { + post = priceAfterBuy(takenIsYes, calcBuy(rBought, rOther, size, BigInt(feeBps))); + } catch { + return null; // fee consumes the whole amount — not executable at all + } + return post > pre ? post - pre : pre - post; + }; + let lo = 0n; + let hi = maxSize; + while (lo < hi) { + const mid = (lo + hi + 1n) / 2n; + const m = impact(mid); + if (m !== null && m <= cap) lo = mid; + else hi = mid - 1n; + } + return lo; +} + /** * Compares a market's implied probability to a fair-value estimate and * decides whether the mispricing is worth trading. Trades toward whichever * side the market is *underpricing* relative to the estimate. + * + * With `maxImpactBps` set to a positive value, the trade size is clamped + * so the estimated price impact stays below the market's on-chain breaker + * (the market state must then carry reserves and `feeBps`); if even the + * smallest size would trip it, the decision is a skip instead of a + * guaranteed revert. `0` or omitted disables the clamp, matching the + * on-chain breaker's "0 = disabled" semantics. */ export function decideTrade( market: MarketState, fairValueBps: number, - opts: { minEdgeBps: number; tradeSizeStroops: bigint }, + opts: { minEdgeBps: number; tradeSizeStroops: bigint; maxImpactBps?: number }, ): TraderDecision { const edge = fairValueBps - market.yesBps; // positive => YES underpriced if (Math.abs(edge) < opts.minEdgeBps) { @@ -111,10 +172,24 @@ export function decideTrade( }; } const outcome = edge > 0 ? "Yes" : "No"; + let collateralInStroops = opts.tradeSizeStroops; + let clampNote = ""; + if (opts.maxImpactBps !== undefined && opts.maxImpactBps > 0) { + collateralInStroops = clampSizeToImpact(market, outcome, opts.tradeSizeStroops, opts.maxImpactBps); + if (collateralInStroops <= 0n) { + return { + action: "skip", + reasoning: `every size up to ${opts.tradeSizeStroops} stroops would move market ${market.marketId}'s price by more than ${opts.maxImpactBps}bps — the on-chain breaker would reject the trade`, + }; + } + if (collateralInStroops < opts.tradeSizeStroops) { + clampNote = `; size clamped from ${opts.tradeSizeStroops} to ${collateralInStroops} stroops to stay under the ${opts.maxImpactBps}bps impact breaker`; + } + } return { action: "buy", - reasoning: `market ${market.marketId} prices YES at ${market.yesBps}bps vs fair value ${fairValueBps}bps (edge ${edge}bps) — buying ${outcome}`, + reasoning: `market ${market.marketId} prices YES at ${market.yesBps}bps vs fair value ${fairValueBps}bps (edge ${edge}bps) — buying ${outcome}${clampNote}`, outcome, - collateralInStroops: opts.tradeSizeStroops, + collateralInStroops, }; } diff --git a/oracledesk-stellar/agents/package.json b/oracledesk-stellar/agents/package.json index f1c7471..ca65ef7 100644 --- a/oracledesk-stellar/agents/package.json +++ b/oracledesk-stellar/agents/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "tsc --noEmit", - "test": "tsx --test core/*.test.ts stellar/*.test.ts", + "test": "tsx --test core/*.test.ts stellar/*.test.ts bin/*.test.ts", "market-maker": "tsx bin/market-maker.ts", "trader": "tsx bin/trader.ts" }, diff --git a/oracledesk-stellar/agents/stellar/adapter.test.ts b/oracledesk-stellar/agents/stellar/adapter.test.ts index ce876cf..b845b07 100644 --- a/oracledesk-stellar/agents/stellar/adapter.test.ts +++ b/oracledesk-stellar/agents/stellar/adapter.test.ts @@ -36,6 +36,17 @@ test("live mode without an agent secret throws immediately", () => { }, /agentSecret/); }); +test("adapter rejects unknown mode", () => { + assert.throws(() => { + new StellarAdapter({ + mode: "yolo" as unknown as "dry-run", + networkPassphrase: "Test SDF Network ; September 2015", + rpcUrl: "https://soroban-testnet.stellar.org", + deployments, + }); + }, /unknown mode/); +}); + test("dry-run agentBuy describes the call without touching the network", async () => { const adapter = new StellarAdapter({ mode: "dry-run", @@ -55,3 +66,47 @@ test("dry-run agentBuy describes the call without touching the network", async ( assert.equal(result.method, "agent_buy"); } }); + +test("dry-run agentBuyV2 describes the bounded call without touching the network", async () => { + const adapter = new StellarAdapter({ + mode: "dry-run", + networkPassphrase: "Test SDF Network ; September 2015", + rpcUrl: "https://soroban-testnet.stellar.org", + deployments, + }); + const result = await adapter.agentBuyV2({ + market_id: 0n, + outcome: { tag: "Yes", values: undefined }, + collateral_in: 10_000_000n, + min_shares_out: 9_500_000n, + limit_price_bps: 6000, + deadline: 1_000_060n, + }); + assert.equal(result.dryRun, true); + if (result.dryRun) { + assert.equal(result.contractId, "CTREASURY"); + assert.equal(result.method, "agent_buy_v2"); + } +}); + +test("dry-run agentSellV2 describes the bounded call without touching the network", async () => { + const adapter = new StellarAdapter({ + mode: "dry-run", + networkPassphrase: "Test SDF Network ; September 2015", + rpcUrl: "https://soroban-testnet.stellar.org", + deployments, + }); + const result = await adapter.agentSellV2({ + market_id: 0n, + outcome: { tag: "No", values: undefined }, + collateral_out: 5_000_000n, + max_shares_in: 6_000_000n, + limit_price_bps: 4000, + deadline: 1_000_060n, + }); + assert.equal(result.dryRun, true); + if (result.dryRun) { + assert.equal(result.contractId, "CTREASURY"); + assert.equal(result.method, "agent_sell_v2"); + } +}); diff --git a/oracledesk-stellar/agents/stellar/adapter.ts b/oracledesk-stellar/agents/stellar/adapter.ts index b7d2c77..54a3544 100644 --- a/oracledesk-stellar/agents/stellar/adapter.ts +++ b/oracledesk-stellar/agents/stellar/adapter.ts @@ -69,6 +69,9 @@ export class StellarAdapter { private readonly publicKey?: string; constructor(private readonly opts: StellarAdapterOptions) { + if (opts.mode !== "dry-run" && opts.mode !== "live") { + throw new Error(`unknown mode ${JSON.stringify(opts.mode)}: expected "dry-run" or "live"`); + } if (opts.mode === "live") { if (!opts.agentSecret) throw new Error("live mode requires agentSecret"); const keypair = Keypair.fromSecret(opts.agentSecret); @@ -145,6 +148,70 @@ export class StellarAdapter { ); } + /** + * `agent_buy` with contract-enforced bounds: the trade reverts past + * `deadline` (ledger timestamp, unix seconds) or when the average + * execution price is worse than `limit_price_bps` (fee included; 0 = no + * limit for buys). On-chain this is the treasury's `agent_buy_v2`. + */ + async agentBuyV2(args: { + market_id: bigint; + outcome: { tag: "Yes" | "No"; values: void }; + collateral_in: bigint; + min_shares_out: bigint; + limit_price_bps: number; + deadline: bigint; + }): Promise> { + return this.run("treasury", "@oracledesk/bindings-treasury", "agent_buy_v2", args, (client) => + client.agent_buy_v2(args), + ); + } + + async agentSell(args: { + market_id: bigint; + outcome: { tag: "Yes" | "No"; values: void }; + collateral_out: bigint; + max_shares_in: bigint; + }): Promise> { + return this.run("treasury", "@oracledesk/bindings-treasury", "agent_sell", args, (client) => + client.agent_sell(args), + ); + } + + /** + * `agent_sell` with contract-enforced bounds. For sells a + * `limit_price_bps` of 0 is trivially satisfied on-chain (no + * protection) — pass the fair value you are willing to sell at. + */ + async agentSellV2(args: { + market_id: bigint; + outcome: { tag: "Yes" | "No"; values: void }; + collateral_out: bigint; + max_shares_in: bigint; + limit_price_bps: number; + deadline: bigint; + }): Promise> { + return this.run("treasury", "@oracledesk/bindings-treasury", "agent_sell_v2", args, (client) => + client.agent_sell_v2(args), + ); + } + + /** + * Simulates market-core's `quote_buy` (read-only — never signs or + * submits) and returns the quoted shares. Unlike the mutating wrappers + * this also runs in dry-run mode, so a dry-run trader can still derive + * its `min_shares_out` / `limit_price_bps` bounds. + */ + async quoteBuy(args: { + market_id: bigint; + outcome: { tag: "Yes" | "No"; values: void }; + collateral_in: bigint; + }): Promise { + const client = await this.client("market_core", "@oracledesk/bindings-market-core"); + const { result } = await client.quote_buy(args); + return result as bigint; + } + async publishTrace(args: { agent: string; market_id: bigint; diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index d8b8a69..57e537e 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -96,6 +96,44 @@ pub struct Position { pub no: i128, } +/// Fee destination and split, snapshotted per market at creation as a side +/// entry (`DataKey::FeeTerms`) so `Market`'s XDR shape never changes. +/// +/// Markets created before this entry existed have no `FeeTerms` and keep the +/// legacy behaviour: `sweep_fees` pays whatever `DataKey::Treasury` holds at +/// sweep time, with the protocol keeping 100% of the fee. That fallback is +/// documented in `sweep_fees` and makes the upgrade migration-free. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeeTerms { + /// Where the protocol's fee share is swept. Frozen at creation: later + /// `set_treasury` calls never redirect fees already earned. + pub recipient: Address, + /// Protocol's share of each trade fee in bps; the rest is the LP's. + /// 0..=BPS. At BPS (the default) the split is exactly today's behaviour. + pub protocol_share_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. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BreakerConfig { + /// Maximum |post-price - pre-price| a single trade may move in bps. + pub max_impact_bps: u32, + /// Maximum |post-price - ledger-open price| cumulative movement in bps. + pub band_bps: u32, +} + +/// Temporary (one ledger) band reference for a market. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BandState { + pub ledger_seq: u32, + pub ref_price_bps: u32, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Config { @@ -104,6 +142,7 @@ pub struct Config { pub treasury: Address, pub resolver: Address, pub default_fee_bps: u32, + pub protocol_fee_share_bps: u32, pub paused: bool, } @@ -119,11 +158,18 @@ pub enum DataKey { Treasury, Resolver, DefaultFeeBps, + ProtocolFeeShareBps, + DefaultBreaker, Paused, NextId, // persistent storage (one entry each, own TTL) Market(u64), Position(u64, Address), + FeeTerms(u64), + LpFees(u64), + Breaker(u64), + // temporary storage (expires on its own, one entry per market) + Band(u64), } // --------------------------------------------------------------------------- @@ -152,6 +198,16 @@ pub enum Error { NothingToRedeem = 16, AlreadyClaimed = 17, Overflow = 18, + /// `buy_v2` / `sell_v2` called with `env.ledger().timestamp() > deadline`. + DeadlineExpired = 19, + /// `buy_v2` / `sell_v2` average execution price worse than the limit. + LimitPriceExceeded = 20, + /// A single trade would move the price more than `max_impact_bps`. + /// (Both this and the band reject the whole invocation, so their events, + /// if any, roll back with it — see the note on `BreakerConfig`.) + PriceImpactTooHigh = 21, + /// Cumulative same-ledger movement would exceed `band_bps`. + PriceBandExceeded = 22, } impl From for Error { @@ -219,6 +275,69 @@ pub struct FeesSwept { pub amount: i128, } +/// Same sweep as `FeesSwept` plus the actual recipient. New event rather +/// than new fields so the existing `FeesSwept` XDR stays stable for +/// indexers; both are emitted on every sweep. +#[contractevent] +pub struct FeesSweptTo { + #[topic] + pub market_id: u64, + pub recipient: Address, + pub amount: i128, +} + +#[contractevent] +pub struct LpFeesClaimed { + #[topic] + pub market_id: u64, + #[topic] + pub creator: Address, + pub amount: i128, +} + +#[contractevent] +pub struct PoolRemainderClaimed { + #[topic] + pub market_id: u64, + #[topic] + pub creator: Address, + /// Pool remainder plus any LP fees drained in the same call. + pub payout: i128, +} + +#[contractevent] +pub struct TreasuryChanged { + pub old: Address, + pub new: Address, +} + +#[contractevent] +pub struct ProtocolFeeShareChanged { + pub old: u32, + pub new: u32, +} + +/// Positions moved between holders. Indexers that track balances from +/// `Trade`/`Redeemed` must also apply this event (see docs/architecture.md). +#[contractevent] +pub struct PositionTransferred { + #[topic] + pub market_id: u64, + #[topic] + pub from: Address, + pub to: Address, + pub outcome: Outcome, + pub amount: i128, +} + +#[contractevent] +pub struct LpTransferred { + #[topic] + pub market_id: u64, + pub from: Address, + pub to: Address, +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -229,6 +348,9 @@ pub struct MarketCore; #[contractimpl] impl MarketCore { /// Runs atomically at deploy time, so nobody can front-run initialization. + /// `protocol_fee_share_bps` is the default protocol/LP split for new + /// markets; 10_000 keeps the whole fee with the protocol (pre-upgrade + /// behaviour). Pass 0 to give all fees to the market creators. pub fn __constructor( env: Env, admin: Address, @@ -236,16 +358,21 @@ impl MarketCore { treasury: Address, resolver: Address, default_fee_bps: u32, + protocol_fee_share_bps: u32, ) { if default_fee_bps > MAX_FEE_BPS { panic_with_error!(&env, Error::InvalidFee); } + if protocol_fee_share_bps as i128 > BPS { + panic_with_error!(&env, Error::InvalidFee); + } let s = env.storage().instance(); s.set(&DataKey::Admin, &admin); s.set(&DataKey::Collateral, &collateral); s.set(&DataKey::Treasury, &treasury); s.set(&DataKey::Resolver, &resolver); s.set(&DataKey::DefaultFeeBps, &default_fee_bps); + s.set(&DataKey::ProtocolFeeShareBps, &protocol_fee_share_bps); s.set(&DataKey::Paused, &false); s.set(&DataKey::NextId, &0u64); } @@ -276,9 +403,54 @@ impl MarketCore { Ok(()) } + /// Affects markets created after the call and the legacy fallback in + /// `sweep_fees`. Markets with a snapshotted `FeeTerms` keep paying their + /// original recipient. pub fn set_treasury(env: Env, treasury: Address) -> Result<(), Error> { Self::require_admin(&env)?; + let old: Address = Self::instance_get(&env, &DataKey::Treasury)?; env.storage().instance().set(&DataKey::Treasury, &treasury); + TreasuryChanged { old, new: treasury }.publish(&env); + Ok(()) + } + + /// Affects markets created after the call; existing markets keep the + /// split they snapshotted at creation. + pub fn set_protocol_fee_share_bps(env: Env, share_bps: u32) -> Result<(), Error> { + Self::require_admin(&env)?; + if share_bps as i128 > BPS { + return Err(Error::InvalidFee); + } + let old: u32 = Self::instance_get(&env, &DataKey::ProtocolFeeShareBps)?; + env.storage() + .instance() + .set(&DataKey::ProtocolFeeShareBps, &share_bps); + ProtocolFeeShareChanged { + old, + new: share_bps, + } + .publish(&env); + Ok(()) + } + + /// Sets the breaker defaults copied into markets created after the call. + /// `band_bps < max_impact_bps` (both non-zero) is rejected: a single + /// trade that is allowed by the impact cap could then never fit inside + /// the band, locking trading. + pub fn set_default_breaker(env: Env, breaker: BreakerConfig) -> Result<(), Error> { + Self::require_admin(&env)?; + if breaker.max_impact_bps as i128 > BPS || breaker.band_bps as i128 > BPS { + return Err(Error::InvalidAmount); + } + if breaker.max_impact_bps > 0 + && breaker.band_bps > 0 + && breaker.band_bps < breaker.max_impact_bps + { + return Err(Error::InvalidAmount); + } + env.storage() + .instance() + .set(&DataKey::DefaultBreaker, &breaker); Ok(()) } @@ -319,6 +491,11 @@ impl MarketCore { 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 breaker: Option = s.get(&DataKey::DefaultBreaker); let market = Market { creator: creator.clone(), @@ -336,6 +513,17 @@ impl MarketCore { lp_claimed: false, }; Self::save_market(&env, id, &market); + Self::save_fee_terms( + &env, + id, + &FeeTerms { + recipient: treasury, + protocol_share_bps, + }, + ); + if let Some(b) = breaker { + Self::save_breaker(&env, id, &b); + } if seed.creator_yes > 0 || seed.creator_no > 0 { Self::save_position( &env, @@ -366,6 +554,9 @@ impl MarketCore { /// Spend `collateral_in` to buy `outcome` shares. Returns shares received. /// Reverts if fewer than `min_shares_out` would be received. + /// + /// Legacy entrypoint: no deadline and no limit price, so a signed bound + /// can execute at any later time. Prefer `buy_v2`. pub fn buy( env: Env, trader: Address, @@ -373,8 +564,74 @@ impl MarketCore { outcome: Outcome, collateral_in: i128, min_shares_out: i128, + ) -> Result { + Self::do_buy( + env, + trader, + market_id, + outcome, + collateral_in, + min_shares_out, + None, + ) + } + + /// `buy` plus a deadline (`env.ledger().timestamp()` seconds — network + /// time, not the client clock) and an average-price limit + /// (`limit_price_bps` per share, 1.0 = BPS; 0 = no limit). + /// + /// The average price includes the fee (`fee` is part of `collateral_in`) + /// and, for a NO buy, is compared against the NO price. Because the + /// deadline check runs before `require_auth`, an expired transaction + /// fails cheaply. A limit above BPS can never pass for a buy and is + /// rejected as invalid. + #[allow(clippy::too_many_arguments)] + pub fn buy_v2( + env: Env, + trader: Address, + market_id: u64, + outcome: Outcome, + collateral_in: i128, + min_shares_out: i128, + limit_price_bps: u32, + deadline: u64, + ) -> Result { + if env.ledger().timestamp() > deadline { + return Err(Error::DeadlineExpired); + } + Self::do_buy( + env, + trader, + market_id, + outcome, + collateral_in, + min_shares_out, + if limit_price_bps == 0 { + None + } else { + Some(limit_price_bps) + }, + ) + } + + /// Shared implementation of `buy` / `buy_v2` (single copy of the + /// breaker, effect and interaction logic). + #[allow(clippy::too_many_arguments)] + fn do_buy( + env: Env, + trader: Address, + market_id: u64, + outcome: Outcome, + collateral_in: i128, + min_shares_out: i128, + limit_price_bps: Option, ) -> Result { Self::assert_not_paused(&env)?; + if let Some(limit) = limit_price_bps { + if limit as i128 > BPS { + return Err(Error::InvalidAmount); + } + } trader.require_auth(); let mut m = Self::load_market(&env, market_id)?; Self::assert_tradable(&env, &m)?; @@ -384,6 +641,19 @@ impl MarketCore { if q.shares_out < min_shares_out { return Err(Error::SlippageExceeded); } + if let Some(limit) = limit_price_bps { + if !math::buy_within_limit(collateral_in, q.shares_out, limit)? { + return Err(Error::LimitPriceExceeded); + } + } + Self::check_breakers( + &env, + market_id, + &m, + outcome, + q.new_reserve_bought, + q.new_reserve_other, + )?; let new_sets = m.sets_minted.checked_add(q.net).ok_or(Error::Overflow)?; if new_sets > MAX_SETS { return Err(Error::MarketTooLarge); @@ -392,7 +662,11 @@ impl MarketCore { // effects Self::set_reserves(&mut m, outcome, q.new_reserve_bought, q.new_reserve_other); m.sets_minted = new_sets; - m.fees_accrued = m.fees_accrued.checked_add(q.fee).ok_or(Error::Overflow)?; + let protocol_fee = Self::accrue_fees(&env, market_id, q.fee)?; + m.fees_accrued = m + .fees_accrued + .checked_add(protocol_fee) + .ok_or(Error::Overflow)?; let mut pos = Self::load_position(&env, market_id, &trader); match outcome { Outcome::Yes => pos.yes += q.shares_out, @@ -423,6 +697,8 @@ impl MarketCore { /// 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`. pub fn sell( env: Env, trader: Address, @@ -430,6 +706,64 @@ impl MarketCore { outcome: Outcome, collateral_out: i128, max_shares_in: i128, + ) -> Result { + Self::do_sell( + env, + trader, + market_id, + outcome, + collateral_out, + max_shares_in, + None, + ) + } + + /// `sell` plus a deadline (ledger timestamp, seconds) and a minimum + /// average execution price (`limit_price_bps` per share, 1.0 = BPS). + /// + /// A zero limit is trivially satisfied for sells — unlike buys, where 0 + /// means "no limit" — and a limit above BPS can never pass, so neither + /// needs a special case. The average includes the fee, which the trader + /// pays out of the gross. + #[allow(clippy::too_many_arguments)] + pub fn sell_v2( + env: Env, + trader: Address, + market_id: u64, + outcome: Outcome, + collateral_out: i128, + max_shares_in: i128, + limit_price_bps: u32, + deadline: u64, + ) -> Result { + if env.ledger().timestamp() > deadline { + return Err(Error::DeadlineExpired); + } + Self::do_sell( + env, + trader, + market_id, + outcome, + collateral_out, + max_shares_in, + if limit_price_bps == 0 { + None + } else { + Some(limit_price_bps) + }, + ) + } + + /// Shared implementation of `sell` / `sell_v2`. + #[allow(clippy::too_many_arguments)] + fn do_sell( + env: Env, + trader: Address, + market_id: u64, + outcome: Outcome, + collateral_out: i128, + max_shares_in: i128, + limit_price_bps: Option, ) -> Result { Self::assert_not_paused(&env)?; trader.require_auth(); @@ -441,6 +775,19 @@ impl MarketCore { if q.shares_in > max_shares_in { return Err(Error::SlippageExceeded); } + if let Some(limit) = limit_price_bps { + if !math::sell_within_limit(collateral_out, q.shares_in, limit)? { + return Err(Error::LimitPriceExceeded); + } + } + Self::check_breakers( + &env, + market_id, + &m, + outcome, + q.new_reserve_sold, + q.new_reserve_other, + )?; let mut pos = Self::load_position(&env, market_id, &trader); let held = match outcome { @@ -459,7 +806,11 @@ impl MarketCore { Self::save_position(&env, market_id, &trader, &pos); Self::set_reserves(&mut m, outcome, q.new_reserve_sold, q.new_reserve_other); m.sets_minted -= q.gross; - m.fees_accrued = m.fees_accrued.checked_add(q.fee).ok_or(Error::Overflow)?; + let protocol_fee = Self::accrue_fees(&env, market_id, q.fee)?; + m.fees_accrued = m + .fees_accrued + .checked_add(protocol_fee) + .ok_or(Error::Overflow)?; Self::save_market(&env, market_id, &m); // interaction @@ -547,7 +898,8 @@ impl MarketCore { Ok(payout) } - /// Creator (the LP) withdraws whatever the pool still holds once final. + /// Creator (the LP) withdraws whatever the pool still holds once final, + /// plus any unclaimed LP fees. Returns the total paid out. pub fn claim_pool_remainder(env: Env, market_id: u64) -> Result { let mut m = Self::load_market(&env, market_id)?; m.creator.require_auth(); @@ -555,7 +907,11 @@ impl MarketCore { if m.lp_claimed { return Err(Error::AlreadyClaimed); } - let payout = math::settle_value(kind, m.reserve_yes, m.reserve_no); + let pool_value = math::settle_value(kind, m.reserve_yes, m.reserve_no); + 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.reserve_yes = 0; m.reserve_no = 0; m.lp_claimed = true; @@ -564,10 +920,41 @@ impl MarketCore { if payout > 0 { Self::token(&env)?.transfer(&env.current_contract_address(), &creator, &payout); } + PoolRemainderClaimed { + market_id, + creator, + payout, + } + .publish(&env); Ok(payout) } - /// Anyone can move accrued fees to the treasury. + /// 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`). + pub fn claim_lp_fees(env: Env, market_id: u64) -> Result { + let m = Self::load_market(&env, market_id)?; + m.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); + LpFeesClaimed { + market_id, + creator: m.creator.clone(), + amount, + } + .publish(&env); + Ok(amount) + } + + /// Anyone can move accrued protocol fees to the market's fee recipient. + /// + /// 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. pub fn sweep_fees(env: Env, market_id: u64) -> Result { let mut m = Self::load_market(&env, market_id)?; let amount = m.fees_accrued; @@ -576,12 +963,104 @@ impl MarketCore { } m.fees_accrued = 0; Self::save_market(&env, market_id, &m); - let treasury: Address = Self::instance_get(&env, &DataKey::Treasury)?; - Self::token(&env)?.transfer(&env.current_contract_address(), &treasury, &amount); + let recipient = match Self::load_fee_terms(&env, market_id) { + Some(t) => t.recipient, + None => Self::instance_get(&env, &DataKey::Treasury)?, + }; + Self::token(&env)?.transfer(&env.current_contract_address(), &recipient, &amount); FeesSwept { market_id, amount }.publish(&env); + FeesSweptTo { + market_id, + recipient, + amount, + } + .publish(&env); Ok(amount) } + // ----- transferability -------------------------------------------------- + // Positions and the single LP role are plain storage entries, so moving + // them is bookkeeping only: no collateral moves, `k` is untouched, and + // the invariant `reserve_yes + Σ pos.yes == sets_minted` holds by + // construction. Transfers are allowed while the market is Open, Paused, + // or final — a transfer spends no collateral, so the pause does not + // apply. After `redeem` the position is empty and transfers fail + // naturally with `InsufficientShares`. + + /// Moves `amount` shares of `outcome` from `from` to `to`. + pub fn transfer_position( + env: Env, + from: Address, + to: Address, + market_id: u64, + outcome: Outcome, + amount: i128, + ) -> Result<(), Error> { + from.require_auth(); + if amount <= 0 || from == to { + return Err(Error::InvalidAmount); + } + if to == env.current_contract_address() { + // Shares held by the contract itself could never be redeemed + // (`redeem` requires the holder's auth), so this would burn them. + return Err(Error::InvalidAmount); + } + Self::load_market(&env, market_id)?; + let mut a = Self::load_position(&env, market_id, &from); + let mut b = Self::load_position(&env, market_id, &to); + match outcome { + Outcome::Yes => { + if a.yes < amount { + return Err(Error::InsufficientShares); + } + a.yes -= amount; + b.yes = b.yes.checked_add(amount).ok_or(Error::Overflow)?; + } + Outcome::No => { + if a.no < amount { + return Err(Error::InsufficientShares); + } + a.no -= amount; + b.no = b.no.checked_add(amount).ok_or(Error::Overflow)?; + } + } + Self::save_position(&env, market_id, &from, &a); + Self::save_position(&env, market_id, &to, &b); + PositionTransferred { + market_id, + from, + to, + outcome, + amount, + } + .publish(&env); + Ok(()) + } + + /// Transfers the single LP role (the right to `claim_pool_remainder` and + /// `claim_lp_fees`) to `new_creator`. Only until the remainder has been + /// claimed; after that the role has no remaining value. + pub fn transfer_lp(env: Env, market_id: u64, new_creator: Address) -> Result<(), Error> { + let mut m = Self::load_market(&env, market_id)?; + m.creator.require_auth(); + if m.lp_claimed { + return Err(Error::AlreadyClaimed); + } + if new_creator == m.creator || new_creator == env.current_contract_address() { + return Err(Error::InvalidAmount); + } + let from = m.creator.clone(); + m.creator = new_creator.clone(); + Self::save_market(&env, market_id, &m); + LpTransferred { + market_id, + from, + to: new_creator, + } + .publish(&env); + Ok(()) + } + // ----- views ---------------------------------------------------------- pub fn get_config(env: Env) -> Result { @@ -591,6 +1070,7 @@ impl MarketCore { treasury: Self::instance_get(&env, &DataKey::Treasury)?, resolver: Self::instance_get(&env, &DataKey::Resolver)?, default_fee_bps: Self::instance_get(&env, &DataKey::DefaultFeeBps)?, + protocol_fee_share_bps: Self::instance_get(&env, &DataKey::ProtocolFeeShareBps)?, paused: Self::instance_get(&env, &DataKey::Paused)?, }) } @@ -641,6 +1121,72 @@ impl MarketCore { 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) } + + /// The market's snapshotted breakers (`None` for legacy markets created + /// before breakers existed — those are never checked). + pub fn breaker_config(env: Env, market_id: u64) -> Result, Error> { + Self::load_market(&env, market_id)?; + Ok(Self::load_breaker(&env, market_id)) + } + + /// 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). + pub fn breaker_state(env: Env, market_id: u64) -> Result, Error> { + Self::load_market(&env, market_id)?; + let state = env + .storage() + .temporary() + .get::<_, BandState>(&DataKey::Band(market_id)); + Ok(match state { + Some(s) if s.ledger_seq == env.ledger().sequence() => Some(s), + _ => None, + }) + } + + /// True when a `buy` of `collateral_in` would be rejected by the + /// market's breakers right now. Read-only: never writes band state, so + /// on a fresh ledger the reference is taken as the current price — the + /// same value a subsequent real trade would use. + pub fn would_trip( + env: Env, + market_id: u64, + outcome: Outcome, + collateral_in: i128, + ) -> Result { + let m = Self::load_market(&env, market_id)?; + let cfg = match Self::load_breaker(&env, market_id) { + Some(c) => c, + None => return Ok(false), + }; + if cfg.max_impact_bps == 0 && cfg.band_bps == 0 { + 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 pre = math::price_yes_bps(m.reserve_yes, m.reserve_no)?; + let post = math::price_after( + matches!(outcome, Outcome::Yes), + q.new_reserve_bought, + q.new_reserve_other, + )?; + if cfg.max_impact_bps > 0 && (post - pre).abs() > cfg.max_impact_bps as i128 { + return Ok(true); + } + if cfg.band_bps > 0 { + let reference = env + .storage() + .temporary() + .get::<_, BandState>(&DataKey::Band(market_id)) + .filter(|s| s.ledger_seq == env.ledger().sequence()) + .map(|s| s.ref_price_bps) + .unwrap_or(pre as u32); + if (post - reference as i128).abs() > cfg.band_bps as i128 { + return Ok(true); + } + } + Ok(false) + } } // --------------------------------------------------------------------------- @@ -770,38 +1316,194 @@ impl MarketCore { .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); } } -} - -#[cfg(test)] -mod test { - use super::*; - use soroban_sdk::{ - testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}, - token::StellarAssetClient, - IntoVal, - }; - fn setup_market() -> ( - Env, - MarketCoreClient<'static>, - Address, - Address, - Address, - Address, - ) { - let env = Env::default(); - env.mock_all_auths(); + fn load_fee_terms(env: &Env, id: u64) -> Option { + let key = DataKey::FeeTerms(id); + let terms = env.storage().persistent().get(&key); + if terms.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + terms + } - let admin = Address::generate(&env); - let treasury = Address::generate(&env); - let resolver = Address::generate(&env); - let creator = Address::generate(&env); - let trader = Address::generate(&env); + fn save_fee_terms(env: &Env, id: u64, terms: &FeeTerms) { + let key = DataKey::FeeTerms(id); + env.storage().persistent().set(&key, terms); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } - let collateral = env.register_stellar_asset_contract_v2(admin.clone()); - let collateral_client = StellarAssetClient::new(&env, &collateral.address()); - collateral_client.mint(&creator, &10_000_000_000_i128); - collateral_client.mint(&trader, &10_000_000_000_i128); + fn load_lp_fees(env: &Env, id: u64) -> i128 { + env.storage() + .persistent() + .get(&DataKey::LpFees(id)) + .unwrap_or(0) + } + + /// Removing the entry at zero keeps storage from growing with dust. + fn save_lp_fees(env: &Env, id: u64, amount: i128) { + let key = DataKey::LpFees(id); + if amount <= 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &amount); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + } + + /// Splits a trade `fee` per the market's snapshotted `FeeTerms` (legacy + /// markets: everything to the protocol), records the LP portion under + /// `LpFees(id)` and returns the protocol portion for `fees_accrued`. + /// Must be called once per trade, never recomputed later. + fn accrue_fees(env: &Env, id: u64, fee: i128) -> Result { + let (protocol, lp) = match Self::load_fee_terms(env, id) { + Some(t) => math::split_fee(fee, t.protocol_share_bps)?, + None => (fee, 0), + }; + if lp > 0 { + let current = Self::load_lp_fees(env, id); + Self::save_lp_fees(env, id, current.checked_add(lp).ok_or(Error::Overflow)?); + } + Ok(protocol) + } + + fn load_breaker(env: &Env, id: u64) -> Option { + let key = DataKey::Breaker(id); + let cfg: Option = env.storage().persistent().get(&key); + if cfg.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + cfg + } + + fn save_breaker(env: &Env, id: u64, cfg: &BreakerConfig) { + let key = DataKey::Breaker(id); + env.storage().persistent().set(&key, cfg); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + /// 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. + /// + /// Both failures abort the whole invocation, so any event describing the + /// trip would be rolled back with it — this contract therefore emits + /// **no** breaker event. The error code (`PriceImpactTooHigh` / + /// `PriceBandExceeded`) in the transaction result, and the + /// `would_trip` / `breaker_state` views, are the observability path. + fn check_breakers( + env: &Env, + id: u64, + m: &Market, + outcome: Outcome, + new_outcome_reserve: i128, + new_other_reserve: i128, + ) -> Result<(), Error> { + let cfg = match Self::load_breaker(env, id) { + Some(c) => c, + None => return Ok(()), + }; + if cfg.max_impact_bps == 0 && cfg.band_bps == 0 { + return Ok(()); + } + let pre = math::price_yes_bps(m.reserve_yes, m.reserve_no)?; + let post = math::price_after( + matches!(outcome, Outcome::Yes), + new_outcome_reserve, + new_other_reserve, + )?; + // `>`, not `>=`: a trade that moves exactly max_impact_bps passes. + if cfg.max_impact_bps > 0 && (post - pre).abs() > cfg.max_impact_bps as i128 { + return Err(Error::PriceImpactTooHigh); + } + if cfg.band_bps > 0 { + let reference = Self::band_ref(env, id, pre); + if (post - reference as i128).abs() > cfg.band_bps as i128 { + return Err(Error::PriceBandExceeded); + } + } + Ok(()) + } + + /// The band reference for the current ledger: the price at the ledger's + /// first trade (== the ledger-open price, because no trade earlier this + /// ledger succeeded), written to short-TTL temporary storage. A missing + /// or stale entry means "new ledger", which is safe: the reference + /// becomes the current price. + fn band_ref(env: &Env, id: u64, pre_price: i128) -> u32 { + let key = DataKey::Band(id); + let seq = env.ledger().sequence(); + if let Some(state) = env.storage().temporary().get::<_, BandState>(&key) { + if state.ledger_seq == seq { + return state.ref_price_bps; + } + } + let reference = pre_price as u32; + env.storage().temporary().set( + &key, + &BandState { + ledger_seq: seq, + ref_price_bps: reference, + }, + ); + env.storage().temporary().extend_ttl(&key, 1, 16); + reference + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{ + testutils::{Address as _, Events, Ledger, MockAuth, MockAuthInvoke}, + token::StellarAssetClient, + IntoVal, + }; + + fn setup_market() -> ( + Env, + MarketCoreClient<'static>, + Address, + Address, + Address, + Address, + ) { + setup_market_full(10_000u32, None) + } + + fn setup_market_full( + protocol_share_bps: u32, + breaker: Option, + ) -> ( + Env, + MarketCoreClient<'static>, + Address, + Address, + Address, + Address, + ) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let resolver = Address::generate(&env); + let creator = Address::generate(&env); + let trader = Address::generate(&env); + + let collateral = env.register_stellar_asset_contract_v2(admin.clone()); + let collateral_client = StellarAssetClient::new(&env, &collateral.address()); + collateral_client.mint(&creator, &10_000_000_000_i128); + collateral_client.mint(&trader, &10_000_000_000_i128); let contract_id = env.register( MarketCore, @@ -811,13 +1513,53 @@ mod test { &treasury, &resolver, &100u32, + &protocol_share_bps, ), ); let client = MarketCoreClient::new(&env, &contract_id); + if let Some(b) = breaker { + client.set_default_breaker(&b); + } (env, client, creator, trader, resolver, collateral.address()) } + /// One 100 USDC market seeded at the given opening probability. + fn new_market( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + initial_yes_bps: u32, + ) -> u64 { + client.create_market( + creator, + &BytesN::from_array(env, &[42u8; 32]), + &BytesN::from_array(env, &[142u8; 32]), + &String::from_str(env, "ipfs://test-market"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &(100 * 10_000_000_i128), + &initial_yes_bps, + ) + } + + fn lp_fees_stored(env: &Env, client: &MarketCoreClient, market_id: u64) -> i128 { + let contract = client.address.clone(); + env.as_contract(&contract, || { + env.storage() + .persistent() + .get::(&DataKey::LpFees(market_id)) + .unwrap_or(0) + }) + } + + fn remove_side_entry(env: &Env, client: &MarketCoreClient, key: DataKey) { + let contract = client.address.clone(); + env.as_contract(&contract, || { + env.storage().persistent().remove(&key); + }); + } + #[test] fn create_market_sets_initial_price() { let (env, client, creator, _, _, _) = setup_market(); @@ -1143,4 +1885,848 @@ mod test { .try_resolve(&market_id, &Outcome::Yes) .is_err()); } + + // ----- #8: fee recipient snapshot + protocol/LP fee split --------------- + + #[test] + fn fee_recipient_snapshotted_at_creation() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + let original_treasury = client.get_config().treasury; + + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let accrued = client.get_market(&market_id).fees_accrued; + assert!(accrued > 0); + + // Changing the contract-wide treasury must not redirect fees the + // market already committed to the treasury of creation time. + let replacement = Address::generate(&env); + client.set_treasury(&replacement); + + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let before = token.balance(&original_treasury); + assert_eq!(client.sweep_fees(&market_id), accrued); + assert_eq!(token.balance(&original_treasury), before + accrued); + assert_eq!(token.balance(&replacement), 0); + } + + #[test] + fn lp_fees_accrue_and_claimable_before_finality() { + let (env, client, creator, trader, _, collateral) = setup_market_full(5000, None); + let market_id = new_market(&env, &client, &creator, 5000); + + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + + // 1% of 10 USDC, split 50/50. + assert_eq!(client.get_market(&market_id).fees_accrued, 500_000); + assert_eq!(lp_fees_stored(&env, &client, market_id), 500_000); + + // LP fees are claimable before finality — unlike the pool remainder. + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let before = token.balance(&creator); + assert_eq!(client.claim_lp_fees(&market_id), 500_000); + assert_eq!(token.balance(&creator), before + 500_000); + assert_eq!(lp_fees_stored(&env, &client, market_id), 0); + assert_eq!(client.claim_lp_fees(&market_id), 0); + } + + #[test] + fn claim_pool_remainder_includes_lp_fees() { + let (env, client, creator, trader, _, collateral) = setup_market_full(5000, None); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let lp_fees = lp_fees_stored(&env, &client, market_id); + assert!(lp_fees > 0); + + client.void_market(&market_id); + let m = client.get_market(&market_id); + let expected_pool = (m.reserve_yes + m.reserve_no) / 2; // void pays half + + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let before = token.balance(&creator); + let payout = client.claim_pool_remainder(&market_id); + assert_eq!(payout, expected_pool + lp_fees); + assert_eq!(token.balance(&creator), before + payout); + assert_eq!(lp_fees_stored(&env, &client, market_id), 0); + } + + #[test] + fn protocol_plus_lp_equals_total_fee() { + let (env, client, creator, trader, _, _) = setup_market_full(3333, None); + let market_id = new_market(&env, &client, &creator, 5000); + + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + + // Fee is exactly 1_000_000; split 3333/6667 rounds the protocol's + // ceil so the two parts always sum to the fee. + let accrued = client.get_market(&market_id).fees_accrued; + let lp = lp_fees_stored(&env, &client, market_id); + assert_eq!(accrued, 333_300); + assert_eq!(lp, 666_700); + assert_eq!(accrued + lp, 1_000_000); + } + + #[test] + fn legacy_market_without_fee_terms_uses_current_treasury() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + // Simulate a market created before FeeTerms existed. + remove_side_entry(&env, &client, DataKey::FeeTerms(market_id)); + + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let accrued = client.get_market(&market_id).fees_accrued; + assert!(accrued > 0); + // No terms: the whole fee stays on the protocol side. + assert_eq!(lp_fees_stored(&env, &client, market_id), 0); + + let replacement = Address::generate(&env); + client.set_treasury(&replacement); + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let before = token.balance(&replacement); + assert_eq!(client.sweep_fees(&market_id), accrued); + assert_eq!(token.balance(&replacement), before + accrued); + } + + #[test] + fn wrong_caller_cannot_set_protocol_fee_share() { + let (env, client, _, trader, _, _) = setup_market(); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: &trader, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "set_protocol_fee_share_bps", + args: (2500u32,).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_set_protocol_fee_share_bps(&2500u32) + .is_err()); + } + + #[test] + fn wrong_caller_cannot_claim_lp_fees() { + let (env, client, creator, trader, _, _) = setup_market_full(5000, None); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + assert!(lp_fees_stored(&env, &client, market_id) > 0); + + let attacker = Address::generate(&env); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "claim_lp_fees", + args: (market_id,).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_claim_lp_fees(&market_id) + .is_err()); + } + + #[test] + fn set_treasury_and_split_change_emit_events() { + let (env, client, _, _, _, _) = setup_market(); + // `env.events().all()` reports the last invocation's events only, so + // each setter must publish exactly one event in its own invocation. + client.set_treasury(&Address::generate(&env)); + assert_eq!(env.events().all().events().len(), 1); + client.set_protocol_fee_share_bps(&2500u32); + assert_eq!(env.events().all().events().len(), 1); + assert_eq!(client.get_config().protocol_fee_share_bps, 2500); + assert_eq!( + client.try_set_protocol_fee_share_bps(&10_001u32), + Err(Ok(Error::InvalidFee)) + ); + } + + // ----- #10: price-impact and per-ledger price-band breakers ------------- + + /// Measures how many bps a 10 USDC YES buy moves the YES price on a + /// fresh 100 USDC market — exactly the impact a breaker must allow. + fn measure_impact_10_usdc( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + trader: &Address, + ) -> u32 { + let market_id = new_market(env, client, creator, 5000); + let before = client.get_price(&market_id, &Outcome::Yes); + client.buy( + trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + client.get_price(&market_id, &Outcome::Yes) - before + } + + #[test] + #[should_panic(expected = "Error(Contract, #21)")] + fn impact_cap_rejects_large_buy() { + let (env, client, creator, trader, _, _) = setup_market(); + let impact = measure_impact_10_usdc(&env, &client, &creator, &trader); + assert!(impact > 0); + client.set_default_breaker(&BreakerConfig { + max_impact_bps: impact - 1, + band_bps: 0, + }); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + } + + #[test] + fn impact_cap_allows_exact_bound() { + let (env, client, creator, trader, _, _) = setup_market(); + let impact = measure_impact_10_usdc(&env, &client, &creator, &trader); + client.set_default_breaker(&BreakerConfig { + max_impact_bps: impact, + band_bps: 0, + }); + let market_id = new_market(&env, &client, &creator, 5000); + // A trade that moves exactly max_impact_bps passes. + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + assert_eq!(client.get_price(&market_id, &Outcome::Yes), 5000 + impact); + } + + #[test] + #[should_panic(expected = "Error(Contract, #22)")] + fn band_trips_on_cumulative_moves_within_ledger() { + let (env, client, creator, trader, _, _) = setup_market_full( + 10_000, + Some(BreakerConfig { + max_impact_bps: 0, + band_bps: 500, + }), + ); + let market_id = new_market(&env, &client, &creator, 5000); + // First trade: ~470 bps from the ledger-open reference, inside band. + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + // Second trade moves ~420 bps on its own — each trade individually + // fits the band, but the cumulative move from the ledger's first + // price (~890 bps) does not. + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + } + + #[test] + fn band_resets_next_ledger() { + let (env, client, creator, trader, _, _) = setup_market_full( + 10_000, + Some(BreakerConfig { + max_impact_bps: 0, + band_bps: 500, + }), + ); + let market_id = new_market(&env, &client, &creator, 5000); + assert_eq!(client.breaker_state(&market_id), None); + + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let first_ledger_price = client.get_price(&market_id, &Outcome::Yes); + assert_eq!( + client.breaker_state(&market_id), + Some(BandState { + ledger_seq: env.ledger().sequence(), + ref_price_bps: 5000, + }) + ); + + // On the next ledger the band re-anchors to the current price, so the + // same second buy that trips within one ledger now passes. + env.ledger() + .set_sequence_number(env.ledger().sequence() + 1); + assert_eq!(client.breaker_state(&market_id), None); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + assert_eq!( + client.breaker_state(&market_id), + Some(BandState { + ledger_seq: env.ledger().sequence(), + ref_price_bps: first_ledger_price, + }) + ); + } + + #[test] + #[should_panic(expected = "Error(Contract, #22)")] + fn band_trips_on_downward_sell() { + let (env, client, creator, _, _, _) = setup_market_full( + 10_000, + Some(BreakerConfig { + max_impact_bps: 0, + band_bps: 500, + }), + ); + // Seeded at 68%: the creator holds the leftover YES shares and can + // sell enough to move the price down far more than the band. + let market_id = new_market(&env, &client, &creator, 6800); + client.sell( + &creator, + &market_id, + &Outcome::Yes, + &(20 * 10_000_000_i128), + &i128::MAX, + ); + } + + #[test] + #[should_panic(expected = "Error(Contract, #22)")] + fn band_trips_on_no_side_buy() { + let (env, client, creator, trader, _, _) = setup_market_full( + 10_000, + Some(BreakerConfig { + max_impact_bps: 0, + band_bps: 500, + }), + ); + let market_id = new_market(&env, &client, &creator, 5000); + // Buying NO moves YES's price down, so the band must trip on the + // absolute move regardless of which outcome is bought. + client.buy( + &trader, + &market_id, + &Outcome::No, + &(20 * 10_000_000_i128), + &0_i128, + ); + } + + #[test] + fn breaker_disabled_when_zero() { + let (env, client, creator, trader, _, _) = setup_market_full( + 10_000, + Some(BreakerConfig { + max_impact_bps: 0, + band_bps: 0, + }), + ); + let market_id = new_market(&env, &client, &creator, 5000); + // The snapshot exists but an all-zero config disables all checks. + assert_eq!( + client.breaker_config(&market_id), + Some(BreakerConfig { + max_impact_bps: 0, + band_bps: 0, + }) + ); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(20 * 10_000_000_i128), + &0_i128, + ); + } + + #[test] + fn legacy_market_without_breaker_unaffected() { + let (env, client, creator, trader, _, _) = setup_market_full( + 10_000, + Some(BreakerConfig { + max_impact_bps: 100, + band_bps: 0, + }), + ); + let market_id = new_market(&env, &client, &creator, 5000); + // Simulate a market created before breakers existed: no snapshot, so + // even trades that would trip the default are allowed. + remove_side_entry(&env, &client, DataKey::Breaker(market_id)); + assert_eq!(client.breaker_config(&market_id), None); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(20 * 10_000_000_i128), + &0_i128, + ); + } + + #[test] + fn set_default_breaker_rejects_invalid_configs() { + let (_env, client, _, _, _, _) = setup_market(); + // A band narrower than the impact cap could lock trading. + assert_eq!( + client.try_set_default_breaker(&BreakerConfig { + max_impact_bps: 200, + band_bps: 100, + }), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!( + client.try_set_default_breaker(&BreakerConfig { + max_impact_bps: 10_001, + band_bps: 0, + }), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!( + client.try_set_default_breaker(&BreakerConfig { + max_impact_bps: 100, + band_bps: 100, + }), + Ok(Ok(())) + ); + assert_eq!( + client.try_set_default_breaker(&BreakerConfig { + max_impact_bps: 0, + band_bps: 500, + }), + Ok(Ok(())) + ); + } + + #[test] + fn wrong_caller_cannot_set_default_breaker() { + let (env, client, _, trader, _, _) = setup_market(); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: &trader, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "set_default_breaker", + args: (BreakerConfig { + max_impact_bps: 100, + band_bps: 0, + },) + .into_val(&env), + sub_invokes: &[], + }, + }]) + .try_set_default_breaker(&BreakerConfig { + max_impact_bps: 100, + band_bps: 0, + }) + .is_err()); + } + + #[test] + fn would_trip_previews_impact_cap() { + let (env, client, creator, _, _, _) = setup_market_full( + 10_000, + Some(BreakerConfig { + max_impact_bps: 100, + band_bps: 0, + }), + ); + let market_id = new_market(&env, &client, &creator, 5000); + assert!(client.would_trip(&market_id, &Outcome::Yes, &(20 * 10_000_000_i128))); + assert!(!client.would_trip(&market_id, &Outcome::Yes, &(1_000_000_i128))); + // Read-only: previewing writes no band state. + assert_eq!(client.breaker_state(&market_id), None); + } + + // ----- #12: deadlines and limit prices ---------------------------------- + + #[test] + #[should_panic(expected = "Error(Contract, #19)")] + fn buy_v2_rejects_after_deadline() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + env.ledger().set_timestamp(1_000); + client.buy_v2( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + &0u32, + &999u64, + ); + } + + #[test] + #[should_panic(expected = "Error(Contract, #20)")] + fn buy_v2_rejects_worse_than_limit() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + let deadline = env.ledger().timestamp(); + // Average fill price incl. fee is ~5289 bps; limit 5000 must reject. + client.buy_v2( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + &5000u32, + &deadline, + ); + } + + #[test] + #[should_panic(expected = "Error(Contract, #20)")] + fn sell_v2_rejects_below_limit() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let deadline = env.ledger().timestamp(); + let max_shares = client.quote_sell(&market_id, &Outcome::Yes, &(5 * 10_000_000_i128)); + // Average execution price is ~5302 bps; limit 6000 must reject. + client.sell_v2( + &trader, + &market_id, + &Outcome::Yes, + &(5 * 10_000_000_i128), + &max_shares, + &6000u32, + &deadline, + ); + } + + #[test] + fn buy_v2_matches_buy_when_unbounded() { + let (env, client, creator, trader, _, _) = setup_market(); + let v2_market = new_market(&env, &client, &creator, 5000); + let legacy_market = new_market(&env, &client, &creator, 5000); + // deadline == now passes (only a strictly later timestamp expires), + // and limit 0 disables the price bound. + let deadline = env.ledger().timestamp(); + + let shares_v2 = client.buy_v2( + &trader, + &v2_market, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + &0u32, + &deadline, + ); + let shares_legacy = client.buy( + &trader, + &legacy_market, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + assert_eq!(shares_v2, shares_legacy); + assert_eq!( + client.get_price(&v2_market, &Outcome::Yes), + client.get_price(&legacy_market, &Outcome::Yes) + ); + + let out = 5 * 10_000_000_i128; + let max_v2 = client.quote_sell(&v2_market, &Outcome::Yes, &out); + let max_legacy = client.quote_sell(&legacy_market, &Outcome::Yes, &out); + let sold_v2 = client.sell_v2( + &trader, + &v2_market, + &Outcome::Yes, + &out, + &max_v2, + &0u32, + &deadline, + ); + let sold_legacy = client.sell(&trader, &legacy_market, &Outcome::Yes, &out, &max_legacy); + assert_eq!(sold_v2, sold_legacy); + assert_eq!( + client.get_price(&v2_market, &Outcome::Yes), + client.get_price(&legacy_market, &Outcome::Yes) + ); + } + + #[test] + #[should_panic(expected = "Error(Contract, #4)")] + fn buy_v2_rejects_limit_above_bps() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + let deadline = env.ledger().timestamp(); + client.buy_v2( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + &20_000u32, + &deadline, + ); + } + + // ----- #13: transferable positions and LP role -------------------------- + + #[test] + fn transfer_position_moves_shares_and_preserves_invariant() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + + let receiver = Address::generate(&env); + let moved = 100_000_000_i128; + client.transfer_position(&trader, &receiver, &market_id, &Outcome::Yes, &moved); + + let a = client.get_position(&market_id, &trader); + let b = client.get_position(&market_id, &receiver); + assert_eq!(b.yes, moved); + let m = client.get_market(&market_id); + // reserve_yes + Σ positions == sets_minted, always. + assert_eq!(m.reserve_yes + a.yes + b.yes, m.sets_minted); + + // Transfers spend no collateral, so the pause does not block them. + client.set_paused(&true); + client.transfer_position(&trader, &receiver, &market_id, &Outcome::Yes, &1_i128); + assert_eq!(client.get_position(&market_id, &receiver).yes, moved + 1); + } + + #[test] + fn transfer_position_removes_empty_entry() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let held = client.get_position(&market_id, &trader).yes; + + let receiver = Address::generate(&env); + client.transfer_position(&trader, &receiver, &market_id, &Outcome::Yes, &held); + assert_eq!( + client.get_position(&market_id, &trader), + Position { yes: 0, no: 0 } + ); + + let contract = client.address.clone(); + let raw = env.as_contract(&contract, || { + env.storage() + .persistent() + .get::(&DataKey::Position(market_id, trader.clone())) + }); + assert_eq!(raw, None); + } + + #[test] + #[should_panic(expected = "Error(Contract, #13)")] + fn transfer_position_rejects_insufficient_shares() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let held = client.get_position(&market_id, &trader).yes; + client.transfer_position( + &trader, + &Address::generate(&env), + &market_id, + &Outcome::Yes, + &(held + 1), + ); + } + + #[test] + fn transfer_position_rejects_invalid_destinations() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let receiver = Address::generate(&env); + assert_eq!( + client.try_transfer_position(&trader, &trader, &market_id, &Outcome::Yes, &1_i128), + Err(Ok(Error::InvalidAmount)) + ); + // The contract address would burn the shares: redeem needs the + // holder's auth, so shares held by the contract are unreachable. + assert_eq!( + client.try_transfer_position( + &trader, + &client.address, + &market_id, + &Outcome::Yes, + &1_i128 + ), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!( + client.try_transfer_position(&trader, &receiver, &market_id, &Outcome::Yes, &0_i128), + Err(Ok(Error::InvalidAmount)) + ); + } + + #[test] + fn wrong_caller_cannot_transfer_position() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + + let attacker = Address::generate(&env); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "transfer_position", + args: ( + trader.clone(), + attacker.clone(), + market_id, + Outcome::Yes, + 1_i128 + ) + .into_val(&env), + sub_invokes: &[], + }, + }]) + .try_transfer_position(&trader, &attacker, &market_id, &Outcome::Yes, &1_i128) + .is_err()); + } + + #[test] + fn transfer_lp_then_new_creator_claims() { + let (env, client, creator, trader, _, collateral) = setup_market_full(5000, None); + let market_id = new_market(&env, &client, &creator, 5000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + client.void_market(&market_id); + + let successor = Address::generate(&env); + client.transfer_lp(&market_id, &successor); + assert_eq!(client.get_market(&market_id).creator, successor); + + let m = client.get_market(&market_id); + let expected_pool = (m.reserve_yes + m.reserve_no) / 2; + let lp_fees = lp_fees_stored(&env, &client, market_id); + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let before = token.balance(&successor); + let payout = client.claim_pool_remainder(&market_id); + assert_eq!(payout, expected_pool + lp_fees); + assert_eq!(token.balance(&successor), before + payout); + } + + #[test] + fn wrong_caller_cannot_transfer_lp() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + let attacker = Address::generate(&env); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "transfer_lp", + args: (market_id, attacker.clone()).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_transfer_lp(&market_id, &attacker) + .is_err()); + } + + #[test] + #[should_panic(expected = "Error(Contract, #17)")] + fn transfer_lp_rejected_after_claim() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = new_market(&env, &client, &creator, 5000); + client.void_market(&market_id); + client.claim_pool_remainder(&market_id); + client.transfer_lp(&market_id, &Address::generate(&env)); + } } diff --git a/oracledesk-stellar/contracts/market-core/src/math.rs b/oracledesk-stellar/contracts/market-core/src/math.rs index 332ce36..93e44c3 100644 --- a/oracledesk-stellar/contracts/market-core/src/math.rs +++ b/oracledesk-stellar/contracts/market-core/src/math.rs @@ -95,6 +95,22 @@ pub fn price_yes_bps(reserve_yes: i128, reserve_no: i128) -> Result Result { + if outcome_is_yes { + price_yes_bps(r_after, r_other_after) + } else { + price_yes_bps(r_other_after, r_after) + } +} + /// 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 { @@ -193,6 +209,50 @@ pub fn calc_sell( }) } +/// True when a buy's average execution price `collateral_in / shares_out` +/// (fees included) is at most `limit_bps` per share (1.0 = BPS). +/// Compared by cross-multiplication so division rounding cannot flip the +/// decision; exact equality passes. `shares_out` comes from `calc_buy`, so +/// it is always > 0 in practice. +pub fn buy_within_limit( + collateral_in: i128, + shares_out: i128, + limit_bps: u32, +) -> Result { + if collateral_in < 0 || shares_out <= 0 { + return Err(MathError::InvalidInput); + } + Ok(mul(collateral_in, BPS)? <= mul(shares_out, limit_bps as i128)?) +} + +/// True when a sell's average execution price `collateral_out / shares_in` +/// is at least `limit_bps` per share. Exact equality passes; a zero limit is +/// trivially satisfied (`collateral_out >= 0`), which buys the caller no +/// protection — see `sell_v2`'s docs. +pub fn sell_within_limit( + collateral_out: i128, + shares_in: i128, + limit_bps: u32, +) -> Result { + if collateral_out < 0 || shares_in <= 0 { + return Err(MathError::InvalidInput); + } + Ok(mul(collateral_out, BPS)? >= mul(shares_in, limit_bps as i128)?) +} + +/// Splits a gross fee between the protocol and the LP. +/// +/// The protocol portion rounds **up** so `protocol + lp == fee` exactly (no +/// rounding leakage): the LP absorbs at most one sub-unit of dust, and the +/// default `protocol_share_bps = BPS` is exact (all protocol, 0 to the LP). +pub fn split_fee(fee: i128, protocol_share_bps: u32) -> Result<(i128, i128), MathError> { + if fee < 0 || protocol_share_bps as i128 > BPS { + return Err(MathError::InvalidInput); + } + let protocol = mul_div_ceil(fee, protocol_share_bps as i128, BPS)?; + Ok((protocol, fee - protocol)) +} + /// Collateral value of a (yes, no) share pair once a market is final. /// Void pays 0.5 per share, which keeps total payouts <= sets minted. pub fn settle_value(kind: Settlement, yes: i128, no: i128) -> i128 { @@ -265,4 +325,67 @@ mod tests { assert_eq!(settle_value(Settlement::Void, 3, 4), 3); assert!(settle_value(Settlement::Void, 5, 5) <= 5); } + + #[test] + fn split_fee_sums_exactly() { + let mut x: u64 = 0x9E37_79B9_7F4A_7C15; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (x >> 33) as i128 + }; + for _ in 0..2_000 { + let fee = next() % 1_000_000_000_000; + let share = (next() % (BPS + 1)) as u32; + let (protocol, lp) = split_fee(fee, share).unwrap(); + assert_eq!(protocol + lp, fee, "fee={fee} share={share}"); + assert!(protocol >= 0 && lp >= 0); + // protocol rounds up: never below the exact proportional share + assert!(protocol * BPS >= fee * share as i128); + } + } + + #[test] + fn split_fee_default_is_all_protocol() { + assert_eq!(split_fee(12_345, 10_000).unwrap(), (12_345, 0)); + assert_eq!(split_fee(12_345, 0).unwrap(), (0, 12_345)); + assert_eq!(split_fee(0, 3_333).unwrap(), (0, 0)); + } + + #[test] + fn buy_within_limit_boundaries() { + // avg price exactly 1.0: passing costs the full limit, passes + assert!(buy_within_limit(100, 100, 10_000).unwrap()); + // cheaper than the limit + assert!(buy_within_limit(100, 101, 10_000).unwrap()); + // one unit above the limit fails + assert!(!buy_within_limit(101, 100, 10_000).unwrap()); + // 100 in -> 200 shares = 0.5 per share, exactly at 5_000 bps + assert!(buy_within_limit(100, 200, 5_000).unwrap()); + assert!(!buy_within_limit(101, 200, 5_000).unwrap()); + } + + #[test] + fn sell_within_limit_boundaries() { + assert!(sell_within_limit(100, 100, 10_000).unwrap()); + assert!(!sell_within_limit(99, 100, 10_000).unwrap()); + // 100 out from 200 shares = 0.5 per share, exactly at 5_000 bps + assert!(sell_within_limit(100, 200, 5_000).unwrap()); + assert!(!sell_within_limit(100, 201, 5_000).unwrap()); + // a zero limit is trivially satisfied + assert!(sell_within_limit(0, 1, 0).unwrap()); + } + + #[test] + fn price_after_maps_both_sides() { + assert_eq!(price_after(true, 1_000, 1_000).unwrap(), 5_000); + assert_eq!(price_after(false, 1_000, 1_000).unwrap(), 5_000); + // NO buy pushing NO reserve to 910 with 1100 YES: YES price is + // 1100 / (1100 + 910), i.e. price_yes_bps(yes, no) + assert_eq!( + price_after(false, 910, 1_100).unwrap(), + price_yes_bps(1_100, 910).unwrap() + ); + } } diff --git a/oracledesk-stellar/contracts/resolver/market_core.wasm b/oracledesk-stellar/contracts/resolver/market_core.wasm index cdf8718..4e6bdb1 100644 Binary files a/oracledesk-stellar/contracts/resolver/market_core.wasm and b/oracledesk-stellar/contracts/resolver/market_core.wasm differ diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index 211cfba..4d69343 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -538,6 +538,8 @@ mod test { treasury.clone(), resolver_id.clone(), 100u32, + // Protocol keeps the whole fee; resolver tests do no splits. + 10_000u32, ), ); let market_core = MarketCoreClient::new(&env, &market_core_id); diff --git a/oracledesk-stellar/contracts/treasury/market_core.wasm b/oracledesk-stellar/contracts/treasury/market_core.wasm index cdf8718..4e6bdb1 100644 Binary files a/oracledesk-stellar/contracts/treasury/market_core.wasm and b/oracledesk-stellar/contracts/treasury/market_core.wasm differ diff --git a/oracledesk-stellar/contracts/treasury/src/lib.rs b/oracledesk-stellar/contracts/treasury/src/lib.rs index 21f1276..e65c058 100644 --- a/oracledesk-stellar/contracts/treasury/src/lib.rs +++ b/oracledesk-stellar/contracts/treasury/src/lib.rs @@ -52,6 +52,7 @@ pub enum DataKey { Market(u64), Daily, Paused, + Successor, } #[contracterror] @@ -68,6 +69,7 @@ pub enum Error { MarketNotOpen = 8, InsufficientBalance = 9, Paused = 10, + NoSuccessor = 11, } #[contractevent] @@ -98,6 +100,25 @@ pub struct FeesCollected { pub amount: i128, } +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SuccessorSet { + pub old: Option
, + pub new: Address, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MarketMigrated { + #[topic] + pub market_id: u64, + #[topic] + pub successor: Address, + pub yes: i128, + pub no: i128, + pub lp: bool, +} + #[contract] pub struct Treasury; @@ -243,6 +264,41 @@ impl Treasury { outcome: Outcome, collateral_in: i128, min_shares_out: i128, + ) -> Result { + Self::do_agent_buy(env, market_id, outcome, collateral_in, min_shares_out, None) + } + + /// `agent_buy` with a per-share limit price (`limit_price_bps`, 0 = none) + /// and a deadline (unix seconds, network time). market-core's `buy_v2` + /// enforces both; an expired or out-of-limit trade reverts the whole + /// invocation, so the treasury's caps are never consumed by a reverted + /// trade. + pub fn agent_buy_v2( + env: Env, + market_id: u64, + outcome: Outcome, + collateral_in: i128, + min_shares_out: i128, + limit_price_bps: u32, + deadline: u64, + ) -> Result { + Self::do_agent_buy( + env, + market_id, + outcome, + collateral_in, + min_shares_out, + Some((limit_price_bps, deadline)), + ) + } + + fn do_agent_buy( + env: Env, + market_id: u64, + outcome: Outcome, + collateral_in: i128, + min_shares_out: i128, + bound: Option<(u32, u64)>, ) -> Result { Self::assert_not_paused(&env)?; let config = Self::get_config(env.clone())?; @@ -266,13 +322,25 @@ impl Treasury { // `pitfall_agent_buy_fails_without_nested_transfer_preauth` below and // `docs/adr/0002-treasury-nested-auth.md`. Self::authorize_token_pull(&env, &config, &treasury_addr, collateral_in); - let shares = MarketCoreClient::new(&env, &config.market_core).buy( - &treasury_addr, - &market_id, - &outcome, - &collateral_in, - &min_shares_out, - ); + let client = MarketCoreClient::new(&env, &config.market_core); + let shares = match bound { + Some((limit_price_bps, deadline)) => client.buy_v2( + &treasury_addr, + &market_id, + &outcome, + &collateral_in, + &min_shares_out, + &limit_price_bps, + &deadline, + ), + None => client.buy( + &treasury_addr, + &market_id, + &outcome, + &collateral_in, + &min_shares_out, + ), + }; Self::reserve_capacity(&env, &config, market_id, collateral_in)?; Ok(shares) } @@ -287,18 +355,63 @@ impl Treasury { outcome: Outcome, collateral_out: i128, max_shares_in: i128, + ) -> Result { + Self::do_agent_sell(env, market_id, outcome, collateral_out, max_shares_in, None) + } + + /// `agent_sell` with a per-share limit price (`limit_price_bps`, 0 = no + /// limit) and a deadline (unix seconds, network time), both enforced by + /// market-core's `sell_v2`. + pub fn agent_sell_v2( + env: Env, + market_id: u64, + outcome: Outcome, + collateral_out: i128, + max_shares_in: i128, + limit_price_bps: u32, + deadline: u64, + ) -> Result { + Self::do_agent_sell( + env, + market_id, + outcome, + collateral_out, + max_shares_in, + Some((limit_price_bps, deadline)), + ) + } + + fn do_agent_sell( + env: Env, + market_id: u64, + outcome: Outcome, + collateral_out: i128, + max_shares_in: i128, + bound: Option<(u32, u64)>, ) -> Result { Self::assert_not_paused(&env)?; let config = Self::get_config(env.clone())?; config.agent.require_auth(); let treasury_addr = env.current_contract_address(); - Ok(MarketCoreClient::new(&env, &config.market_core).sell( - &treasury_addr, - &market_id, - &outcome, - &collateral_out, - &max_shares_in, - )) + let client = MarketCoreClient::new(&env, &config.market_core); + Ok(match bound { + Some((limit_price_bps, deadline)) => client.sell_v2( + &treasury_addr, + &market_id, + &outcome, + &collateral_out, + &max_shares_in, + &limit_price_bps, + &deadline, + ), + None => client.sell( + &treasury_addr, + &market_id, + &outcome, + &collateral_out, + &max_shares_in, + ), + }) } /// Marks a market's exposure as exited. Pure bookkeeping (see @@ -333,7 +446,21 @@ impl Treasury { Ok(MarketCoreClient::new(&env, &config.market_core).claim_pool_remainder(&market_id)) } - /// Pulls accrued fees from a configured market-core contract. + /// Claims the market's LP fee share for a market the treasury created. + /// The LP portion accrues separately on market-core (see its `claim_lp_fees`); + /// this is that claim, agent-gated like the other trading entrypoints. + pub fn agent_claim_lp_fees(env: Env, market_id: u64) -> Result { + let config = Self::get_config(env.clone())?; + config.agent.require_auth(); + Ok(MarketCoreClient::new(&env, &config.market_core).claim_lp_fees(&market_id)) + } + + /// Pulls accrued **protocol** fees from a configured market-core contract. + /// + /// Only the protocol's share of each market's fees: the LP portion accrues + /// separately on market-core and belongs to the market's creator — for + /// agent-created markets that is the treasury itself, via + /// `agent_claim_lp_fees`. pub fn collect_market_fees(env: Env, market_id: u64) -> Result { Self::require_admin(&env)?; let config = Self::get_config(env.clone())?; @@ -341,6 +468,85 @@ impl Treasury { FeesCollected { market_id, amount }.publish(&env); Ok(amount) } + + // ----- migration --------------------------------------------------------- + + /// Sets the successor treasury that `migrate_market` hands positions and + /// the LP role to. Admin-only; overwrites any previous successor (the + /// `SuccessorSet` events are the audit trail). + pub fn set_successor(env: Env, successor: Address) -> Result<(), Error> { + Self::require_admin(&env)?; + let old: Option
= env.storage().instance().get(&DataKey::Successor); + env.storage() + .instance() + .set(&DataKey::Successor, &successor); + SuccessorSet { + old, + new: successor, + } + .publish(&env); + Ok(()) + } + + /// Moves the treasury's position in `market_id` — and the LP role, when + /// the treasury is the market's creator and has not claimed the remainder + /// yet — to the successor set via `set_successor`, then closes the local + /// exposure. Admin-only: relocating capital is not an agent trading + /// action, and the agent key must not be able to drain positions. + /// + /// market-core's `transfer_position` / `transfer_lp` require the treasury + /// itself to authorize. Because the treasury is their *direct* caller + /// here, the implicit invoker authorization covers both — no + /// `authorize_as_current_contract` entry is needed (unlike the nested + /// token transfer in `agent_buy`, see `docs/adr/0002-treasury-nested-auth.md`). + pub fn migrate_market(env: Env, market_id: u64) -> Result<(), Error> { + Self::require_admin(&env)?; + let successor: Address = env + .storage() + .instance() + .get(&DataKey::Successor) + .ok_or(Error::NoSuccessor)?; + let config = Self::get_config(env.clone())?; + let treasury_addr = env.current_contract_address(); + let client = MarketCoreClient::new(&env, &config.market_core); + + let position = client.get_position(&market_id, &treasury_addr); + if position.yes > 0 { + client.transfer_position( + &treasury_addr, + &successor, + &market_id, + &Outcome::Yes, + &position.yes, + ); + } + if position.no > 0 { + client.transfer_position( + &treasury_addr, + &successor, + &market_id, + &Outcome::No, + &position.no, + ); + } + let market = client.get_market(&market_id); + let lp = market.creator == treasury_addr && !market.lp_claimed; + if lp { + client.transfer_lp(&market_id, &successor); + } + let mut exposure = Self::market_exposure(&env, market_id); + exposure.open = false; + Self::save_market(&env, market_id, &exposure); + MarketMigrated { + market_id, + successor, + yes: position.yes, + no: position.no, + lp, + } + .publish(&env); + Ok(()) + } } impl Treasury { @@ -480,6 +686,7 @@ mod test { struct Fixture { env: Env, + admin: Address, treasury: TreasuryClient<'static>, treasury_id: Address, token: Address, @@ -518,6 +725,9 @@ mod test { treasury_id.clone(), Address::generate(&env), 100u32, + // Protocol keeps the whole fee; treasury tests only exercise + // protocol-side sweeps. + 10_000u32, ), ); let market_core = MarketCoreClient::new(&env, &market_core_id); @@ -546,6 +756,7 @@ mod test { ); Fixture { env: env.clone(), + admin, treasury: TreasuryClient::new(&env, &treasury), treasury_id, token: collateral.address(), @@ -803,4 +1014,350 @@ mod test { .agent_buy(&market_id, &outcome, &amount, &min_out); assert!(shares > 0); } + + #[test] + fn agent_claims_lp_fees_for_own_market() { + let f = fixture_with_caps( + 150 * 10_000_000_i128, + 300 * 10_000_000_i128, + 300 * 10_000_000_i128, + ); + // New markets snapshot the split at creation; give the creator half. + f.market_core.set_protocol_fee_share_bps(&5000u32); + let market_id = f.treasury.agent_create_market( + &BytesN::from_array(&f.env, &[15u8; 32]), + &BytesN::from_array(&f.env, &[115u8; 32]), + &String::from_str(&f.env, "ipfs://lp-fees"), + &Category::Macro, + &MARKET_CLOSE, + &(100 * 10_000_000_i128), + &5000u32, + ); + let trader = Address::generate(&f.env); + StellarAssetClient::new(&f.env, &f.token).mint(&trader, &(100 * 10_000_000_i128)); + f.market_core.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + + // Fee is 1_000_000, split 50/50 between protocol and the LP. + let token = soroban_sdk::token::TokenClient::new(&f.env, &f.token); + let before = token.balance(&f.treasury_id); + assert_eq!(f.treasury.agent_claim_lp_fees(&market_id), 500_000); + assert_eq!(token.balance(&f.treasury_id), before + 500_000); + assert_eq!(f.treasury.agent_claim_lp_fees(&market_id), 0); + } + + #[test] + fn agent_buy_breaker_revert_leaves_caps_unchanged() { + let f = fixture(); + f.market_core + .set_default_breaker(&market_core::BreakerConfig { + max_impact_bps: 10, + band_bps: 0, + }); + let creator = f.market_core.get_market(&f.market_id).creator; + let market_id = f.market_core.create_market( + &creator, + &BytesN::from_array(&f.env, &[16u8; 32]), + &BytesN::from_array(&f.env, &[116u8; 32]), + &String::from_str(&f.env, "ipfs://breaker-market"), + &Category::Crypto, + &MARKET_CLOSE, + &(100 * 10_000_000_i128), + &5000u32, + ); + + // 0.1 USDC moves the price ~4 bps: inside the 10 bps impact cap. + f.treasury + .agent_buy(&market_id, &Outcome::Yes, &(1_000_000_i128), &0); + assert_eq!(f.treasury.daily_usage().amount, 1_000_000); + + // 20 USDC moves it ~893 bps: market-core trips the breaker, which + // reverts the whole treasury call — the caps must not absorb it. + assert!(f + .treasury + .try_agent_buy(&market_id, &Outcome::Yes, &(20 * 10_000_000_i128), &0) + .is_err()); + assert_eq!(f.treasury.daily_usage().amount, 1_000_000); + let exposure = f.env.as_contract(&f.treasury_id, || { + f.env + .storage() + .persistent() + .get::(&DataKey::Market(market_id)) + .unwrap() + }); + assert!(exposure.open); + assert_eq!(exposure.amount, 1_000_000); + } + + #[test] + fn agent_buy_v2_forwards_bounds() { + let f = fixture(); + let now = f.env.ledger().timestamp(); + // Expired deadline. + assert!(f + .treasury + .try_agent_buy_v2( + &f.market_id, + &Outcome::Yes, + &(10_000_000_i128), + &0, + &0u32, + &(now - 1), + ) + .is_err()); + // Average fill for 20 USDC is ~5505 bps; limit 5000 must reject. + assert!(f + .treasury + .try_agent_buy_v2( + &f.market_id, + &Outcome::Yes, + &(20 * 10_000_000_i128), + &0, + &5000u32, + &(now + 60), + ) + .is_err()); + assert_eq!(f.treasury.daily_usage().amount, 0); + + // Generous bounds: succeeds and consumes the caps like agent_buy. + let shares = f.treasury.agent_buy_v2( + &f.market_id, + &Outcome::Yes, + &(20 * 10_000_000_i128), + &0, + &6000u32, + &(now + 60), + ); + assert!(shares > 0); + assert_eq!(f.treasury.daily_usage().amount, 20 * 10_000_000_i128); + + // The sell side forwards its bounds too. + let quote = f + .market_core + .quote_sell(&f.market_id, &Outcome::Yes, &(5 * 10_000_000_i128)); + let sold = f.treasury.agent_sell_v2( + &f.market_id, + &Outcome::Yes, + &(5 * 10_000_000_i128), + "e, + &0u32, + &(now + 60), + ); + assert!(sold > 0); + assert!(f + .treasury + .try_agent_sell_v2( + &f.market_id, + &Outcome::Yes, + &(5 * 10_000_000_i128), + "e, + &0u32, + &(now - 1), + ) + .is_err()); + } + + #[test] + fn agent_buy_v2_deadline_revert_leaves_caps_unchanged() { + let f = fixture(); + let past = f.env.ledger().timestamp() - 1; + assert!(f + .treasury + .try_agent_buy_v2( + &f.market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0, + &0u32, + &past, + ) + .is_err()); + assert_eq!(f.treasury.daily_usage().amount, 0); + let exposure = f.env.as_contract(&f.treasury_id, || { + f.env + .storage() + .persistent() + .get::(&DataKey::Market(f.market_id)) + }); + assert_eq!(exposure, None); + } + + /// The v2 twin of the pitfall test above: the `buy_v2` path must also + /// pre-authorize the nested token transfer (`mock_auths` for only the + /// agent, so a regression in `authorize_token_pull` fails here). + #[test] + fn pitfall_agent_buy_v2_forwards_nested_transfer_auth() { + let f = fixture(); + let agent = f.treasury.get_config().agent; + let market_id = f.market_id; + let outcome = Outcome::Yes; + let amount = 20 * 10_000_000_i128; + let min_out = 0_i128; + let limit = 0u32; + let deadline = f.env.ledger().timestamp() + 60; + + f.env.set_auths(&[]); + let shares = f + .treasury + .mock_auths(&[MockAuth { + address: &agent, + invoke: &MockAuthInvoke { + contract: &f.treasury_id, + fn_name: "agent_buy_v2", + args: (market_id, outcome.clone(), amount, min_out, limit, deadline) + .into_val(&f.env), + sub_invokes: &[], + }, + }]) + .agent_buy_v2(&market_id, &outcome, &amount, &min_out, &limit, &deadline); + assert!(shares > 0); + } + + #[test] + fn migrate_market_moves_position_and_lp_to_successor() { + let f = fixture_with_caps( + 150 * 10_000_000_i128, + 300 * 10_000_000_i128, + 300 * 10_000_000_i128, + ); + let market_id = f.treasury.agent_create_market( + &BytesN::from_array(&f.env, &[17u8; 32]), + &BytesN::from_array(&f.env, &[117u8; 32]), + &String::from_str(&f.env, "ipfs://migration"), + &Category::Macro, + &MARKET_CLOSE, + &(100 * 10_000_000_i128), + &5000u32, + ); + f.treasury + .agent_buy(&market_id, &Outcome::Yes, &(20 * 10_000_000_i128), &0); + let held = f.market_core.get_position(&market_id, &f.treasury_id).yes; + assert!(held > 0); + assert_eq!(f.market_core.get_market(&market_id).creator, f.treasury_id); + + let successor = Address::generate(&f.env); + f.treasury.set_successor(&successor); + f.treasury.migrate_market(&market_id); + + assert_eq!( + f.market_core.get_position(&market_id, &f.treasury_id).yes, + 0 + ); + assert_eq!(f.market_core.get_position(&market_id, &successor).yes, held); + assert_eq!(f.market_core.get_market(&market_id).creator, successor); + let exposure = f.env.as_contract(&f.treasury_id, || { + f.env + .storage() + .persistent() + .get::(&DataKey::Market(market_id)) + .unwrap() + }); + assert!(!exposure.open); + // Migration does not free the cumulative cap (like close_trade). + assert_eq!(exposure.amount, 120 * 10_000_000_i128); + } + + #[test] + fn migrate_requires_successor() { + let f = fixture(); + assert_eq!( + f.treasury.try_migrate_market(&f.market_id), + Err(Ok(Error::NoSuccessor)) + ); + } + + #[test] + fn agent_cannot_migrate() { + let f = fixture(); + let agent = f.treasury.get_config().agent; + f.env.set_auths(&[]); + assert!(f + .treasury + .mock_auths(&[MockAuth { + address: &agent, + invoke: &MockAuthInvoke { + contract: &f.treasury_id, + fn_name: "migrate_market", + args: (f.market_id,).into_val(&f.env), + sub_invokes: &[], + }, + }]) + .try_migrate_market(&f.market_id) + .is_err()); + } + + /// The treasury's calls to market-core's `transfer_position` / + /// `transfer_lp` are *direct* calls, so market-core's `require_auth` + /// on the treasury is satisfied implicitly by the invoker rule — no + /// `authorize_as_current_contract` entry is needed (unlike the nested + /// token transfer in `agent_buy`). Mocking only the admin's auth must be + /// enough for the whole migration. + #[test] + fn pitfall_migrate_market_needs_only_admin_auth() { + let f = fixture_with_caps( + 150 * 10_000_000_i128, + 300 * 10_000_000_i128, + 300 * 10_000_000_i128, + ); + let market_id = f.treasury.agent_create_market( + &BytesN::from_array(&f.env, &[18u8; 32]), + &BytesN::from_array(&f.env, &[118u8; 32]), + &String::from_str(&f.env, "ipfs://migration-auth"), + &Category::Macro, + &MARKET_CLOSE, + &(100 * 10_000_000_i128), + &5000u32, + ); + f.treasury + .agent_buy(&market_id, &Outcome::Yes, &(20 * 10_000_000_i128), &0); + let successor = Address::generate(&f.env); + f.treasury.set_successor(&successor); + let held = f.market_core.get_position(&market_id, &f.treasury_id).yes; + + f.env.set_auths(&[]); + f.treasury + .mock_auths(&[MockAuth { + address: &f.admin, + invoke: &MockAuthInvoke { + contract: &f.treasury_id, + fn_name: "migrate_market", + args: (market_id,).into_val(&f.env), + sub_invokes: &[], + }, + }]) + .migrate_market(&market_id); + + assert_eq!(f.market_core.get_position(&market_id, &successor).yes, held); + assert_eq!(f.market_core.get_market(&market_id).creator, successor); + } + + #[test] + fn migrate_then_successor_redeems_after_resolution() { + let f = fixture(); + f.treasury + .agent_buy(&f.market_id, &Outcome::Yes, &(20 * 10_000_000_i128), &0); + let successor = Address::generate(&f.env); + f.treasury.set_successor(&successor); + + f.env.ledger().set_timestamp(MARKET_CLOSE + 1); + f.market_core.resolve(&f.market_id, &Outcome::Yes); + f.treasury.migrate_market(&f.market_id); + + // The successor (not the treasury) now redeems the migrated shares. + let payout = f.market_core.redeem(&successor, &f.market_id); + assert!(payout > 0); + assert_eq!( + soroban_sdk::token::TokenClient::new(&f.env, &f.token).balance(&successor), + payout + ); + assert_eq!( + f.market_core.get_position(&f.market_id, &f.treasury_id).yes, + 0 + ); + } } diff --git a/oracledesk-stellar/docs/STATUS.md b/oracledesk-stellar/docs/STATUS.md index 4adba1b..9261ef6 100644 --- a/oracledesk-stellar/docs/STATUS.md +++ b/oracledesk-stellar/docs/STATUS.md @@ -1,9 +1,27 @@ # Status -Last updated: 2026-09-21. This is the honest account of what works, what's +Last updated: 2026-09-21 (contributor addendum 2026-10-16 — see the first +section after this intro). This is the honest account of what works, what's unverified, and what's left. Everything marked "verified" was demonstrated with real command output in the session that wrote this file, not asserted. +## Contributor addendum: 2026-10-16 + +Fee split + recipient snapshot (#8), per-market circuit breakers (#10), +trade deadlines and limit prices (#12), and transferable positions/LP plus +treasury migration (#13) are implemented on +`feat/fees-breakers-bounds-and-transfers` (PR against `develop`; design +notes in `docs/architecture.md`). Verified in the contributor session: +`cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings` +and `cargo test --workspace` (now **87** tests: market-core 54, resolver 6, +reasoning-registry 6, treasury 21 — superseding the 40-test count below), +`python3 model/fpmm_model.py`, agents (36 tests) and x402 (5 tests) with the +regenerated bindings, `make build` and `make bindings` with only the +intended diffs. **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. + ## Phase 0: recon and safety **Toolchain** (as installed in the environment this was built in): diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index 045e342..688ab8d 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -19,15 +19,82 @@ natively unit-testable and mirrored line-for-line in **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`, and `reserve_yes + Σ(user yes -positions) == sets_minted` (symmetrically for `no`). +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`). + +**Fee split and recipient snapshot.** `create_market` snapshots a +`FeeTerms { recipient, protocol_share_bps }` side entry per market, so +`set_treasury` / `set_protocol_fee_share_bps` only affect markets created +after them. Each trade's fee is split once at accrual time +(`math::split_fee`, protocol shares round up so `protocol + lp == fee` +exactly): the protocol portion accumulates in `Market.fees_accrued` and is +swept to the snapshotted recipient by the permissionless `sweep_fees` +(old `FeesSwept` event kept for indexers, plus `FeesSweptTo`); the creator +portion accumulates in a `LpFees(market_id)` side entry, claimable any time +(before finality too) via `claim_lp_fees`, and also drained by +`claim_pool_remainder`. Markets created before this change have no +`FeeTerms` entry and fall back to the old behavior: recipient = whatever +`DataKey::Treasury` holds at sweep time, `protocol_share_bps = 10_000` — +fully backward compatible, no migration transaction needed. The separate +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)). + +**Trade bounds and circuit breakers.** Two layers of protection beyond the +share-count slippage parameters: + +- *Deadline + limit price* (#12): `buy_v2` / `sell_v2` take a + `deadline` (ledger **timestamp**, unix seconds) checked before anything + else, and a `limit_price_bps` bounding the **average execution price** + (`collateral_in / shares_out` for buys, `collateral_out / shares_in` for + sells, fee included, compared by cross-multiplication). `0` means "no + limit" for buys; for sells it is trivially satisfied and buys no + protection. The original `buy` / `sell` remain as thin wrappers for + backward compatibility (same body, no bounds). +- *Per-market breakers* (#10): `BreakerConfig { max_impact_bps, band_bps }` + is snapshotted per market from an admin default (`set_default_breaker`, + which rejects `band_bps < max_impact_bps` because that makes one trade + cover the whole band). `max_impact_bps` bounds a single trade's + |post − pre| YES-price move (`>` — a trade moving exactly the cap + passes); `band_bps` bounds |post − ledger-open price|, with the reference + stored in short-TTL **temporary** storage keyed by ledger sequence, so it + resets by itself every ledger. 0 disables either check; legacy markets + have no config and are unaffected. Breaker trips revert with + `PriceImpactTooHigh` / `PriceBandExceeded` and — because Soroban rolls + back an invocation's events on `Err` — emit **no** event (the error codes + and the `would_trip` / `breaker_config` / `breaker_state` views are the + observability path; see the comment on `check_breakers`). + + *Worst-case lock cost.* The band cannot be ratcheted: the reference is + the ledger-open price, so an attacker who pushes the price to the band + edge only blocks further trades for the rest of that ledger (~5 s) — + and pays the trading fee plus price impact to do it, which arbitrageurs + can take back the next ledger. The breakers cannot lock a market across + ledgers by construction. + +**Transferability.** `transfer_position(from, to, market_id, outcome, +amount)` moves shares between any two addresses (owner auth; allowed while +`Paused` — a transfer moves no collateral — and after finality until +`redeem`), rejecting self-transfers, transfers to the contract's own +address (they could never be redeemed) and over-transfers, and emitting +`PositionTransferred` so indexers stay in sync. `transfer_lp(market_id, +new_creator)` (current-creator auth, until `lp_claimed`) moves the single +LP role — the right to `claim_pool_remainder` and `claim_lp_fees` — and +emits `LpTransferred`. Both preserve the solvency invariant by +construction; the position mapping is unchanged down to the "remove +zero entries" rule. **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) → `resolve` or -`void_market` (resolver-only) → `redeem` per holder, `claim_pool_remainder` -for the creator/LP, `sweep_fees` (permissionless) to the treasury. +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` / +`transfer_lp`. ### resolver @@ -53,12 +120,51 @@ off. ### treasury Holds the agents' pooled USDC and is the *only* path that spends it: an -agent calls `agent_create_market` / `agent_buy` / `agent_sell`, the treasury -checks its own per-trade/per-market/daily caps, 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. +agent calls `agent_create_market` / `agent_buy` / `agent_sell` / +`agent_buy_v2` / `agent_sell_v2`, the treasury checks its own +per-trade/per-market/daily caps, 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 +market-core's deadline / limit-price bounds, so a revert from either layer +leaves the caps unchanged (the whole invocation is atomic). The treasury's +creator-side claims are split the same way the fees are: `collect_market_fees` +sweeps the **protocol portion** to the treasury, while `agent_claim_lp_fees` +claims the LP portion for markets the treasury created. + +**Migration to a successor treasury.** The treasury has no upgrade path or +config setters by design, so redeploying it (bug fix, cap change, agent +rotation) means deploying a new contract. `set_successor(successor)` +(admin) records where value may go and emits `SuccessorSet`; then +`migrate_market(market_id)` (admin, **not** agent) moves the treasury's +YES/NO position and, if it is still the LP, its LP ownership for one market +to the successor — market by market, so the footprint stays bounded — and +emits `MarketMigrated`. The auth model: + +- The agent has no path: both migration entrypoints are `require_admin`. +- The destination is fixed to the configured `Successor` — `migrate_market` + takes no destination argument, so even a compromised agent key cannot + redirect value, and a compromised admin gains no new power over *user* + funds (it could already `withdraw`; migration only moves + treasury-owned positions, and the successor is visible on-chain via + `SuccessorSet`). +- The treasury's calls to market-core's `transfer_position` / `transfer_lp` + are *direct* calls, so market-core's `require_auth` on the treasury's own + address is satisfied implicitly by the invoker rule — no + `authorize_as_current_contract` entry is needed (unlike the nested token + call in `authorize_token_pull`). This is proven by the + `pitfall_migrate_market_needs_only_admin_auth` test, which permits only + the admin's signature and no sub-invocations. + +A migrated market's `MarketExposure` on the successor starts at zero and the +old treasury's entry is closed (`open = false`, amount preserved), so the +successor's caps apply to its own future activity — acceptable because the +cap is a per-deployment risk bound, not a market-level accounting value. +Successor-side acceptance (`accept_migration`) is deliberately not +implemented: it would record provenance only, and the events plus the +successor's own exposure records are enough for an off-chain dashboard +([wave-issue 08](wave-issues/08-treasury-exposure-dashboard.md)). The nested-authorization pitfall this design implies, and how it's handled, is documented at length in `contracts/treasury/src/lib.rs` (see @@ -89,9 +195,13 @@ one of the seeded `docs/wave-issues/`. IPFS or `reasoning-registry`'s configured store), recomputes its hash, and serves it only if the hash matches the on-chain record — see `x402/registry-trace-repository.ts`. -- **agents/**: `core/` is chain-agnostic (FPMM math, trace building, - strategy decisions); `stellar/` is the only part that knows about Soroban, - wrapping the generated bindings with a dry-run/live switch. +- **agents/**: `core/` is chain-agnostic (FPMM math including the + price-impact quoting used to size trades under a breaker cap, trace + building, strategy decisions with an optional impact clamp); `stellar/` + 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. - **packages/bindings/**: generated TypeScript clients (`scripts/gen-bindings.sh`). ## Known limitations (see docs/STATUS.md for the full, current list) diff --git a/oracledesk-stellar/docs/frontend-integration.md b/oracledesk-stellar/docs/frontend-integration.md index 63007e5..e1faebf 100644 --- a/oracledesk-stellar/docs/frontend-integration.md +++ b/oracledesk-stellar/docs/frontend-integration.md @@ -47,11 +47,16 @@ const tx = await contract.buy({ market_id: 0n, outcome: { tag: "Yes", values: undefined }, collateral_in: 10_000_000n, // 1 USDC, 7 decimals - min_shares_out: 0n, // see slippage note below + min_shares_out: 9_500_000n, // quote minus your slippage margin — see below }); const { result } = await tx.signAndSend({ signTransaction }); ``` +For anything user-facing, prefer `buy_v2` / `sell_v2` (below): they add a +deadline and a limit price on top of the share bound, and they are the only +entrypoints that protect against a transaction being included long after it +was signed. + ## Slippage on `buy` `quote_buy(market_id, outcome, collateral_in)` returns the exact shares a @@ -59,6 +64,67 @@ buy would receive *right now*. Call it immediately before building the `buy` transaction, then pass a `min_shares_out` a few bps below that quote (prices move between simulation and submission). +Never pass `min_shares_out: 0` (the old example did): it accepts any +execution price at all, which in a thin pool is how a sandwich takes your +money. + +## Deadlines and limit prices + +`buy_v2` / `sell_v2` add two contract-enforced bounds to every trade: + +- `deadline: u64` — unix seconds, compared against the **ledger + timestamp** (network time, not the user's clock). Past it the trade + reverts with `DeadlineExpired` (#19). +- `limit_price_bps: u32` — the **average execution price** bound, + fee included: for a buy, `collateral_in / shares_out <= limit`; for a + sell, `collateral_out / shares_in >= limit`. Worse than the limit + reverts with `LimitPriceExceeded` (#20). The limit is on the price of + *the outcome being traded* — a NO buy's limit is checked against NO + prices, i.e. the complement of the YES price. `0` means "no limit" for + buys; for sells it is trivially satisfied and buys you nothing. Values + above `10_000` are rejected (`InvalidAmount`). + +```ts +const quote = (await contract.quote_buy({ market_id, outcome, collateral_in })).result; +const tx = await contract.buy_v2({ + trader: userAddress, + market_id, + outcome, + collateral_in, + min_shares_out: (quote * 9_950n) / 10_000n, // 50bps slippage + limit_price_bps: 6500, // never pay more than 65¢ per share + deadline: BigInt(Math.floor(Date.now() / 1000) + 60), +}); +``` + +`buy` / `sell` remain for backward compatibility but carry no deadline or +limit; the share-count bound alone does not stop a stale transaction. + +## Circuit breakers + +Markets can carry per-market breakers (snapshotted at creation): a maximum +price impact for a **single trade** (`max_impact_bps`) and a **per-ledger +band** (`band_bps`) around the price at the start of the ledger. A trade +that would move the YES price more than the impact cap reverts with +`PriceImpactTooHigh` (#21); one that would push it more than the band away +from the ledger-open price reverts with `PriceBandExceeded` (#22). The +band resets every ledger, so the worst case is ~5s of blocked trading, not +a permanent lock. + +Read the config and the current band reference before quoting so your UI +can size trades that will actually execute: + +- `breaker_config(market_id)` → `{ max_impact_bps, band_bps } | null` + (`null` = old market, no breakers) +- `breaker_state(market_id)` → `{ ledger_seq, ref_price_bps } | null` +- `would_trip(market_id, outcome, collateral_in)` → bool: whether a buy of + that size would trip either check right now. Use it to warn users before + they sign; `quote_buy` is not filtered by the breakers. + +There is deliberately no on-chain "breaker tripped" event (a reverted +invocation's events are rolled back with it) — surface trips to users from +the error codes above and the views here. + ## Selling an exact share count `sell` takes an exact `collateral_out`, not a share count — market-core has @@ -79,6 +145,17 @@ no on-chain sqrt (see the docstring on `sell` in `model/fpmm_model.py` — and the pool may have moved between your read and your transaction). +## Moving shares between wallets + +`transfer_position(from, to, market_id, outcome, amount)` sends outcome +shares to another wallet (the sender signs; `transfer_lp(market_id, +new_creator)` does the same for the creator/LP role). It is allowed while +the market is open and after resolution, up to the moment shares are +redeemed, and emits `PositionTransferred`. If your UI tracks balances +itself, handle that event (and `LpTransferred`); until your indexer does, +read balances from `get_position(market_id, holder)` rather than from +event-derived state. + ## Category enum `Category` is a plain enum (`Crypto | Macro | Geopolitics | Sports | Culture diff --git a/oracledesk-stellar/model/fpmm_model.py b/oracledesk-stellar/model/fpmm_model.py index 53ec152..60815b9 100644 --- a/oracledesk-stellar/model/fpmm_model.py +++ b/oracledesk-stellar/model/fpmm_model.py @@ -162,6 +162,49 @@ def collateral_out_for_shares(r_sold: int, r_other: int, shares_in: int, fee_bps return mul_div_floor(gross, BPS - fee_bps, BPS) +def price_after(outcome_is_yes: bool, r_after: int, r_other_after: int) -> int: + """YES price in bps of the pool **after** applying an outcome-relative + reserve update (the `new_reserve_*` pair of a BuyQuote/SellQuote). + Mirrors math.rs::price_after: pass `outcome_is_yes=False` for a NO trade, + where `r_after` is the NO reserve.""" + if outcome_is_yes: + return price_yes_bps(r_after, r_other_after) + return price_yes_bps(r_other_after, r_after) + + +def price_after_buy(taken_is_yes: bool, quote: BuyQuote) -> int: + """Post-trade YES price of a `calc_buy` quote for the outcome the buyer + receives (`taken_is_yes=False` for a NO buy). This is what the + per-market impact breaker measures against the pre-trade price.""" + return price_after(taken_is_yes, quote.new_reserve_bought, quote.new_reserve_other) + + +def price_after_sell(taken_is_yes: bool, quote: SellQuote) -> int: + """Post-trade YES price of a `calc_sell` quote for the outcome the seller + gave up (`taken_is_yes=False` for a NO sell).""" + return price_after(taken_is_yes, quote.new_reserve_sold, quote.new_reserve_other) + + +def buy_within_limit(collateral_in: int, shares_out: int, limit_bps: int) -> bool: + """True when a buy's average execution price `collateral_in / shares_out` + (fees included) is at most `limit_bps` per share. Compared by + cross-multiplication so rounding cannot flip the decision; exact + equality passes. Mirrors math.rs::buy_within_limit.""" + if collateral_in < 0 or shares_out <= 0: + raise MathError("invalid input") + return collateral_in * BPS <= shares_out * limit_bps + + +def sell_within_limit(collateral_out: int, shares_in: int, limit_bps: int) -> bool: + """True when a sell's average execution price `collateral_out / shares_in` + is at least `limit_bps` per share. Exact equality passes; a zero limit is + trivially satisfied, which buys the caller no protection. Mirrors + math.rs::sell_within_limit.""" + if collateral_out < 0 or shares_in <= 0: + raise MathError("invalid input") + return collateral_out * BPS >= shares_in * limit_bps + + # --------------------------------------------------------------------------- # Property tests. No pytest/hypothesis dependency: run directly. # --------------------------------------------------------------------------- @@ -247,12 +290,49 @@ def _check_void_never_overpays(): assert settle_value("void", 5, 5) <= 5 +def _check_price_after_maps_both_sides(): + """Mirrors math.rs's price_after tests: outcome_is_yes selects which of + the two post-trade reserves is the YES reserve.""" + for r, other in ((1000, 1000), (910, 1100), (1, 9999), (5893, 4107)): + assert price_after(True, r, other) == price_yes_bps(r, other) + assert price_after(False, r, other) == price_yes_bps(other, r) + + # Worked example shared by all three mirrors: buying 10 USDC of YES in a + # 100-sets pool at 50% with a 1% fee moves YES from 5000 to 5470 bps. + q = calc_buy(1_000_000_000, 1_000_000_000, 100_000_000, 100) + assert price_yes_bps(1_000_000_000, 1_000_000_000) == 5000 + assert price_after_buy(True, q) == 5470 + # Selling right back must not land above the pre-trade price: two fees + # and pool-favoring rounding always leave the seller worse off. + s = calc_sell(q.new_reserve_bought, q.new_reserve_other, 100_000_000, 100) + assert price_after_sell(True, s) < 5000 + + +def _check_limits_boundary_and_symmetry(): + # Exact equality passes, one unit over fails, in both directions. + assert buy_within_limit(100, 100, 10_000) + assert buy_within_limit(100, 101, 10_000) + assert not buy_within_limit(101, 100, 10_000) + assert buy_within_limit(100, 200, 5_000) + assert not buy_within_limit(101, 200, 5_000) + assert sell_within_limit(100, 100, 10_000) + assert not sell_within_limit(99, 100, 10_000) + assert sell_within_limit(100, 200, 5_000) + assert not sell_within_limit(100, 201, 5_000) + # A zero limit is trivially satisfied for sells (documented as "no + # protection") and only binds a buy at a zero average price. + assert sell_within_limit(0, 1, 0) + assert not buy_within_limit(1, 1, 0) + + def run_all(): _check_known_values() _check_seed_opens_at_requested_probability() _check_k_never_decreases_and_round_trip_never_profits() _check_gross_for_shares_inverts_calc_sell() _check_void_never_overpays() + _check_price_after_maps_both_sides() + _check_limits_boundary_and_symmetry() print("fpmm_model: all property checks passed") diff --git a/oracledesk-stellar/packages/bindings/market-core/package-lock.json b/oracledesk-stellar/packages/bindings/market-core/package-lock.json index 098fe8a..3d2ba3d 100644 --- a/oracledesk-stellar/packages/bindings/market-core/package-lock.json +++ b/oracledesk-stellar/packages/bindings/market-core/package-lock.json @@ -1,11 +1,11 @@ { - "name": "market-core", + "name": "@oracledesk/bindings-market-core", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "market-core", + "name": "@oracledesk/bindings-market-core", "version": "0.0.0", "dependencies": { "@stellar/stellar-sdk": "^16.0.1", diff --git a/oracledesk-stellar/packages/bindings/market-core/src/index.ts b/oracledesk-stellar/packages/bindings/market-core/src/index.ts index 536442a..c6fc420 100644 --- a/oracledesk-stellar/packages/bindings/market-core/src/index.ts +++ b/oracledesk-stellar/packages/bindings/market-core/src/index.ts @@ -51,7 +51,25 @@ export const Errors = { 15: {message:"MarketTooLarge"}, 16: {message:"NothingToRedeem"}, 17: {message:"AlreadyClaimed"}, - 18: {message:"Overflow"} + 18: {message:"Overflow"}, + /** + * `buy_v2` / `sell_v2` called with `env.ledger().timestamp() > deadline`. + */ + 19: {message:"DeadlineExpired"}, + /** + * `buy_v2` / `sell_v2` average execution price worse than the limit. + */ + 20: {message:"LimitPriceExceeded"}, + /** + * A single trade would move the price more than `max_impact_bps`. + * (Both this and the band reject the whole invocation, so their events, + * if any, roll back with it — see the note on `BreakerConfig`.) + */ + 21: {message:"PriceImpactTooHigh"}, + /** + * Cumulative same-ledger movement would exceed `band_bps`. + */ + 22: {message:"PriceBandExceeded"} } @@ -61,6 +79,7 @@ export interface Config { collateral: string; default_fee_bps: u32; paused: boolean; + protocol_fee_share_bps: u32; resolver: string; treasury: string; } @@ -111,13 +130,36 @@ sets_minted: i128; status: MarketStatus; } -export type DataKey = {tag: "Admin", values: void} | {tag: "Collateral", values: void} | {tag: "Treasury", values: void} | {tag: "Resolver", values: void} | {tag: "DefaultFeeBps", values: void} | {tag: "Paused", values: void} | {tag: "NextId", values: void} | {tag: "Market", values: readonly [u64]} | {tag: "Position", values: readonly [u64, string]}; +export type DataKey = {tag: "Admin", values: void} | {tag: "Collateral", values: void} | {tag: "Treasury", values: void} | {tag: "Resolver", values: void} | {tag: "DefaultFeeBps", values: void} | {tag: "ProtocolFeeShareBps", values: void} | {tag: "DefaultBreaker", values: void} | {tag: "Paused", values: void} | {tag: "NextId", values: void} | {tag: "Market", values: readonly [u64]} | {tag: "Position", values: readonly [u64, string]} | {tag: "FeeTerms", values: readonly [u64]} | {tag: "LpFees", values: readonly [u64]} | {tag: "Breaker", values: readonly [u64]} | {tag: "Band", values: readonly [u64]}; export type Outcome = {tag: "Yes", values: void} | {tag: "No", values: void}; export type Category = {tag: "Crypto", values: void} | {tag: "Macro", values: void} | {tag: "Geopolitics", values: void} | {tag: "Sports", values: void} | {tag: "Culture", values: void} | {tag: "Other", values: void}; +/** + * Fee destination and split, snapshotted per market at creation as a side + * entry (`DataKey::FeeTerms`) so `Market`'s XDR shape never changes. + * + * Markets created before this entry existed have no `FeeTerms` and keep the + * legacy behaviour: `sweep_fees` pays whatever `DataKey::Treasury` holds at + * sweep time, with the protocol keeping 100% of the fee. That fallback is + * documented in `sweep_fees` and makes the upgrade migration-free. + */ +export interface FeeTerms { + /** + * Protocol's share of each trade fee in bps; the rest is the LP's. + * 0..=BPS. At BPS (the default) the split is exactly today's behaviour. + */ +protocol_share_bps: u32; + /** + * Where the protocol's fee share is swept. Frozen at creation: later + * `set_treasury` calls never redirect fees already earned. + */ +recipient: string; +} + + export interface Position { no: i128; yes: i128; @@ -125,15 +167,51 @@ export interface Position { +/** + * Temporary (one ledger) band reference for a market. + */ +export interface BandState { + ledger_seq: u32; + ref_price_bps: u32; +} + + + export type MarketStatus = {tag: "Open", values: void} | {tag: "Resolved", values: readonly [Outcome]} | {tag: "Void", values: void}; +/** + * 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. + */ +export interface BreakerConfig { + /** + * Maximum |post-price - ledger-open price| cumulative movement in bps. + */ +band_bps: u32; + /** + * Maximum |post-price - pre-price| a single trade may move in bps. + */ +max_impact_bps: u32; +} + + + + + + + + export interface Client { /** * Construct and simulate a buy transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Spend `collateral_in` to buy `outcome` shares. Returns shares received. * Reverts if fewer than `min_shares_out` would be received. + * + * Legacy entrypoint: no deadline and no limit price, so a signed bound + * can execute at any later time. Prefer `buy_v2`. */ buy: ({trader, market_id, outcome, collateral_in, min_shares_out}: {trader: string, market_id: u64, outcome: Outcome, collateral_in: i128, min_shares_out: i128}, options?: MethodOptions) => Promise>> @@ -143,9 +221,25 @@ export interface Client { * 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`. */ sell: ({trader, market_id, outcome, collateral_out, max_shares_in}: {trader: string, market_id: u64, outcome: Outcome, collateral_out: i128, max_shares_in: i128}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a buy_v2 transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * `buy` plus a deadline (`env.ledger().timestamp()` seconds — network + * time, not the client clock) and an average-price limit + * (`limit_price_bps` per share, 1.0 = BPS; 0 = no limit). + * + * The average price includes the fee (`fee` is part of `collateral_in`) + * and, for a NO buy, is compared against the NO price. Because the + * deadline check runs before `require_auth`, an expired transaction + * fails cheaply. A limit above BPS can never pass for a buy and is + * rejected as invalid. + */ + buy_v2: ({trader, market_id, outcome, collateral_in, min_shares_out, limit_price_bps, deadline}: {trader: string, market_id: u64, outcome: Outcome, collateral_in: i128, min_shares_out: i128, limit_price_bps: u32, deadline: u64}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a redeem transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Pays out `holder`'s position for a final market and clears it. @@ -158,6 +252,18 @@ export interface Client { */ resolve: ({market_id, outcome}: {market_id: u64, outcome: Outcome}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a sell_v2 transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * `sell` plus a deadline (ledger timestamp, seconds) and a minimum + * average execution price (`limit_price_bps` per share, 1.0 = BPS). + * + * A zero limit is trivially satisfied for sells — unlike buys, where 0 + * means "no limit" — and a limit above BPS can never pass, so neither + * needs a special case. The average includes the fee, which the trader + * pays out of the gross. + */ + sell_v2: ({trader, market_id, outcome, collateral_out, max_shares_in, limit_price_bps, deadline}: {trader: string, market_id: u64, outcome: Outcome, collateral_out: i128, max_shares_in: i128, limit_price_bps: u32, deadline: u64}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a get_price transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Spot price of `outcome` in bps (0..=10_000). YES + NO ~ 10_000. @@ -193,10 +299,31 @@ export interface Client { /** * Construct and simulate a sweep_fees transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - * Anyone can move accrued fees to the treasury. + * Anyone can move accrued protocol fees to the market's fee recipient. + * + * 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. */ sweep_fees: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a would_trip transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * True when a `buy` of `collateral_in` would be rejected by the + * market's breakers right now. Read-only: never writes band state, so + * on a fresh ledger the reference is taken as the current price — the + * same value a subsequent real trade would use. + */ + would_trip: ({market_id, outcome, collateral_in}: {market_id: u64, outcome: Outcome, collateral_in: i128}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a transfer_lp transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Transfers the single LP role (the right to `claim_pool_remainder` and + * `claim_lp_fees`) to `new_creator`. Only until the remainder has been + * claimed; after that the role has no remaining value. + */ + transfer_lp: ({market_id, new_creator}: {market_id: u64, new_creator: string}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a void_market transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Invalid or unresolvable market. Allowed any time before finality. @@ -220,9 +347,28 @@ export interface Client { /** * Construct and simulate a set_treasury transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Affects markets created after the call and the legacy fallback in + * `sweep_fees`. Markets with a snapshotted `FeeTerms` keep paying their + * original recipient. */ set_treasury: ({treasury}: {treasury: string}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a breaker_state transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * 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). + */ + breaker_state: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>> + + /** + * Construct and simulate a claim_lp_fees transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * 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`). + */ + claim_lp_fees: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a create_market transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Creates a market and seeds the pool. `creator` becomes the sole LP. @@ -231,6 +377,28 @@ export interface Client { */ create_market: ({creator, question_hash, resolution_hash, meta_uri, category, close_time, seed_amount, initial_yes_bps}: {creator: string, question_hash: Buffer, resolution_hash: Buffer, meta_uri: string, category: Category, close_time: u64, seed_amount: i128, initial_yes_bps: u32}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a breaker_config transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * The market's snapshotted breakers (`None` for legacy markets created + * before breakers existed — those are never checked). + */ + breaker_config: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>> + + /** + * Construct and simulate a transfer_position transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Moves `amount` shares of `outcome` from `from` to `to`. + */ + transfer_position: ({from, to, market_id, outcome, amount}: {from: string, to: string, market_id: u64, outcome: Outcome, amount: i128}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a set_default_breaker transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Sets the breaker defaults copied into markets created after the call. + * `band_bps < max_impact_bps` (both non-zero) is rejected: a single + * trade that is allowed by the impact cap could then never fit inside + * the band, locking trading. + */ + set_default_breaker: ({breaker}: {breaker: BreakerConfig}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a set_default_fee_bps transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Affects markets created after the call; existing markets keep their fee. @@ -239,15 +407,23 @@ export interface Client { /** * Construct and simulate a claim_pool_remainder transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - * Creator (the LP) withdraws whatever the pool still holds once final. + * Creator (the LP) withdraws whatever the pool still holds once final, + * plus any unclaimed LP fees. Returns the total paid out. */ claim_pool_remainder: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a set_protocol_fee_share_bps transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Affects markets created after the call; existing markets keep the + * split they snapshotted at creation. + */ + set_protocol_fee_share_bps: ({share_bps}: {share_bps: u32}, options?: MethodOptions) => Promise>> + } export class Client extends ContractClient { static async deploy( /** Constructor/Initialization Args for the contract's `__constructor` method */ - {admin, collateral, treasury, resolver, default_fee_bps}: {admin: string, collateral: string, treasury: string, resolver: string, default_fee_bps: u32}, + {admin, collateral, treasury, resolver, default_fee_bps, protocol_fee_share_bps}: {admin: string, collateral: string, treasury: string, resolver: string, default_fee_bps: u32, protocol_fee_share_bps: u32}, /** Options for initializing a Client as well as for calling a method, with extras specific to deploying. */ options: MethodOptions & Omit & { @@ -259,51 +435,73 @@ export class Client extends ContractClient { format?: "hex" | "base64"; } ): Promise> { - return ContractClient.deploy({admin, collateral, treasury, resolver, default_fee_bps}, options) + return ContractClient.deploy({admin, collateral, treasury, resolver, default_fee_bps, protocol_fee_share_bps}, options) } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAEgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAABlBhdXNlZAAAAAAAAgAAAAAAAAAKSW52YWxpZEZlZQAAAAAAAwAAAAAAAAANSW52YWxpZEFtb3VudAAAAAAAAAQAAAAAAAAAEkludmFsaWRQcm9iYWJpbGl0eQAAAAAABQAAAAAAAAAQSW52YWxpZENsb3NlVGltZQAAAAYAAAAAAAAADk1hcmtldE5vdEZvdW5kAAAAAAAHAAAAAAAAAA1NYXJrZXROb3RPcGVuAAAAAAAACAAAAAAAAAAMTWFya2V0Q2xvc2VkAAAACQAAAAAAAAAMTm90Q2xvc2VkWWV0AAAACgAAAAAAAAAITm90RmluYWwAAAALAAAAAAAAABBTbGlwcGFnZUV4Y2VlZGVkAAAADAAAAAAAAAASSW5zdWZmaWNpZW50U2hhcmVzAAAAAAANAAAAAAAAABVJbnN1ZmZpY2llbnRMaXF1aWRpdHkAAAAAAAAOAAAAAAAAAA5NYXJrZXRUb29MYXJnZQAAAAAADwAAAAAAAAAPTm90aGluZ1RvUmVkZWVtAAAAABAAAAAAAAAADkFscmVhZHlDbGFpbWVkAAAAAAARAAAAAAAAAAhPdmVyZmxvdwAAABI=", + new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAFgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAABlBhdXNlZAAAAAAAAgAAAAAAAAAKSW52YWxpZEZlZQAAAAAAAwAAAAAAAAANSW52YWxpZEFtb3VudAAAAAAAAAQAAAAAAAAAEkludmFsaWRQcm9iYWJpbGl0eQAAAAAABQAAAAAAAAAQSW52YWxpZENsb3NlVGltZQAAAAYAAAAAAAAADk1hcmtldE5vdEZvdW5kAAAAAAAHAAAAAAAAAA1NYXJrZXROb3RPcGVuAAAAAAAACAAAAAAAAAAMTWFya2V0Q2xvc2VkAAAACQAAAAAAAAAMTm90Q2xvc2VkWWV0AAAACgAAAAAAAAAITm90RmluYWwAAAALAAAAAAAAABBTbGlwcGFnZUV4Y2VlZGVkAAAADAAAAAAAAAASSW5zdWZmaWNpZW50U2hhcmVzAAAAAAANAAAAAAAAABVJbnN1ZmZpY2llbnRMaXF1aWRpdHkAAAAAAAAOAAAAAAAAAA5NYXJrZXRUb29MYXJnZQAAAAAADwAAAAAAAAAPTm90aGluZ1RvUmVkZWVtAAAAABAAAAAAAAAADkFscmVhZHlDbGFpbWVkAAAAAAARAAAAAAAAAAhPdmVyZmxvdwAAABIAAABHYGJ1eV92MmAgLyBgc2VsbF92MmAgY2FsbGVkIHdpdGggYGVudi5sZWRnZXIoKS50aW1lc3RhbXAoKSA+IGRlYWRsaW5lYC4AAAAAD0RlYWRsaW5lRXhwaXJlZAAAAAATAAAAQmBidXlfdjJgIC8gYHNlbGxfdjJgIGF2ZXJhZ2UgZXhlY3V0aW9uIHByaWNlIHdvcnNlIHRoYW4gdGhlIGxpbWl0LgAAAAAAEkxpbWl0UHJpY2VFeGNlZWRlZAAAAAAAFAAAAMVBIHNpbmdsZSB0cmFkZSB3b3VsZCBtb3ZlIHRoZSBwcmljZSBtb3JlIHRoYW4gYG1heF9pbXBhY3RfYnBzYC4KKEJvdGggdGhpcyBhbmQgdGhlIGJhbmQgcmVqZWN0IHRoZSB3aG9sZSBpbnZvY2F0aW9uLCBzbyB0aGVpciBldmVudHMsCmlmIGFueSwgcm9sbCBiYWNrIHdpdGggaXQg4oCUIHNlZSB0aGUgbm90ZSBvbiBgQnJlYWtlckNvbmZpZ2AuKQAAAAAAABJQcmljZUltcGFjdFRvb0hpZ2gAAAAAABUAAAA4Q3VtdWxhdGl2ZSBzYW1lLWxlZGdlciBtb3ZlbWVudCB3b3VsZCBleGNlZWQgYGJhbmRfYnBzYC4AAAARUHJpY2VCYW5kRXhjZWVkZWQAAAAAAAAW", "AAAABQAAAAAAAAAAAAAABVRyYWRlAAAAAAAAAQAAAAV0cmFkZQAAAAAAAAkAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAZ0cmFkZXIAAAAAABMAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAZpc19idXkAAAAAAAEAAAAAAAAAAAAAAApjb2xsYXRlcmFsAAAAAAALAAAAAAAAAAAAAAAGc2hhcmVzAAAAAAALAAAAAAAAAAAAAAADZmVlAAAAAAsAAAAAAAAAAAAAAAtyZXNlcnZlX3llcwAAAAALAAAAAAAAAAAAAAAKcmVzZXJ2ZV9ubwAAAAAACwAAAAAAAAAC", - "AAAAAQAAAAAAAAAAAAAABkNvbmZpZwAAAAAABgAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAApjb2xsYXRlcmFsAAAAAAATAAAAAAAAAA9kZWZhdWx0X2ZlZV9icHMAAAAABAAAAAAAAAAGcGF1c2VkAAAAAAABAAAAAAAAAAhyZXNvbHZlcgAAABMAAAAAAAAACHRyZWFzdXJ5AAAAEw==", + "AAAAAQAAAAAAAAAAAAAABkNvbmZpZwAAAAAABwAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAApjb2xsYXRlcmFsAAAAAAATAAAAAAAAAA9kZWZhdWx0X2ZlZV9icHMAAAAABAAAAAAAAAAGcGF1c2VkAAAAAAABAAAAAAAAABZwcm90b2NvbF9mZWVfc2hhcmVfYnBzAAAAAAAEAAAAAAAAAAhyZXNvbHZlcgAAABMAAAAAAAAACHRyZWFzdXJ5AAAAEw==", "AAAAAQAAAAAAAAAAAAAABk1hcmtldAAAAAAADQAAAAAAAAAIY2F0ZWdvcnkAAAfQAAAACENhdGVnb3J5AAAAPFVuaXggc2Vjb25kcyAobGVkZ2VyIHRpbWVzdGFtcCkuIFRyYWRpbmcgc3RvcHMgYXQgdGhpcyB0aW1lLgAAAApjbG9zZV90aW1lAAAAAAAGAAAAAAAAAAdjcmVhdG9yAAAAABMAAABGU25hcHNob3Qgb2YgdGhlIGZlZSBhdCBjcmVhdGlvbiBzbyBsYXRlciBjb25maWcgY2hhbmdlcyBuZXZlciBtb3ZlIGl0LgAAAAAAB2ZlZV9icHMAAAAABAAAACZUcmFkaW5nIGZlZXMgd2FpdGluZyBmb3IgYHN3ZWVwX2ZlZXNgLgAAAAAADGZlZXNfYWNjcnVlZAAAAAsAAAA4Q3JlYXRvciBoYXMgd2l0aGRyYXduIHRoZSBwb29sIHJlbWFpbmRlciBhZnRlciBmaW5hbGl0eS4AAAAKbHBfY2xhaW1lZAAAAAAAAQAAAChJUEZTIENJRCAvIFVSSSBvZiB0aGUgcXVlc3Rpb24gbWV0YWRhdGEuAAAACG1ldGFfdXJpAAAAEAAAAIZzaGEyNTYgb2YgdGhlIGNhbm9uaWNhbCBxdWVzdGlvbiBKU09OIHN0b3JlZCBvZmYtY2hhaW4gKElQRlMpLCBzbyBhCmNsaWVudCBjYW4gcHJvdmUgdGhlIHRleHQgaXQgc2hvd3MgaXMgdGhlIHRleHQgdGhhdCB3YXMgY29tbWl0dGVkLgAAAAAADXF1ZXN0aW9uX2hhc2gAAAAAAAPuAAAAIAAAAAAAAAAKcmVzZXJ2ZV9ubwAAAAAACwAAAAAAAAALcmVzZXJ2ZV95ZXMAAAAACwAAAS9Db21taXRtZW50IHRvIGhvdyB0aGlzIG1hcmtldCB3aWxsIGJlIHJlc29sdmVkLCBmaXhlZCBhdCBjcmVhdGlvbiBzbwp0aGUgY3JlYXRvciBjYW5ub3QgcGljayBmYXZvcmFibGUgcmVzb2x1dGlvbiBydWxlcyBhZnRlciBzZWVpbmcKdHJhZGluZyBhY3Rpdml0eS4gVGhlIHJlc29sdmVyIGNvbnRyYWN0IHZlcmlmaWVzIGEgcmV2ZWFsZWQgc3BlYwphZ2FpbnN0IHRoaXMgaGFzaCBiZWZvcmUgY29uZmlndXJpbmcgaXRzZWxmIGZvciB0aGUgbWFya2V0LiBTZWUKYGRvY3MvYWRyLzAwMDEtcmVzb2x1dGlvbi1jb21taXRtZW50Lm1kYC4AAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAEBDb21wbGV0ZSBzZXRzIGluIGV4aXN0ZW5jZSA9PSBjb2xsYXRlcmFsIGxvY2tlZCBmb3IgdGhpcyBtYXJrZXQuAAAAC3NldHNfbWludGVkAAAAAAsAAAAAAAAABnN0YXR1cwAAAAAH0AAAAAxNYXJrZXRTdGF0dXM=", - "AAAAAAAAAIFTcGVuZCBgY29sbGF0ZXJhbF9pbmAgdG8gYnV5IGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMgc2hhcmVzIHJlY2VpdmVkLgpSZXZlcnRzIGlmIGZld2VyIHRoYW4gYG1pbl9zaGFyZXNfb3V0YCB3b3VsZCBiZSByZWNlaXZlZC4AAAAAAAADYnV5AAAAAAUAAAAAAAAABnRyYWRlcgAAAAAAEwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA1jb2xsYXRlcmFsX2luAAAAAAAACwAAAAAAAAAObWluX3NoYXJlc19vdXQAAAAAAAsAAAABAAAD6QAAAAsAAAAD", - "AAAAAgAAAAAAAAAAAAAAB0RhdGFLZXkAAAAACQAAAAAAAAAAAAAABUFkbWluAAAAAAAAAAAAAAAAAAAKQ29sbGF0ZXJhbAAAAAAAAAAAAAAAAAAIVHJlYXN1cnkAAAAAAAAAAAAAAAhSZXNvbHZlcgAAAAAAAAAAAAAADURlZmF1bHRGZWVCcHMAAAAAAAAAAAAAAAAAAAZQYXVzZWQAAAAAAAAAAAAAAAAABk5leHRJZAAAAAAAAQAAAAAAAAAGTWFya2V0AAAAAAABAAAABgAAAAEAAAAAAAAACFBvc2l0aW9uAAAAAgAAAAYAAAAT", + "AAAAAgAAAAAAAAAAAAAAB0RhdGFLZXkAAAAADwAAAAAAAAAAAAAABUFkbWluAAAAAAAAAAAAAAAAAAAKQ29sbGF0ZXJhbAAAAAAAAAAAAAAAAAAIVHJlYXN1cnkAAAAAAAAAAAAAAAhSZXNvbHZlcgAAAAAAAAAAAAAADURlZmF1bHRGZWVCcHMAAAAAAAAAAAAAAAAAABNQcm90b2NvbEZlZVNoYXJlQnBzAAAAAAAAAAAAAAAADkRlZmF1bHRCcmVha2VyAAAAAAAAAAAAAAAAAAZQYXVzZWQAAAAAAAAAAAAAAAAABk5leHRJZAAAAAAAAQAAAAAAAAAGTWFya2V0AAAAAAABAAAABgAAAAEAAAAAAAAACFBvc2l0aW9uAAAAAgAAAAYAAAATAAAAAQAAAAAAAAAIRmVlVGVybXMAAAABAAAABgAAAAEAAAAAAAAABkxwRmVlcwAAAAAAAQAAAAYAAAABAAAAAAAAAAdCcmVha2VyAAAAAAEAAAAGAAAAAQAAAAAAAAAEQmFuZAAAAAEAAAAG", "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA", - "AAAAAAAAARBSZWNlaXZlIGV4YWN0bHkgYGNvbGxhdGVyYWxfb3V0YCBieSBzZWxsaW5nIGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMKc2hhcmVzIHNvbGQuIFJldmVydHMgaWYgbW9yZSB0aGFuIGBtYXhfc2hhcmVzX2luYCB3b3VsZCBiZSBuZWVkZWQuCkNsaWVudHMgdHVybiAic2VsbCBOIHNoYXJlcyIgaW50byBgY29sbGF0ZXJhbF9vdXRgIG9mZi1jaGFpbgooc2VlIGBtYXRoOjpjYWxjX3NlbGxgIGRvY3MpIGFuZCBhZGQgYSBzbGlwcGFnZSBtYXJnaW4gdG8gYG1heF9zaGFyZXNfaW5gLgAAAARzZWxsAAAABQAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADmNvbGxhdGVyYWxfb3V0AAAAAAALAAAAAAAAAA1tYXhfc2hhcmVzX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAgAAAAAAAAAAAAAACENhdGVnb3J5AAAABgAAAAAAAAAAAAAABkNyeXB0bwAAAAAAAAAAAAAAAAAFTWFjcm8AAAAAAAAAAAAAAAAAAAtHZW9wb2xpdGljcwAAAAAAAAAAAAAAAAZTcG9ydHMAAAAAAAAAAAAAAAAAB0N1bHR1cmUAAAAAAAAAAAAAAAAFT3RoZXIAAAA=", + "AAAAAQAAAahGZWUgZGVzdGluYXRpb24gYW5kIHNwbGl0LCBzbmFwc2hvdHRlZCBwZXIgbWFya2V0IGF0IGNyZWF0aW9uIGFzIGEgc2lkZQplbnRyeSAoYERhdGFLZXk6OkZlZVRlcm1zYCkgc28gYE1hcmtldGAncyBYRFIgc2hhcGUgbmV2ZXIgY2hhbmdlcy4KCk1hcmtldHMgY3JlYXRlZCBiZWZvcmUgdGhpcyBlbnRyeSBleGlzdGVkIGhhdmUgbm8gYEZlZVRlcm1zYCBhbmQga2VlcCB0aGUKbGVnYWN5IGJlaGF2aW91cjogYHN3ZWVwX2ZlZXNgIHBheXMgd2hhdGV2ZXIgYERhdGFLZXk6OlRyZWFzdXJ5YCBob2xkcyBhdApzd2VlcCB0aW1lLCB3aXRoIHRoZSBwcm90b2NvbCBrZWVwaW5nIDEwMCUgb2YgdGhlIGZlZS4gVGhhdCBmYWxsYmFjayBpcwpkb2N1bWVudGVkIGluIGBzd2VlcF9mZWVzYCBhbmQgbWFrZXMgdGhlIHVwZ3JhZGUgbWlncmF0aW9uLWZyZWUuAAAAAAAAAAhGZWVUZXJtcwAAAAIAAACGUHJvdG9jb2wncyBzaGFyZSBvZiBlYWNoIHRyYWRlIGZlZSBpbiBicHM7IHRoZSByZXN0IGlzIHRoZSBMUCdzLgowLi49QlBTLiBBdCBCUFMgKHRoZSBkZWZhdWx0KSB0aGUgc3BsaXQgaXMgZXhhY3RseSB0b2RheSdzIGJlaGF2aW91ci4AAAAAABJwcm90b2NvbF9zaGFyZV9icHMAAAAAAAQAAAB7V2hlcmUgdGhlIHByb3RvY29sJ3MgZmVlIHNoYXJlIGlzIHN3ZXB0LiBGcm96ZW4gYXQgY3JlYXRpb246IGxhdGVyCmBzZXRfdHJlYXN1cnlgIGNhbGxzIG5ldmVyIHJlZGlyZWN0IGZlZXMgYWxyZWFkeSBlYXJuZWQuAAAAAAlyZWNpcGllbnQAAAAAAAAT", "AAAAAQAAAAAAAAAAAAAACFBvc2l0aW9uAAAAAgAAAAAAAAACbm8AAAAAAAsAAAAAAAAAA3llcwAAAAAL", "AAAABQAAAAAAAAAAAAAACFJlZGVlbWVkAAAAAQAAAAhyZWRlZW1lZAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAZob2xkZXIAAAAAABMAAAABAAAAAAAAAAZwYXlvdXQAAAAAAAsAAAAAAAAAAg==", - "AAAAAAAAAGlQYXlzIG91dCBgaG9sZGVyYCdzIHBvc2l0aW9uIGZvciBhIGZpbmFsIG1hcmtldCBhbmQgY2xlYXJzIGl0LgpMb3NpbmcgcG9zaXRpb25zIGNsZWFyIHdpdGggYSBwYXlvdXQgb2YgMC4AAAAAAAAGcmVkZWVtAAAAAAACAAAAAAAAAAZob2xkZXIAAAAAABMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", + "AAAAAQAAADNUZW1wb3JhcnkgKG9uZSBsZWRnZXIpIGJhbmQgcmVmZXJlbmNlIGZvciBhIG1hcmtldC4AAAAAAAAAAAlCYW5kU3RhdGUAAAAAAAACAAAAAAAAAApsZWRnZXJfc2VxAAAAAAAEAAAAAAAAAA1yZWZfcHJpY2VfYnBzAAAAAAAABA==", "AAAABQAAAAAAAAAAAAAACUZlZXNTd2VwdAAAAAAAAAEAAAAKZmVlc19zd2VwdAAAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAC", - "AAAAAAAAAAAAAAAHcmVzb2x2ZQAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAABAAAD6QAAAAIAAAAD", + "AAAABQAAALFTYW1lIHN3ZWVwIGFzIGBGZWVzU3dlcHRgIHBsdXMgdGhlIGFjdHVhbCByZWNpcGllbnQuIE5ldyBldmVudCByYXRoZXIKdGhhbiBuZXcgZmllbGRzIHNvIHRoZSBleGlzdGluZyBgRmVlc1N3ZXB0YCBYRFIgc3RheXMgc3RhYmxlIGZvcgppbmRleGVyczsgYm90aCBhcmUgZW1pdHRlZCBvbiBldmVyeSBzd2VlcC4AAAAAAAAAAAAAC0ZlZXNTd2VwdFRvAAAAAAEAAAANZmVlc19zd2VwdF90bwAAAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAlyZWNpcGllbnQAAAAAAAATAAAAAAAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", "AAAAAgAAAAAAAAAAAAAADE1hcmtldFN0YXR1cwAAAAMAAAAAAAAAQlRyYWRpbmcgYWxsb3dlZCB1bnRpbCBgY2xvc2VfdGltZWAsIHRoZW4gd2FpdGluZyBmb3IgdGhlIHJlc29sdmVyLgAAAAAABE9wZW4AAAABAAAAIUZpbmFsLiBXaW5uaW5nIHNoYXJlcyByZWRlZW0gMToxLgAAAAAAAAhSZXNvbHZlZAAAAAEAAAfQAAAAB091dGNvbWUAAAAAAAAAACtJbnZhbGlkIG1hcmtldC4gRXZlcnkgc2hhcmUgcmVkZWVtcyBhdCAwLjUuAAAAAARWb2lk", + "AAAAAQAAAMJQZXItbWFya2V0IGNpcmN1aXQgYnJlYWtlcnMsIHNuYXBzaG90dGVkIGF0IGNyZWF0aW9uIGZyb20KYERhdGFLZXk6OkRlZmF1bHRCcmVha2VyYC4gMCBkaXNhYmxlcyBlYWNoIGNoZWNrLiBBIG1hcmtldCB3aXRob3V0IGEKYEJyZWFrZXJgIGVudHJ5IChjcmVhdGVkIGJlZm9yZSBicmVha2VycyBleGlzdGVkKSBpcyBuZXZlciBjaGVja2VkLgAAAAAAAAAAAA1CcmVha2VyQ29uZmlnAAAAAAAAAgAAAERNYXhpbXVtIHxwb3N0LXByaWNlIC0gbGVkZ2VyLW9wZW4gcHJpY2V8IGN1bXVsYXRpdmUgbW92ZW1lbnQgaW4gYnBzLgAAAAhiYW5kX2JwcwAAAAQAAABATWF4aW11bSB8cG9zdC1wcmljZSAtIHByZS1wcmljZXwgYSBzaW5nbGUgdHJhZGUgbWF5IG1vdmUgaW4gYnBzLgAAAA5tYXhfaW1wYWN0X2JwcwAAAAAABA==", + "AAAABQAAAAAAAAAAAAAADUxwRmVlc0NsYWltZWQAAAAAAAABAAAAD2xwX2ZlZXNfY2xhaW1lZAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", + "AAAABQAAAAAAAAAAAAAADUxwVHJhbnNmZXJyZWQAAAAAAAABAAAADmxwX3RyYW5zZmVycmVkAAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAEZnJvbQAAABMAAAAAAAAAAAAAAAJ0bwAAAAAAEwAAAAAAAAAC", + "AAAABQAAAAAAAAAAAAAADU1hcmtldENyZWF0ZWQAAAAAAAABAAAADm1hcmtldF9jcmVhdGVkAAAAAAAGAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAAAAAAAIY2F0ZWdvcnkAAAfQAAAACENhdGVnb3J5AAAAAAAAAAAAAAAKY2xvc2VfdGltZQAAAAAABgAAAAAAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAAAAAAA9pbml0aWFsX3llc19icHMAAAAABAAAAAAAAAAC", + "AAAABQAAAAAAAAAAAAAADk1hcmtldFJlc29sdmVkAAAAAAABAAAAD21hcmtldF9yZXNvbHZlZAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAACFOb25lIG1lYW5zIHRoZSBtYXJrZXQgd2FzIHZvaWRlZC4AAAAAAAAHb3V0Y29tZQAAAAPoAAAH0AAAAAdPdXRjb21lAAAAAAAAAAAC", + "AAAABQAAAAAAAAAAAAAAD1RyZWFzdXJ5Q2hhbmdlZAAAAAABAAAAEHRyZWFzdXJ5X2NoYW5nZWQAAAACAAAAAAAAAANvbGQAAAAAEwAAAAAAAAAAAAAAA25ldwAAAAATAAAAAAAAAAI=", + "AAAABQAAAIxQb3NpdGlvbnMgbW92ZWQgYmV0d2VlbiBob2xkZXJzLiBJbmRleGVycyB0aGF0IHRyYWNrIGJhbGFuY2VzIGZyb20KYFRyYWRlYC9gUmVkZWVtZWRgIG11c3QgYWxzbyBhcHBseSB0aGlzIGV2ZW50IChzZWUgZG9jcy9hcmNoaXRlY3R1cmUubWQpLgAAAAAAAAATUG9zaXRpb25UcmFuc2ZlcnJlZAAAAAABAAAAFHBvc2l0aW9uX3RyYW5zZmVycmVkAAAABQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAAAAAAABGZyb20AAAATAAAAAQAAAAAAAAACdG8AAAAAABMAAAAAAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAAAg==", + "AAAABQAAAAAAAAAAAAAAFFBvb2xSZW1haW5kZXJDbGFpbWVkAAAAAQAAABZwb29sX3JlbWFpbmRlcl9jbGFpbWVkAAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAQAAADlQb29sIHJlbWFpbmRlciBwbHVzIGFueSBMUCBmZWVzIGRyYWluZWQgaW4gdGhlIHNhbWUgY2FsbC4AAAAAAAAGcGF5b3V0AAAAAAALAAAAAAAAAAI=", + "AAAABQAAAAAAAAAAAAAAF1Byb3RvY29sRmVlU2hhcmVDaGFuZ2VkAAAAAAEAAAAacHJvdG9jb2xfZmVlX3NoYXJlX2NoYW5nZWQAAAAAAAIAAAAAAAAAA29sZAAAAAAEAAAAAAAAAAAAAAADbmV3AAAAAAQAAAAAAAAAAg==", + "AAAAAAAAAPdTcGVuZCBgY29sbGF0ZXJhbF9pbmAgdG8gYnV5IGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMgc2hhcmVzIHJlY2VpdmVkLgpSZXZlcnRzIGlmIGZld2VyIHRoYW4gYG1pbl9zaGFyZXNfb3V0YCB3b3VsZCBiZSByZWNlaXZlZC4KCkxlZ2FjeSBlbnRyeXBvaW50OiBubyBkZWFkbGluZSBhbmQgbm8gbGltaXQgcHJpY2UsIHNvIGEgc2lnbmVkIGJvdW5kCmNhbiBleGVjdXRlIGF0IGFueSBsYXRlciB0aW1lLiBQcmVmZXIgYGJ1eV92MmAuAAAAAANidXkAAAAABQAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADWNvbGxhdGVyYWxfaW4AAAAAAAALAAAAAAAAAA5taW5fc2hhcmVzX291dAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", + "AAAAAAAAAVZSZWNlaXZlIGV4YWN0bHkgYGNvbGxhdGVyYWxfb3V0YCBieSBzZWxsaW5nIGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMKc2hhcmVzIHNvbGQuIFJldmVydHMgaWYgbW9yZSB0aGFuIGBtYXhfc2hhcmVzX2luYCB3b3VsZCBiZSBuZWVkZWQuCkNsaWVudHMgdHVybiAic2VsbCBOIHNoYXJlcyIgaW50byBgY29sbGF0ZXJhbF9vdXRgIG9mZi1jaGFpbgooc2VlIGBtYXRoOjpjYWxjX3NlbGxgIGRvY3MpIGFuZCBhZGQgYSBzbGlwcGFnZSBtYXJnaW4gdG8gYG1heF9zaGFyZXNfaW5gLgoKTGVnYWN5IGVudHJ5cG9pbnQ6IG5vIGRlYWRsaW5lIGFuZCBubyBsaW1pdCBwcmljZS4gUHJlZmVyIGBzZWxsX3YyYC4AAAAAAARzZWxsAAAABQAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADmNvbGxhdGVyYWxfb3V0AAAAAAALAAAAAAAAAA1tYXhfc2hhcmVzX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", + "AAAAAAAAAdRgYnV5YCBwbHVzIGEgZGVhZGxpbmUgKGBlbnYubGVkZ2VyKCkudGltZXN0YW1wKClgIHNlY29uZHMg4oCUIG5ldHdvcmsKdGltZSwgbm90IHRoZSBjbGllbnQgY2xvY2spIGFuZCBhbiBhdmVyYWdlLXByaWNlIGxpbWl0CihgbGltaXRfcHJpY2VfYnBzYCBwZXIgc2hhcmUsIDEuMCA9IEJQUzsgMCA9IG5vIGxpbWl0KS4KClRoZSBhdmVyYWdlIHByaWNlIGluY2x1ZGVzIHRoZSBmZWUgKGBmZWVgIGlzIHBhcnQgb2YgYGNvbGxhdGVyYWxfaW5gKQphbmQsIGZvciBhIE5PIGJ1eSwgaXMgY29tcGFyZWQgYWdhaW5zdCB0aGUgTk8gcHJpY2UuIEJlY2F1c2UgdGhlCmRlYWRsaW5lIGNoZWNrIHJ1bnMgYmVmb3JlIGByZXF1aXJlX2F1dGhgLCBhbiBleHBpcmVkIHRyYW5zYWN0aW9uCmZhaWxzIGNoZWFwbHkuIEEgbGltaXQgYWJvdmUgQlBTIGNhbiBuZXZlciBwYXNzIGZvciBhIGJ1eSBhbmQgaXMKcmVqZWN0ZWQgYXMgaW52YWxpZC4AAAAGYnV5X3YyAAAAAAAHAAAAAAAAAAZ0cmFkZXIAAAAAABMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAAAAAANY29sbGF0ZXJhbF9pbgAAAAAAAAsAAAAAAAAADm1pbl9zaGFyZXNfb3V0AAAAAAALAAAAAAAAAA9saW1pdF9wcmljZV9icHMAAAAABAAAAAAAAAAIZGVhZGxpbmUAAAAGAAAAAQAAA+kAAAALAAAAAw==", + "AAAAAAAAAGlQYXlzIG91dCBgaG9sZGVyYCdzIHBvc2l0aW9uIGZvciBhIGZpbmFsIG1hcmtldCBhbmQgY2xlYXJzIGl0LgpMb3NpbmcgcG9zaXRpb25zIGNsZWFyIHdpdGggYSBwYXlvdXQgb2YgMC4AAAAAAAAGcmVkZWVtAAAAAAACAAAAAAAAAAZob2xkZXIAAAAAABMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", + "AAAAAAAAAAAAAAAHcmVzb2x2ZQAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAABAAAD6QAAAAIAAAAD", + "AAAAAAAAAWxgc2VsbGAgcGx1cyBhIGRlYWRsaW5lIChsZWRnZXIgdGltZXN0YW1wLCBzZWNvbmRzKSBhbmQgYSBtaW5pbXVtCmF2ZXJhZ2UgZXhlY3V0aW9uIHByaWNlIChgbGltaXRfcHJpY2VfYnBzYCBwZXIgc2hhcmUsIDEuMCA9IEJQUykuCgpBIHplcm8gbGltaXQgaXMgdHJpdmlhbGx5IHNhdGlzZmllZCBmb3Igc2VsbHMg4oCUIHVubGlrZSBidXlzLCB3aGVyZSAwCm1lYW5zICJubyBsaW1pdCIg4oCUIGFuZCBhIGxpbWl0IGFib3ZlIEJQUyBjYW4gbmV2ZXIgcGFzcywgc28gbmVpdGhlcgpuZWVkcyBhIHNwZWNpYWwgY2FzZS4gVGhlIGF2ZXJhZ2UgaW5jbHVkZXMgdGhlIGZlZSwgd2hpY2ggdGhlIHRyYWRlcgpwYXlzIG91dCBvZiB0aGUgZ3Jvc3MuAAAAB3NlbGxfdjIAAAAABwAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADmNvbGxhdGVyYWxfb3V0AAAAAAALAAAAAAAAAA1tYXhfc2hhcmVzX2luAAAAAAAACwAAAAAAAAAPbGltaXRfcHJpY2VfYnBzAAAAAAQAAAAAAAAACGRlYWRsaW5lAAAABgAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAD9TcG90IHByaWNlIG9mIGBvdXRjb21lYCBpbiBicHMgKDAuLj0xMF8wMDApLiBZRVMgKyBOTyB+IDEwXzAwMC4AAAAACWdldF9wcmljZQAAAAAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAEAAAPpAAAABAAAAAM=", "AAAAAAAAADlTaGFyZXMgYSBgYnV5YCBvZiBgY29sbGF0ZXJhbF9pbmAgd291bGQgcmV0dXJuIHJpZ2h0IG5vdy4AAAAAAAAJcXVvdGVfYnV5AAAAAAAAAwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA1jb2xsYXRlcmFsX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAAAAAAAKZ2V0X2NvbmZpZwAAAAAAAAAAAAEAAAPpAAAH0AAAAAZDb25maWcAAAAAAAM=", "AAAAAAAAAAAAAAAKZ2V0X21hcmtldAAAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAH0AAAAAZNYXJrZXQAAAAAAAM=", "AAAAAAAAAEVTaGFyZXMgYSBgc2VsbGAgZm9yIGV4YWN0bHkgYGNvbGxhdGVyYWxfb3V0YCB3b3VsZCBjb25zdW1lIHJpZ2h0IG5vdy4AAAAAAAAKcXVvdGVfc2VsbAAAAAAAAwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA5jb2xsYXRlcmFsX291dAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAAAAAAAKc2V0X3BhdXNlZAAAAAAAAQAAAAAAAAAGcGF1c2VkAAAAAAABAAAAAQAAA+kAAAACAAAAAw==", - "AAAAAAAAAC1BbnlvbmUgY2FuIG1vdmUgYWNjcnVlZCBmZWVzIHRvIHRoZSB0cmVhc3VyeS4AAAAAAAAKc3dlZXBfZmVlcwAAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAACwAAAAM=", - "AAAABQAAAAAAAAAAAAAADU1hcmtldENyZWF0ZWQAAAAAAAABAAAADm1hcmtldF9jcmVhdGVkAAAAAAAGAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAAAAAAAIY2F0ZWdvcnkAAAfQAAAACENhdGVnb3J5AAAAAAAAAAAAAAAKY2xvc2VfdGltZQAAAAAABgAAAAAAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAAAAAAA9pbml0aWFsX3llc19icHMAAAAABAAAAAAAAAAC", + "AAAAAAAAAQpBbnlvbmUgY2FuIG1vdmUgYWNjcnVlZCBwcm90b2NvbCBmZWVzIHRvIHRoZSBtYXJrZXQncyBmZWUgcmVjaXBpZW50LgoKU2VuZHMgdG8gdGhlIHJlY2lwaWVudCBzbmFwc2hvdHRlZCBpbiBgRmVlVGVybXNgIGF0IGNyZWF0aW9uLiBNYXJrZXRzCmNyZWF0ZWQgYmVmb3JlIGBGZWVUZXJtc2AgZXhpc3RlZCBoYXZlIG5vbmUgYW5kIGtlZXAgdGhlIGxlZ2FjeQpiZWhhdmlvdXI6IHRoZSBgRGF0YUtleTo6VHJlYXN1cnlgIG9mIHRoZSBtb21lbnQgb2YgdGhlIHN3ZWVwLgAAAAAACnN3ZWVwX2ZlZXMAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", + "AAAAAAAAAPVUcnVlIHdoZW4gYSBgYnV5YCBvZiBgY29sbGF0ZXJhbF9pbmAgd291bGQgYmUgcmVqZWN0ZWQgYnkgdGhlCm1hcmtldCdzIGJyZWFrZXJzIHJpZ2h0IG5vdy4gUmVhZC1vbmx5OiBuZXZlciB3cml0ZXMgYmFuZCBzdGF0ZSwgc28Kb24gYSBmcmVzaCBsZWRnZXIgdGhlIHJlZmVyZW5jZSBpcyB0YWtlbiBhcyB0aGUgY3VycmVudCBwcmljZSDigJQgdGhlCnNhbWUgdmFsdWUgYSBzdWJzZXF1ZW50IHJlYWwgdHJhZGUgd291bGQgdXNlLgAAAAAAAAp3b3VsZF90cmlwAAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADWNvbGxhdGVyYWxfaW4AAAAAAAALAAAAAQAAA+kAAAABAAAAAw==", + "AAAAAAAAAL9UcmFuc2ZlcnMgdGhlIHNpbmdsZSBMUCByb2xlICh0aGUgcmlnaHQgdG8gYGNsYWltX3Bvb2xfcmVtYWluZGVyYCBhbmQKYGNsYWltX2xwX2ZlZXNgKSB0byBgbmV3X2NyZWF0b3JgLiBPbmx5IHVudGlsIHRoZSByZW1haW5kZXIgaGFzIGJlZW4KY2xhaW1lZDsgYWZ0ZXIgdGhhdCB0aGUgcm9sZSBoYXMgbm8gcmVtYWluaW5nIHZhbHVlLgAAAAALdHJhbnNmZXJfbHAAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAALbmV3X2NyZWF0b3IAAAAAEwAAAAEAAAPpAAAAAgAAAAM=", "AAAAAAAAAEFJbnZhbGlkIG9yIHVucmVzb2x2YWJsZSBtYXJrZXQuIEFsbG93ZWQgYW55IHRpbWUgYmVmb3JlIGZpbmFsaXR5LgAAAAAAAAt2b2lkX21hcmtldAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==", - "AAAABQAAAAAAAAAAAAAADk1hcmtldFJlc29sdmVkAAAAAAABAAAAD21hcmtldF9yZXNvbHZlZAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAACFOb25lIG1lYW5zIHRoZSBtYXJrZXQgd2FzIHZvaWRlZC4AAAAAAAAHb3V0Y29tZQAAAAPoAAAH0AAAAAdPdXRjb21lAAAAAAAAAAAC", "AAAAAAAAAAAAAAAMZ2V0X3Bvc2l0aW9uAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAGaG9sZGVyAAAAAAATAAAAAQAAB9AAAAAIUG9zaXRpb24=", "AAAAAAAAAAAAAAAMbWFya2V0X2NvdW50AAAAAAAAAAEAAAAG", "AAAAAAAAAAAAAAAMc2V0X3Jlc29sdmVyAAAAAQAAAAAAAAAIcmVzb2x2ZXIAAAATAAAAAQAAA+kAAAACAAAAAw==", - "AAAAAAAAAAAAAAAMc2V0X3RyZWFzdXJ5AAAAAQAAAAAAAAAIdHJlYXN1cnkAAAATAAAAAQAAA+kAAAACAAAAAw==", - "AAAAAAAAAEdSdW5zIGF0b21pY2FsbHkgYXQgZGVwbG95IHRpbWUsIHNvIG5vYm9keSBjYW4gZnJvbnQtcnVuIGluaXRpYWxpemF0aW9uLgAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAUAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAKY29sbGF0ZXJhbAAAAAAAEwAAAAAAAAAIdHJlYXN1cnkAAAATAAAAAAAAAAhyZXNvbHZlcgAAABMAAAAAAAAAD2RlZmF1bHRfZmVlX2JwcwAAAAAEAAAAAA==", + "AAAAAAAAAJtBZmZlY3RzIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbCBhbmQgdGhlIGxlZ2FjeSBmYWxsYmFjayBpbgpgc3dlZXBfZmVlc2AuIE1hcmtldHMgd2l0aCBhIHNuYXBzaG90dGVkIGBGZWVUZXJtc2Aga2VlcCBwYXlpbmcgdGhlaXIKb3JpZ2luYWwgcmVjaXBpZW50LgAAAAAMc2V0X3RyZWFzdXJ5AAAAAQAAAAAAAAAIdHJlYXN1cnkAAAATAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAQhSdW5zIGF0b21pY2FsbHkgYXQgZGVwbG95IHRpbWUsIHNvIG5vYm9keSBjYW4gZnJvbnQtcnVuIGluaXRpYWxpemF0aW9uLgpgcHJvdG9jb2xfZmVlX3NoYXJlX2Jwc2AgaXMgdGhlIGRlZmF1bHQgcHJvdG9jb2wvTFAgc3BsaXQgZm9yIG5ldwptYXJrZXRzOyAxMF8wMDAga2VlcHMgdGhlIHdob2xlIGZlZSB3aXRoIHRoZSBwcm90b2NvbCAocHJlLXVwZ3JhZGUKYmVoYXZpb3VyKS4gUGFzcyAwIHRvIGdpdmUgYWxsIGZlZXMgdG8gdGhlIG1hcmtldCBjcmVhdG9ycy4AAAANX19jb25zdHJ1Y3RvcgAAAAAAAAYAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAKY29sbGF0ZXJhbAAAAAAAEwAAAAAAAAAIdHJlYXN1cnkAAAATAAAAAAAAAAhyZXNvbHZlcgAAABMAAAAAAAAAD2RlZmF1bHRfZmVlX2JwcwAAAAAEAAAAAAAAABZwcm90b2NvbF9mZWVfc2hhcmVfYnBzAAAAAAAEAAAAAA==", + "AAAAAAAAALpUaGUgY3VycmVudCBsZWRnZXIncyBiYW5kIHJlZmVyZW5jZSwgaWYgYSBiYW5kIGNoZWNrIGhhcyBydW4gdGhpcwpsZWRnZXIuIGBOb25lYCBtZWFucyB0aGUgbmV4dCB0cmFkZSBzZXRzIHRoZSByZWZlcmVuY2UgKHRvIGl0cyBvd24KcHJlLXRyYWRlIHByaWNlLCB3aGljaCBlcXVhbHMgdGhlIGxlZGdlci1vcGVuIHByaWNlKS4AAAAAAA1icmVha2VyX3N0YXRlAAAAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAD6AAAB9AAAAAJQmFuZFN0YXRlAAAAAAAAAw==", + "AAAAAAAAALVUaGUgY3JlYXRvciAodGhlIExQKSBjbGFpbXMgdGhlaXIgc2hhcmUgb2YgYWNjcnVlZCB0cmFkZSBmZWVzIGF0IGFueQp0aW1lLCBpbmNsdWRpbmcgYmVmb3JlIGZpbmFsaXR5LiBSZXR1cm5zIHRoZSBhbW91bnQgY2xhaW1lZCAoMCB3aGVuCm5vdGhpbmcgaXMgcGVuZGluZywgbWlycm9yaW5nIGBzd2VlcF9mZWVzYCkuAAAAAAAADWNsYWltX2xwX2ZlZXMAAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAMhDcmVhdGVzIGEgbWFya2V0IGFuZCBzZWVkcyB0aGUgcG9vbC4gYGNyZWF0b3JgIGJlY29tZXMgdGhlIHNvbGUgTFAuCmBpbml0aWFsX3llc19icHNgIGluIFsxMDAsIDk5MDBdIHNldHMgdGhlIG9wZW5pbmcgWUVTIHByb2JhYmlsaXR5Owp0aGUgY3JlYXRvciByZWNlaXZlcyB0aGUgbGVmdG92ZXIgc2hhcmVzIG9mIHRoZSBsaWtlbGllciBvdXRjb21lLgAAAA1jcmVhdGVfbWFya2V0AAAAAAAACAAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAA1xdWVzdGlvbl9oYXNoAAAAAAAD7gAAACAAAAAAAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAAAAAAAIbWV0YV91cmkAAAAQAAAAAAAAAAhjYXRlZ29yeQAAB9AAAAAIQ2F0ZWdvcnkAAAAAAAAACmNsb3NlX3RpbWUAAAAAAAYAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAD2luaXRpYWxfeWVzX2JwcwAAAAAEAAAAAQAAA+kAAAAGAAAAAw==", + "AAAAAAAAAHpUaGUgbWFya2V0J3Mgc25hcHNob3R0ZWQgYnJlYWtlcnMgKGBOb25lYCBmb3IgbGVnYWN5IG1hcmtldHMgY3JlYXRlZApiZWZvcmUgYnJlYWtlcnMgZXhpc3RlZCDigJQgdGhvc2UgYXJlIG5ldmVyIGNoZWNrZWQpLgAAAAAADmJyZWFrZXJfY29uZmlnAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAPoAAAH0AAAAA1CcmVha2VyQ29uZmlnAAAAAAAAAw==", + "AAAAAAAAADdNb3ZlcyBgYW1vdW50YCBzaGFyZXMgb2YgYG91dGNvbWVgIGZyb20gYGZyb21gIHRvIGB0b2AuAAAAABF0cmFuc2Zlcl9wb3NpdGlvbgAAAAAAAAUAAAAAAAAABGZyb20AAAATAAAAAAAAAAJ0bwAAAAAAEwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAAZhbW91bnQAAAAAAAsAAAABAAAD6QAAAAIAAAAD", + "AAAAAAAAAOZTZXRzIHRoZSBicmVha2VyIGRlZmF1bHRzIGNvcGllZCBpbnRvIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbC4KYGJhbmRfYnBzIDwgbWF4X2ltcGFjdF9icHNgIChib3RoIG5vbi16ZXJvKSBpcyByZWplY3RlZDogYSBzaW5nbGUKdHJhZGUgdGhhdCBpcyBhbGxvd2VkIGJ5IHRoZSBpbXBhY3QgY2FwIGNvdWxkIHRoZW4gbmV2ZXIgZml0IGluc2lkZQp0aGUgYmFuZCwgbG9ja2luZyB0cmFkaW5nLgAAAAAAE3NldF9kZWZhdWx0X2JyZWFrZXIAAAAAAQAAAAAAAAAHYnJlYWtlcgAAAAfQAAAADUJyZWFrZXJDb25maWcAAAAAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAEhBZmZlY3RzIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbDsgZXhpc3RpbmcgbWFya2V0cyBrZWVwIHRoZWlyIGZlZS4AAAATc2V0X2RlZmF1bHRfZmVlX2JwcwAAAAABAAAAAAAAAAdmZWVfYnBzAAAAAAQAAAABAAAD6QAAAAIAAAAD", - "AAAAAAAAAERDcmVhdG9yICh0aGUgTFApIHdpdGhkcmF3cyB3aGF0ZXZlciB0aGUgcG9vbCBzdGlsbCBob2xkcyBvbmNlIGZpbmFsLgAAABRjbGFpbV9wb29sX3JlbWFpbmRlcgAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD" ]), + "AAAAAAAAAHxDcmVhdG9yICh0aGUgTFApIHdpdGhkcmF3cyB3aGF0ZXZlciB0aGUgcG9vbCBzdGlsbCBob2xkcyBvbmNlIGZpbmFsLApwbHVzIGFueSB1bmNsYWltZWQgTFAgZmVlcy4gUmV0dXJucyB0aGUgdG90YWwgcGFpZCBvdXQuAAAAFGNsYWltX3Bvb2xfcmVtYWluZGVyAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAACwAAAAM=", + "AAAAAAAAAGVBZmZlY3RzIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbDsgZXhpc3RpbmcgbWFya2V0cyBrZWVwIHRoZQpzcGxpdCB0aGV5IHNuYXBzaG90dGVkIGF0IGNyZWF0aW9uLgAAAAAAABpzZXRfcHJvdG9jb2xfZmVlX3NoYXJlX2JwcwAAAAAAAQAAAAAAAAAJc2hhcmVfYnBzAAAAAAAABAAAAAEAAAPpAAAAAgAAAAM=" ]), options ) } public readonly fromJSON = { buy: this.txFromJSON>, sell: this.txFromJSON>, + buy_v2: this.txFromJSON>, redeem: this.txFromJSON>, resolve: this.txFromJSON>, + sell_v2: this.txFromJSON>, get_price: this.txFromJSON>, quote_buy: this.txFromJSON>, get_config: this.txFromJSON>, @@ -311,13 +509,21 @@ export class Client extends ContractClient { quote_sell: this.txFromJSON>, set_paused: this.txFromJSON>, sweep_fees: this.txFromJSON>, + would_trip: this.txFromJSON>, + transfer_lp: this.txFromJSON>, void_market: this.txFromJSON>, get_position: this.txFromJSON, market_count: this.txFromJSON, set_resolver: this.txFromJSON>, set_treasury: this.txFromJSON>, + breaker_state: this.txFromJSON>>, + claim_lp_fees: this.txFromJSON>, create_market: this.txFromJSON>, + breaker_config: this.txFromJSON>>, + transfer_position: this.txFromJSON>, + set_default_breaker: this.txFromJSON>, set_default_fee_bps: this.txFromJSON>, - claim_pool_remainder: this.txFromJSON> + claim_pool_remainder: this.txFromJSON>, + set_protocol_fee_share_bps: this.txFromJSON> } } \ No newline at end of file diff --git a/oracledesk-stellar/packages/bindings/resolver/src/index.ts b/oracledesk-stellar/packages/bindings/resolver/src/index.ts index f62f4b4..742b003 100644 --- a/oracledesk-stellar/packages/bindings/resolver/src/index.ts +++ b/oracledesk-stellar/packages/bindings/resolver/src/index.ts @@ -33,8 +33,6 @@ if (typeof window !== "undefined") { -export type Outcome = {tag: "Yes", values: void} | {tag: "No", values: void}; - export const Errors = { 1: {message:"NotInitialized"}, 2: {message:"Unauthorized"}, @@ -81,6 +79,8 @@ export type ResolutionState = {tag: "Unconfigured", values: void} | {tag: "Signe +export type Outcome = {tag: "Yes", values: void} | {tag: "No", values: void}; + export interface Client { /** * Construct and simulate a state transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -154,22 +154,22 @@ export class Client extends ContractClient { } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec([ "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA", - "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAEgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAABdNYXJrZXRBbHJlYWR5Q29uZmlndXJlZAAAAAAEAAAAAAAAABNNYXJrZXROb3RDb25maWd1cmVkAAAAAAUAAAAAAAAACVdyb25nTW9kZQAAAAAAAAYAAAAAAAAAD01hcmtldE5vdENsb3NlZAAAAAAHAAAAAAAAABBQcmljZVVuYXZhaWxhYmxlAAAACAAAAAAAAAAKU3RhbGVQcmljZQAAAAAACQAAAAAAAAAMSW52YWxpZFByaWNlAAAACgAAAAAAAAAJTm90U2lnbmVyAAAAAAAACwAAAAAAAAAPQWxyZWFkeUF0dGVzdGVkAAAAAAwAAAAAAAAAE1RocmVzaG9sZE5vdFJlYWNoZWQAAAAADQAAAAAAAAANRGlzcHV0ZUFjdGl2ZQAAAAAAAA4AAAAAAAAAEURpc3B1dGVXaW5kb3dPcGVuAAAAAAAADwAAAAAAAAAKTm9Qcm9wb3NhbAAAAAAAEAAAAAAAAAAQQWxyZWFkeUZpbmFsaXplZAAAABEAAAAAAAAAEFNwZWNIYXNoTWlzbWF0Y2gAAAAS", - "AAAAAAAAAAAAAAAFc3RhdGUAAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAB9AAAAAPUmVzb2x1dGlvblN0YXRlAA==", - "AAAAAAAAAAAAAAAGYXR0ZXN0AAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAZzaWduZXIAAAAAABMAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAEAAAPpAAAAAgAAAAM=", + new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAEgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAABdNYXJrZXRBbHJlYWR5Q29uZmlndXJlZAAAAAAEAAAAAAAAABNNYXJrZXROb3RDb25maWd1cmVkAAAAAAUAAAAAAAAACVdyb25nTW9kZQAAAAAAAAYAAAAAAAAAD01hcmtldE5vdENsb3NlZAAAAAAHAAAAAAAAABBQcmljZVVuYXZhaWxhYmxlAAAACAAAAAAAAAAKU3RhbGVQcmljZQAAAAAACQAAAAAAAAAMSW52YWxpZFByaWNlAAAACgAAAAAAAAAJTm90U2lnbmVyAAAAAAAACwAAAAAAAAAPQWxyZWFkeUF0dGVzdGVkAAAAAAwAAAAAAAAAE1RocmVzaG9sZE5vdFJlYWNoZWQAAAAADQAAAAAAAAANRGlzcHV0ZUFjdGl2ZQAAAAAAAA4AAAAAAAAAEURpc3B1dGVXaW5kb3dPcGVuAAAAAAAADwAAAAAAAAAKTm9Qcm9wb3NhbAAAAAAAEAAAAAAAAAAQQWxyZWFkeUZpbmFsaXplZAAAABEAAAAAAAAAEFNwZWNIYXNoTWlzbWF0Y2gAAAAS", "AAAAAgAAAAAAAAAAAAAAC09yYWNsZUFzc2V0AAAAAAIAAAABAAAAAAAAAAdTdGVsbGFyAAAAAAEAAAATAAAAAQAAAAAAAAAFT3RoZXIAAAAAAAABAAAAEQ==", "AAAAAQAAAAAAAAAAAAAAC1ByaWNlQ29uZmlnAAAAAAUAAAAAAAAABWFzc2V0AAAAAAAH0AAAAAtPcmFjbGVBc3NldAAAAAAAAAAACWRpcmVjdGlvbgAAAAAAB9AAAAAOUHJpY2VEaXJlY3Rpb24AAAAAAAAAAAANbWF4X3N0YWxlbmVzcwAAAAAAAAYAAAAAAAAACXJlZmxlY3RvcgAAAAAAABMAAAAAAAAACXRocmVzaG9sZAAAAAAAAAs=", "AAAAAQAAAAAAAAAAAAAADFNpZ25lckNvbmZpZwAAAAMAAAAAAAAADmRpc3B1dGVfd2luZG93AAAAAAAGAAAAAAAAAAdzaWduZXJzAAAAA+oAAAATAAAAAAAAAAl0aHJlc2hvbGQAAAAAAAAE", "AAAAAgAAAAAAAAAAAAAADlByaWNlRGlyZWN0aW9uAAAAAAAEAAAAAAAAAAAAAAAFQWJvdmUAAAAAAAAAAAAAAAAAAAlBdE9yQWJvdmUAAAAAAAAAAAAAAAAAAAVCZWxvdwAAAAAAAAAAAAAAAAAACUF0T3JCZWxvdwAAAA==", - "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAIAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAALbWFya2V0X2NvcmUAAAAAEwAAAAA=", - "AAAAAAAAAAAAAAANcmVzb2x2ZV9wcmljZQAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD", "AAAAAgAAAAAAAAAAAAAAD1Jlc29sdXRpb25TdGF0ZQAAAAADAAAAAAAAAAAAAAAMVW5jb25maWd1cmVkAAAAAAAAAAAAAAAOU2lnbmVyc1BlbmRpbmcAAAAAAAAAAAAAAAAACUZpbmFsaXplZAAAAA==", "AAAABQAAAAAAAAAAAAAAD1ByaWNlUmVzb2x1dGlvbgAAAAABAAAAEHByaWNlX3Jlc29sdXRpb24AAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAFcHJpY2UAAAAAAAALAAAAAAAAAAAAAAAPcHJpY2VfdGltZXN0YW1wAAAAAAYAAAAAAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAg==", - "AAAAAAAAAMVHdWFyZGlhbiBhY3Rpb246IHdpdGhkcmF3cyBhbiB1bmZpbmFsaXplZCBzaWduZXIgcHJvcG9zYWwsIGUuZy4gYmVjYXVzZQpldmlkZW5jZSBzdXJmYWNlZCBkdXJpbmcgdGhlIGRpc3B1dGUgd2luZG93IHRoYXQgaXQgaXMgd3JvbmcuIFZvdGluZwpjYW4gcmVzdGFydCBvbmNlIHRoZSBjb3VudHMgYXJlIGNsZWFyZWQgYnkgcmUtYXR0ZXN0aW5nLgAAAAAAAA9jYW5jZWxfcHJvcG9zYWwAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAAAgAAAAM=", - "AAAAAAAAAAAAAAAQZmluYWxpemVfc2lnbmVycwAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD", "AAAABQAAAAAAAAAAAAAAElJlc29sdXRpb25Qcm9wb3NlZAAAAAAAAQAAABNyZXNvbHV0aW9uX3Byb3Bvc2VkAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAtwcm9wb3NlZF9hdAAAAAAGAAAAAAAAAAI=", "AAAABQAAAAAAAAAAAAAAE1Jlc29sdXRpb25GaW5hbGl6ZWQAAAAAAQAAABRyZXNvbHV0aW9uX2ZpbmFsaXplZAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAxmaW5hbGl6ZWRfYXQAAAAGAAAAAAAAAAI=", + "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA", + "AAAAAAAAAAAAAAAFc3RhdGUAAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAB9AAAAAPUmVzb2x1dGlvblN0YXRlAA==", + "AAAAAAAAAAAAAAAGYXR0ZXN0AAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAZzaWduZXIAAAAAABMAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAEAAAPpAAAAAgAAAAM=", + "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAIAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAALbWFya2V0X2NvcmUAAAAAEwAAAAA=", + "AAAAAAAAAAAAAAANcmVzb2x2ZV9wcmljZQAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD", + "AAAAAAAAAMVHdWFyZGlhbiBhY3Rpb246IHdpdGhkcmF3cyBhbiB1bmZpbmFsaXplZCBzaWduZXIgcHJvcG9zYWwsIGUuZy4gYmVjYXVzZQpldmlkZW5jZSBzdXJmYWNlZCBkdXJpbmcgdGhlIGRpc3B1dGUgd2luZG93IHRoYXQgaXQgaXMgd3JvbmcuIFZvdGluZwpjYW4gcmVzdGFydCBvbmNlIHRoZSBjb3VudHMgYXJlIGNsZWFyZWQgYnkgcmUtYXR0ZXN0aW5nLgAAAAAAAA9jYW5jZWxfcHJvcG9zYWwAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAAAgAAAAM=", + "AAAAAAAAAAAAAAAQZmluYWxpemVfc2lnbmVycwAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD", "AAAAAAAAATpSZXZlYWxzIHRoZSByZXNvbHV0aW9uIHJ1bGUgY29tbWl0dGVkIHRvIGF0IG1hcmtldCBjcmVhdGlvbi4gQW55b25lCm1heSBjYWxsIHRoaXMgKHRoZXJlIGlzIG5vIGFkbWluIGdhdGUpIGJlY2F1c2UgdGhlIG9ubHkgdGhpbmcgdGhhdAptYWtlcyBhIHNwZWMgYWNjZXB0YWJsZSBpcyB0aGF0IGl0IGhhc2hlcyB0byBgbWFya2V0LnJlc29sdXRpb25faGFzaGAK4oCUIHRoZSBjb21taXRtZW50LCBub3QgdGhlIGNhbGxlciwgaXMgdGhlIGF1dGhvcml0eS4gRmFpbHMgaWYgYSBzcGVjCndhcyBhbHJlYWR5IHJlZ2lzdGVyZWQgZm9yIHRoaXMgbWFya2V0LgAAAAAAE3JlZ2lzdGVyX3ByaWNlX3NwZWMAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAEc3BlYwAAB9AAAAALUHJpY2VDb25maWcAAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAMJHdWFyZGlhbiBhY3Rpb246IHZvaWRzIGFuIHVucmVzb2x2YWJsZSBtYXJrZXQgZGlyZWN0bHkgdGhyb3VnaAptYXJrZXQtY29yZS4gVGhlIHJlc29sdmVyIGlzIHRoZSBkaXJlY3QgY2FsbGVyLCBzbyBtYXJrZXQtY29yZSdzCmByZXF1aXJlX3Jlc29sdmVyYCBjaGVjayBwYXNzZXMgdGhlIHNhbWUgd2F5IGByZXNvbHZlX3ByaWNlYCBkb2VzLgAAAAAAFGd1YXJkaWFuX3ZvaWRfbWFya2V0AAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAAAgAAAAM=", "AAAAAAAAAENTZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgc2lnbmVyIG1vZGUuAAAAABRyZWdpc3Rlcl9zaWduZXJfc3BlYwAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABHNwZWMAAAfQAAAADFNpZ25lckNvbmZpZwAAAAEAAAPpAAAAAgAAAAM=" ]), diff --git a/oracledesk-stellar/packages/bindings/treasury/src/index.ts b/oracledesk-stellar/packages/bindings/treasury/src/index.ts index d2e28e5..118fa42 100644 --- a/oracledesk-stellar/packages/bindings/treasury/src/index.ts +++ b/oracledesk-stellar/packages/bindings/treasury/src/index.ts @@ -33,10 +33,6 @@ if (typeof window !== "undefined") { -export type Outcome = {tag: "Yes", values: void} | {tag: "No", values: void}; - -export type Category = {tag: "Crypto", values: void} | {tag: "Macro", values: void} | {tag: "Geopolitics", values: void} | {tag: "Sports", values: void} | {tag: "Culture", values: void} | {tag: "Other", values: void}; - export const Errors = { 1: {message:"NotInitialized"}, 2: {message:"Unauthorized"}, @@ -47,7 +43,8 @@ export const Errors = { 7: {message:"DailyCapExceeded"}, 8: {message:"MarketNotOpen"}, 9: {message:"InsufficientBalance"}, - 10: {message:"Paused"} + 10: {message:"Paused"}, + 11: {message:"NoSuccessor"} } @@ -69,6 +66,12 @@ export interface RiskConfig { + + +export type Outcome = {tag: "Yes", values: void} | {tag: "No", values: void}; + +export type Category = {tag: "Crypto", values: void} | {tag: "Macro", values: void} | {tag: "Geopolitics", values: void} | {tag: "Sports", values: void} | {tag: "Culture", values: void} | {tag: "Other", values: void}; + export interface Client { /** * Construct and simulate a redeem transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -124,11 +127,61 @@ export interface Client { */ daily_usage: (options?: MethodOptions) => Promise>> + /** + * Construct and simulate a agent_buy_v2 transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * `agent_buy` with a per-share limit price (`limit_price_bps`, 0 = none) + * and a deadline (unix seconds, network time). market-core's `buy_v2` + * enforces both; an expired or out-of-limit trade reverts the whole + * invocation, so the treasury's caps are never consumed by a reverted + * trade. + */ + agent_buy_v2: ({market_id, outcome, collateral_in, min_shares_out, limit_price_bps, deadline}: {market_id: u64, outcome: Outcome, collateral_in: i128, min_shares_out: i128, limit_price_bps: u32, deadline: u64}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a agent_sell_v2 transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * `agent_sell` with a per-share limit price (`limit_price_bps`, 0 = no + * limit) and a deadline (unix seconds, network time), both enforced by + * market-core's `sell_v2`. + */ + agent_sell_v2: ({market_id, outcome, collateral_out, max_shares_in, limit_price_bps, deadline}: {market_id: u64, outcome: Outcome, collateral_out: i128, max_shares_in: i128, limit_price_bps: u32, deadline: u64}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a set_successor transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Sets the successor treasury that `migrate_market` hands positions and + * the LP role to. Admin-only; overwrites any previous successor (the + * `SuccessorSet` events are the audit trail). + */ + set_successor: ({successor}: {successor: string}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a migrate_market transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Moves the treasury's position in `market_id` — and the LP role, when + * the treasury is the market's creator and has not claimed the remainder + * yet — to the successor set via `set_successor`, then closes the local + * exposure. Admin-only: relocating capital is not an agent trading + * action, and the agent key must not be able to drain positions. + * + * market-core's `transfer_position` / `transfer_lp` require the treasury + * itself to authorize. Because the treasury is their *direct* caller + * here, the implicit invoker authorization covers both — no + * `authorize_as_current_contract` entry is needed (unlike the nested + * token transfer in `agent_buy`, see `docs/adr/0002-treasury-nested-auth.md`). + */ + migrate_market: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a available_capital transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ available_capital: (options?: MethodOptions) => Promise>> + /** + * Construct and simulate a agent_claim_lp_fees transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Claims the market's LP fee share for a market the treasury created. + * The LP portion accrues separately on market-core (see its `claim_lp_fees`); + * this is that claim, agent-gated like the other trading entrypoints. + */ + agent_claim_lp_fees: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a agent_create_market transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Creates and seeds a market using treasury capital. The treasury @@ -138,7 +191,12 @@ export interface Client { /** * Construct and simulate a collect_market_fees transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - * Pulls accrued fees from a configured market-core contract. + * Pulls accrued **protocol** fees from a configured market-core contract. + * + * Only the protocol's share of each market's fees: the LP portion accrues + * separately on market-core and belongs to the market's creator — for + * agent-created markets that is the treasury itself, via + * `agent_claim_lp_fees`. */ collect_market_fees: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> @@ -168,13 +226,18 @@ export class Client extends ContractClient { } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec([ "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA", + new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAACwAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAAA1JbnZhbGlkQW1vdW50AAAAAAAABAAAAAAAAAAQVHJhZGVDYXBFeGNlZWRlZAAAAAUAAAAAAAAAEU1hcmtldENhcEV4Y2VlZGVkAAAAAAAABgAAAAAAAAAQRGFpbHlDYXBFeGNlZWRlZAAAAAcAAAAAAAAADU1hcmtldE5vdE9wZW4AAAAAAAAIAAAAAAAAABNJbnN1ZmZpY2llbnRCYWxhbmNlAAAAAAkAAAAAAAAABlBhdXNlZAAAAAAACgAAAAAAAAALTm9TdWNjZXNzb3IAAAAACw==", + "AAAAAQAAAAAAAAAAAAAACkRhaWx5VXNhZ2UAAAAAAAIAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAJZGF5X3N0YXJ0AAAAAAAABg==", + "AAAAAQAAAAAAAAAAAAAAClJpc2tDb25maWcAAAAAAAYAAAAAAAAABWFnZW50AAAAAAAAEwAAAAAAAAAKY29sbGF0ZXJhbAAAAAAAEwAAAAAAAAALbWFya2V0X2NvcmUAAAAAEwAAAAAAAAAJbWF4X2RhaWx5AAAAAAAACwAAAAAAAAAKbWF4X21hcmtldAAAAAAACwAAAAAAAAAJbWF4X3RyYWRlAAAAAAAACw==", + "AAAABQAAAAAAAAAAAAAADFN1Y2Nlc3NvclNldAAAAAEAAAANc3VjY2Vzc29yX3NldAAAAAAAAAIAAAAAAAAAA29sZAAAAAPoAAAAEwAAAAAAAAAAAAAAA25ldwAAAAATAAAAAAAAAAI=", + "AAAABQAAAAAAAAAAAAAADUZlZXNDb2xsZWN0ZWQAAAAAAAABAAAADmZlZXNfY29sbGVjdGVkAAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", + "AAAABQAAAAAAAAAAAAAADk1hcmtldE1pZ3JhdGVkAAAAAAABAAAAD21hcmtldF9taWdyYXRlZAAAAAAFAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAJc3VjY2Vzc29yAAAAAAAAEwAAAAEAAAAAAAAAA3llcwAAAAALAAAAAAAAAAAAAAACbm8AAAAAAAsAAAAAAAAAAAAAAAJscAAAAAAAAQAAAAAAAAAC", + "AAAABQAAAAAAAAAAAAAADlBvc2l0aW9uQ2xvc2VkAAAAAAABAAAAD3Bvc2l0aW9uX2Nsb3NlZAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", + "AAAABQAAAAAAAAAAAAAAD1RyYWRlQXV0aG9yaXplZAAAAAABAAAAEHRyYWRlX2F1dGhvcml6ZWQAAAAFAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAFYWdlbnQAAAAAAAATAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAAAAAAMbWFya2V0X3RvdGFsAAAACwAAAAAAAAAAAAAAC2RhaWx5X3RvdGFsAAAAAAsAAAAAAAAAAg==", + "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA", "AAAAAgAAAAAAAAAAAAAACENhdGVnb3J5AAAABgAAAAAAAAAAAAAABkNyeXB0bwAAAAAAAAAAAAAAAAAFTWFjcm8AAAAAAAAAAAAAAAAAAAtHZW9wb2xpdGljcwAAAAAAAAAAAAAAAAZTcG9ydHMAAAAAAAAAAAAAAAAAB0N1bHR1cmUAAAAAAAAAAAAAAAAFT3RoZXIAAAA=", - "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAACgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAAA1JbnZhbGlkQW1vdW50AAAAAAAABAAAAAAAAAAQVHJhZGVDYXBFeGNlZWRlZAAAAAUAAAAAAAAAEU1hcmtldENhcEV4Y2VlZGVkAAAAAAAABgAAAAAAAAAQRGFpbHlDYXBFeGNlZWRlZAAAAAcAAAAAAAAADU1hcmtldE5vdE9wZW4AAAAAAAAIAAAAAAAAABNJbnN1ZmZpY2llbnRCYWxhbmNlAAAAAAkAAAAAAAAABlBhdXNlZAAAAAAACg==", "AAAAAAAAADxSZWRlZW1zIHRoZSB0cmVhc3VyeSdzIHBvc2l0aW9uIGluIGEgcmVzb2x2ZWQvdm9pZGVkIG1hcmtldC4AAAAGcmVkZWVtAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAAAAAAAHZGVwb3NpdAAAAAACAAAAAAAAAARmcm9tAAAAEwAAAAAAAAAGYW1vdW50AAAAAAALAAAAAQAAA+kAAAACAAAAAw==", - "AAAAAQAAAAAAAAAAAAAACkRhaWx5VXNhZ2UAAAAAAAIAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAJZGF5X3N0YXJ0AAAAAAAABg==", - "AAAAAQAAAAAAAAAAAAAAClJpc2tDb25maWcAAAAAAAYAAAAAAAAABWFnZW50AAAAAAAAEwAAAAAAAAAKY29sbGF0ZXJhbAAAAAAAEwAAAAAAAAALbWFya2V0X2NvcmUAAAAAEwAAAAAAAAAJbWF4X2RhaWx5AAAAAAAACwAAAAAAAAAKbWF4X21hcmtldAAAAAAACwAAAAAAAAAJbWF4X3RyYWRlAAAAAAAACw==", "AAAAAAAAAAAAAAAId2l0aGRyYXcAAAACAAAAAAAAAAJ0bwAAAAAAEwAAAAAAAAAGYW1vdW50AAAAAAALAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAENCdXlzIGBvdXRjb21lYCBzaGFyZXMgaW4gYW4gZXhpc3RpbmcgbWFya2V0IHVzaW5nIHRyZWFzdXJ5IGNhcGl0YWwuAAAAAAlhZ2VudF9idXkAAAAAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADWNvbGxhdGVyYWxfaW4AAAAAAAALAAAAAAAAAA5taW5fc2hhcmVzX291dAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAPVTZWxscyBgb3V0Y29tZWAgc2hhcmVzIGJhY2sgaW50byB0aGUgcG9vbCBmb3IgZXhhY3RseQpgY29sbGF0ZXJhbF9vdXRgLiBUaGlzIHJldHVybnMgY2FwaXRhbCB0byB0aGUgdHJlYXN1cnkgKG1hcmtldC1jb3JlCnBheXMgb3V0IG9mIGl0cyBvd24gYmFsYW5jZSksIHNvIGl0IGlzIG5vdCBjYXBwZWQgYW5kIGRvZXMgbm90IG5lZWQKdGhlIG5lc3RlZC10cmFuc2ZlciBwcmUtYXV0aG9yaXphdGlvbiB0aGF0IGJ1eWluZyBkb2VzLgAAAAAAAAphZ2VudF9zZWxsAAAAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADmNvbGxhdGVyYWxfb3V0AAAAAAALAAAAAAAAAA1tYXhfc2hhcmVzX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", @@ -182,13 +245,15 @@ export class Client extends ContractClient { "AAAAAAAAAAAAAAAKc2V0X3BhdXNlZAAAAAAAAQAAAAAAAAAGcGF1c2VkAAAAAAABAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAK1NYXJrcyBhIG1hcmtldCdzIGV4cG9zdXJlIGFzIGV4aXRlZC4gUHVyZSBib29ra2VlcGluZyAoc2VlCmBNYXJrZXRFeHBvc3VyZWApIGZvciB0aGUgZnV0dXJlIGV4cG9zdXJlIGRhc2hib2FyZDsgaXQgZG9lcyBub3QgbW92ZQpmdW5kcyBvciBmcmVlIHRoZSBjdW11bGF0aXZlIHBlci1tYXJrZXQgY2FwLgAAAAAAAAtjbG9zZV90cmFkZQAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAAAAAAALZGFpbHlfdXNhZ2UAAAAAAAAAAAEAAAPpAAAH0AAAAApEYWlseVVzYWdlAAAAAAAD", - "AAAABQAAAAAAAAAAAAAADUZlZXNDb2xsZWN0ZWQAAAAAAAABAAAADmZlZXNfY29sbGVjdGVkAAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", - "AAAABQAAAAAAAAAAAAAADlBvc2l0aW9uQ2xvc2VkAAAAAAABAAAAD3Bvc2l0aW9uX2Nsb3NlZAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", + "AAAAAAAAARdgYWdlbnRfYnV5YCB3aXRoIGEgcGVyLXNoYXJlIGxpbWl0IHByaWNlIChgbGltaXRfcHJpY2VfYnBzYCwgMCA9IG5vbmUpCmFuZCBhIGRlYWRsaW5lICh1bml4IHNlY29uZHMsIG5ldHdvcmsgdGltZSkuIG1hcmtldC1jb3JlJ3MgYGJ1eV92MmAKZW5mb3JjZXMgYm90aDsgYW4gZXhwaXJlZCBvciBvdXQtb2YtbGltaXQgdHJhZGUgcmV2ZXJ0cyB0aGUgd2hvbGUKaW52b2NhdGlvbiwgc28gdGhlIHRyZWFzdXJ5J3MgY2FwcyBhcmUgbmV2ZXIgY29uc3VtZWQgYnkgYSByZXZlcnRlZAp0cmFkZS4AAAAADGFnZW50X2J1eV92MgAAAAYAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAAAAAANY29sbGF0ZXJhbF9pbgAAAAAAAAsAAAAAAAAADm1pbl9zaGFyZXNfb3V0AAAAAAALAAAAAAAAAA9saW1pdF9wcmljZV9icHMAAAAABAAAAAAAAAAIZGVhZGxpbmUAAAAGAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAcAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAFYWdlbnQAAAAAAAATAAAAAAAAAApjb2xsYXRlcmFsAAAAAAATAAAAAAAAAAttYXJrZXRfY29yZQAAAAATAAAAAAAAAAltYXhfdHJhZGUAAAAAAAALAAAAAAAAAAptYXhfbWFya2V0AAAAAAALAAAAAAAAAAltYXhfZGFpbHkAAAAAAAALAAAAAA==", - "AAAABQAAAAAAAAAAAAAAD1RyYWRlQXV0aG9yaXplZAAAAAABAAAAEHRyYWRlX2F1dGhvcml6ZWQAAAAFAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAFYWdlbnQAAAAAAAATAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAAAAAAMbWFya2V0X3RvdGFsAAAACwAAAAAAAAAAAAAAC2RhaWx5X3RvdGFsAAAAAAsAAAAAAAAAAg==", + "AAAAAAAAAKJgYWdlbnRfc2VsbGAgd2l0aCBhIHBlci1zaGFyZSBsaW1pdCBwcmljZSAoYGxpbWl0X3ByaWNlX2Jwc2AsIDAgPSBubwpsaW1pdCkgYW5kIGEgZGVhZGxpbmUgKHVuaXggc2Vjb25kcywgbmV0d29yayB0aW1lKSwgYm90aCBlbmZvcmNlZCBieQptYXJrZXQtY29yZSdzIGBzZWxsX3YyYC4AAAAAAA1hZ2VudF9zZWxsX3YyAAAAAAAABgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA5jb2xsYXRlcmFsX291dAAAAAAACwAAAAAAAAANbWF4X3NoYXJlc19pbgAAAAAAAAsAAAAAAAAAD2xpbWl0X3ByaWNlX2JwcwAAAAAEAAAAAAAAAAhkZWFkbGluZQAAAAYAAAABAAAD6QAAAAsAAAAD", + "AAAAAAAAALRTZXRzIHRoZSBzdWNjZXNzb3IgdHJlYXN1cnkgdGhhdCBgbWlncmF0ZV9tYXJrZXRgIGhhbmRzIHBvc2l0aW9ucyBhbmQKdGhlIExQIHJvbGUgdG8uIEFkbWluLW9ubHk7IG92ZXJ3cml0ZXMgYW55IHByZXZpb3VzIHN1Y2Nlc3NvciAodGhlCmBTdWNjZXNzb3JTZXRgIGV2ZW50cyBhcmUgdGhlIGF1ZGl0IHRyYWlsKS4AAAANc2V0X3N1Y2Nlc3NvcgAAAAAAAAEAAAAAAAAACXN1Y2Nlc3NvcgAAAAAAABMAAAABAAAD6QAAAAIAAAAD", + "AAAAAAAAAqxNb3ZlcyB0aGUgdHJlYXN1cnkncyBwb3NpdGlvbiBpbiBgbWFya2V0X2lkYCDigJQgYW5kIHRoZSBMUCByb2xlLCB3aGVuCnRoZSB0cmVhc3VyeSBpcyB0aGUgbWFya2V0J3MgY3JlYXRvciBhbmQgaGFzIG5vdCBjbGFpbWVkIHRoZSByZW1haW5kZXIKeWV0IOKAlCB0byB0aGUgc3VjY2Vzc29yIHNldCB2aWEgYHNldF9zdWNjZXNzb3JgLCB0aGVuIGNsb3NlcyB0aGUgbG9jYWwKZXhwb3N1cmUuIEFkbWluLW9ubHk6IHJlbG9jYXRpbmcgY2FwaXRhbCBpcyBub3QgYW4gYWdlbnQgdHJhZGluZwphY3Rpb24sIGFuZCB0aGUgYWdlbnQga2V5IG11c3Qgbm90IGJlIGFibGUgdG8gZHJhaW4gcG9zaXRpb25zLgoKbWFya2V0LWNvcmUncyBgdHJhbnNmZXJfcG9zaXRpb25gIC8gYHRyYW5zZmVyX2xwYCByZXF1aXJlIHRoZSB0cmVhc3VyeQppdHNlbGYgdG8gYXV0aG9yaXplLiBCZWNhdXNlIHRoZSB0cmVhc3VyeSBpcyB0aGVpciAqZGlyZWN0KiBjYWxsZXIKaGVyZSwgdGhlIGltcGxpY2l0IGludm9rZXIgYXV0aG9yaXphdGlvbiBjb3ZlcnMgYm90aCDigJQgbm8KYGF1dGhvcml6ZV9hc19jdXJyZW50X2NvbnRyYWN0YCBlbnRyeSBpcyBuZWVkZWQgKHVubGlrZSB0aGUgbmVzdGVkCnRva2VuIHRyYW5zZmVyIGluIGBhZ2VudF9idXlgLCBzZWUgYGRvY3MvYWRyLzAwMDItdHJlYXN1cnktbmVzdGVkLWF1dGgubWRgKS4AAAAObWlncmF0ZV9tYXJrZXQAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAAAAAAARYXZhaWxhYmxlX2NhcGl0YWwAAAAAAAAAAAAAAQAAA+kAAAALAAAAAw==", + "AAAAAAAAANNDbGFpbXMgdGhlIG1hcmtldCdzIExQIGZlZSBzaGFyZSBmb3IgYSBtYXJrZXQgdGhlIHRyZWFzdXJ5IGNyZWF0ZWQuClRoZSBMUCBwb3J0aW9uIGFjY3J1ZXMgc2VwYXJhdGVseSBvbiBtYXJrZXQtY29yZSAoc2VlIGl0cyBgY2xhaW1fbHBfZmVlc2ApOwp0aGlzIGlzIHRoYXQgY2xhaW0sIGFnZW50LWdhdGVkIGxpa2UgdGhlIG90aGVyIHRyYWRpbmcgZW50cnlwb2ludHMuAAAAABNhZ2VudF9jbGFpbV9scF9mZWVzAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", "AAAAAAAAAGBDcmVhdGVzIGFuZCBzZWVkcyBhIG1hcmtldCB1c2luZyB0cmVhc3VyeSBjYXBpdGFsLiBUaGUgdHJlYXN1cnkKYmVjb21lcyB0aGUgbWFya2V0J3MgY3JlYXRvci9MUC4AAAATYWdlbnRfY3JlYXRlX21hcmtldAAAAAAHAAAAAAAAAA1xdWVzdGlvbl9oYXNoAAAAAAAD7gAAACAAAAAAAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAAAAAAAIbWV0YV91cmkAAAAQAAAAAAAAAAhjYXRlZ29yeQAAB9AAAAAIQ2F0ZWdvcnkAAAAAAAAACmNsb3NlX3RpbWUAAAAAAAYAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAD2luaXRpYWxfeWVzX2JwcwAAAAAEAAAAAQAAA+kAAAAGAAAAAw==", - "AAAAAAAAADpQdWxscyBhY2NydWVkIGZlZXMgZnJvbSBhIGNvbmZpZ3VyZWQgbWFya2V0LWNvcmUgY29udHJhY3QuAAAAAAATY29sbGVjdF9tYXJrZXRfZmVlcwAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", + "AAAAAAAAASRQdWxscyBhY2NydWVkICoqcHJvdG9jb2wqKiBmZWVzIGZyb20gYSBjb25maWd1cmVkIG1hcmtldC1jb3JlIGNvbnRyYWN0LgoKT25seSB0aGUgcHJvdG9jb2wncyBzaGFyZSBvZiBlYWNoIG1hcmtldCdzIGZlZXM6IHRoZSBMUCBwb3J0aW9uIGFjY3J1ZXMKc2VwYXJhdGVseSBvbiBtYXJrZXQtY29yZSBhbmQgYmVsb25ncyB0byB0aGUgbWFya2V0J3MgY3JlYXRvciDigJQgZm9yCmFnZW50LWNyZWF0ZWQgbWFya2V0cyB0aGF0IGlzIHRoZSB0cmVhc3VyeSBpdHNlbGYsIHZpYQpgYWdlbnRfY2xhaW1fbHBfZmVlc2AuAAAAE2NvbGxlY3RfbWFya2V0X2ZlZXMAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAD9DbGFpbXMgdGhlIExQIHBvb2wgcmVtYWluZGVyIGZvciBhIG1hcmtldCB0aGUgdHJlYXN1cnkgY3JlYXRlZC4AAAAAFGNsYWltX3Bvb2xfcmVtYWluZGVyAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAACwAAAAM=" ]), options ) @@ -203,7 +268,12 @@ export class Client extends ContractClient { set_paused: this.txFromJSON>, close_trade: this.txFromJSON>, daily_usage: this.txFromJSON>, + agent_buy_v2: this.txFromJSON>, + agent_sell_v2: this.txFromJSON>, + set_successor: this.txFromJSON>, + migrate_market: this.txFromJSON>, available_capital: this.txFromJSON>, + agent_claim_lp_fees: this.txFromJSON>, agent_create_market: this.txFromJSON>, collect_market_fees: this.txFromJSON>, claim_pool_remainder: this.txFromJSON> diff --git a/oracledesk-stellar/scripts/deploy-testnet.sh b/oracledesk-stellar/scripts/deploy-testnet.sh index fbe1076..da34d30 100755 --- a/oracledesk-stellar/scripts/deploy-testnet.sh +++ b/oracledesk-stellar/scripts/deploy-testnet.sh @@ -25,6 +25,10 @@ MAX_TRADE=${MAX_TRADE:-2000000000} # 200 USDC MAX_MARKET=${MAX_MARKET:-5000000000} # 500 USDC MAX_DAILY=${MAX_DAILY:-10000000000} # 1,000 USDC DEFAULT_FEE_BPS=${DEFAULT_FEE_BPS:-100} # 1% +# Portion of each fee kept by the protocol treasury (bps). The remainder +# accrues to the market creator, claimable before resolution. 10000 = the +# protocol keeps everything (pre-#8 behavior). +PROTOCOL_FEE_SHARE_BPS=${PROTOCOL_FEE_SHARE_BPS:-10000} log() { echo "==> $*" >&2; } @@ -110,7 +114,8 @@ if [ -z "$MARKET_CORE" ]; then --collateral "$USDC" \ --treasury "$DEPLOYER" \ --resolver "$DEPLOYER" \ - --default_fee_bps "$DEFAULT_FEE_BPS") + --default_fee_bps "$DEFAULT_FEE_BPS" \ + --protocol_fee_share_bps "$PROTOCOL_FEE_SHARE_BPS") json_set --arg id "$MARKET_CORE" '.contracts.market_core = $id' else log "market-core already deployed: $MARKET_CORE" diff --git a/oracledesk-stellar/scripts/gen-bindings.sh b/oracledesk-stellar/scripts/gen-bindings.sh index d2e5493..219a5b9 100755 --- a/oracledesk-stellar/scripts/gen-bindings.sh +++ b/oracledesk-stellar/scripts/gen-bindings.sh @@ -32,10 +32,23 @@ mkdir -p "$OUT_DIR" for wasm_name in "${!NAMES[@]}"; do pkg_name=${NAMES[$wasm_name]} echo "==> generating bindings for $pkg_name" >&2 + # `--overwrite` deletes files it does not generate, including the committed + # package-lock.json — which would make CI's "bindings are stale" diff fail on + # every run. Stash the lockfile around the call so regeneration touches only + # the generated files. + lock="" + if [ -f "$OUT_DIR/$pkg_name/package-lock.json" ]; then + lock=$(mktemp) + cp "$OUT_DIR/$pkg_name/package-lock.json" "$lock" + fi stellar contract bindings typescript \ --wasm "$WASM_DIR/$wasm_name.wasm" \ --output-dir "$OUT_DIR/$pkg_name" \ --overwrite + if [ -n "$lock" ]; then + cp "$lock" "$OUT_DIR/$pkg_name/package-lock.json" + rm -f "$lock" + fi # The CLI names the package after the wasm (e.g. "treasury"); give it a # stable scoped name so agents/ and any frontend can depend on it # unambiguously (`file:../packages/bindings/` in package.json). diff --git a/oracledesk-stellar/scripts/migrate-treasury.sh b/oracledesk-stellar/scripts/migrate-treasury.sh new file mode 100755 index 0000000..9c8602d --- /dev/null +++ b/oracledesk-stellar/scripts/migrate-treasury.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Migrate an old OracleDesk treasury deployment to a fresh one, one market +# at a time. Writes nothing until the new treasury is deployed; re-running +# is safe (already-migrated markets have empty positions and are skipped by +# the on-chain logic). +# +# What it does, against the `testnet` network only (hardcoded, like +# deploy-testnet.sh — this script never touches mainnet): +# 1. reads the old treasury + market-core addresses from +# deployments/testnet.json and the old treasury's RiskConfig on-chain +# (agent, collateral, caps), +# 2. deploys a new treasury with the same config, +# 3. calls set_successor(new) on the OLD treasury, +# 4. calls migrate_market(i) on the old treasury for every i in +# 0..market_count, moving each market's position and (if still held) +# LP ownership to the new treasury. +# +# It does NOT rewire market-core (`set_treasury`) — that is a separate, +# deliberate cutover step, because it changes the fee recipient for future +# markets. The recorded successor id lands in +# deployments/testnet.json under `.contracts.treasury_successor`. +# +# Requires: stellar CLI, jq. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +NETWORK=testnet +DEPLOYER_KEY=oracledesk-deployer +OUT=deployments/testnet.json +WASM_DIR=target/wasm32v1-none/release + +log() { echo "==> $*" >&2; } + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "error: '$1' is required but not installed" >&2 + exit 1 + } +} +require_cmd stellar +require_cmd jq + +[ -f "$OUT" ] || { + echo "error: $OUT not found — run scripts/deploy-testnet.sh first" >&2 + exit 1 +} +json_get() { jq -r "$1 // empty" "$OUT"; } +json_set() { + local tmp + tmp=$(mktemp) + jq "$@" "$OUT" >"$tmp" && mv "$tmp" "$OUT" +} + +OLD_TREASURY=$(json_get .contracts.treasury) +MARKET_CORE=$(json_get .contracts.market_core) +[ -n "$OLD_TREASURY" ] && [ -n "$MARKET_CORE" ] || { + echo "error: $OUT has no contracts.treasury / contracts.market_core" >&2 + exit 1 +} +log "old treasury: $OLD_TREASURY" +log "market-core: $MARKET_CORE" + +[ -f "$WASM_DIR/treasury.wasm" ] || { + log "missing $WASM_DIR/treasury.wasm — running stellar contract build first" + stellar contract build +} + +# --- read the old treasury's live config ------------------------------------ +CONFIG=$(stellar contract invoke --id "$OLD_TREASURY" --source-account "$DEPLOYER_KEY" \ + --network "$NETWORK" -- get_config) +AGENT=$(jq -r '.agent' <<<"$CONFIG") +COLLATERAL=$(jq -r '.collateral' <<<"$CONFIG") +CONFIG_MARKET_CORE=$(jq -r '.market_core' <<<"$CONFIG") +MAX_TRADE=$(jq -r '.max_trade' <<<"$CONFIG") +MAX_MARKET=$(jq -r '.max_market' <<<"$CONFIG") +MAX_DAILY=$(jq -r '.max_daily' <<<"$CONFIG") +if [ "$CONFIG_MARKET_CORE" != "$MARKET_CORE" ]; then + log "warning: old treasury points at $CONFIG_MARKET_CORE, $OUT records $MARKET_CORE" +fi +DEPLOYER=$(stellar keys address "$DEPLOYER_KEY") + +# --- deploy the successor with the same config ------------------------------ +NEW_TREASURY=$(json_get .contracts.treasury_successor) +if [ -z "$NEW_TREASURY" ]; then + log "deploying successor treasury (same agent/collateral/caps)" + NEW_TREASURY=$(stellar contract deploy \ + --wasm "$WASM_DIR/treasury.wasm" \ + --source-account "$DEPLOYER_KEY" \ + --network "$NETWORK" \ + -- \ + --admin "$DEPLOYER" \ + --agent "$AGENT" \ + --collateral "$COLLATERAL" \ + --market_core "$MARKET_CORE" \ + --max_trade "$MAX_TRADE" \ + --max_market "$MAX_MARKET" \ + --max_daily "$MAX_DAILY") + json_set --arg id "$NEW_TREASURY" '.contracts.treasury_successor = $id' +else + log "successor treasury already recorded: $NEW_TREASURY" +fi + +# --- record the successor on the old treasury -------------------------------- +SET_FOR=$(json_get .migration.successor_set_for) +if [ "$SET_FOR" != "$NEW_TREASURY" ]; then + log "old treasury: set_successor($NEW_TREASURY)" + stellar contract invoke --id "$OLD_TREASURY" --source-account "$DEPLOYER_KEY" \ + --network "$NETWORK" -- set_successor --successor "$NEW_TREASURY" + json_set --arg id "$NEW_TREASURY" '.migration.successor_set_for = $id' +else + log "old treasury already points at the successor" +fi + +# --- migrate every market, market by market ---------------------------------- +MARKET_COUNT=$(stellar contract invoke --id "$MARKET_CORE" --source-account "$DEPLOYER_KEY" \ + --network "$NETWORK" -- market_count) +log "migrating $MARKET_COUNT market(s)" +i=0 +while [ "$i" -lt "$MARKET_COUNT" ]; do + log "migrate_market($i)" + stellar contract invoke --id "$OLD_TREASURY" --source-account "$DEPLOYER_KEY" \ + --network "$NETWORK" -- migrate_market --market_id "$i" + i=$((i + 1)) +done +json_set --arg n "$MARKET_COUNT" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '.migration.markets_migrated = ($n | tonumber) | .migration.completed_at = $ts' + +log "done. Successor: $NEW_TREASURY" +log "next steps (deliberate, operator-owned):" +log " 1. market-core.set_treasury($NEW_TREASURY) to cut fees over for future markets" +log " 2. update .contracts.treasury in $OUT and restart the agents against it"