From 79efc79381b710454948a720b6936338c665c1a6 Mon Sep 17 00:00:00 2001 From: Teescom Date: Fri, 16 Oct 2026 14:22:47 +0100 Subject: [PATCH 1/8] feat: close-time-aware storage TTL policy for all four contracts (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soroban archives entries when their TTL lapses, and an archived entry is unreadable until restored; an archived instance entry makes a contract uncallable. Four defects shared that root cause: - market-core bumped Market/Position to a fixed 60 days regardless of close_time, so a market closing further out could archive before it could be resolved and the resolver's get_market cross-call would fail. - resolver/treasury/reasoning-registry never extended instance storage, so Admin/MarketCore/Config/Daily/NextId lived only as long as the deploy-time default TTL. - the resolver extended only Price/Signers; Attestation/Count/Proposal/State were never extended, and a missing Count reads as 0 via unwrap_or(0) — silently resetting an in-progress signer tally. - the registry extended Agent only at register_agent, so an agent publishing for 60+ days was archived and publish_trace failed with AgentNotRegistered. Each contract now carries an identical src/ttl.rs (duplicated because contract crates cannot path-depend on each other — ADR 0002) with the shared policy: per-market entries target close_time + 90-day settlement horizon, clamped to max_ttl(), threshold = target/2 so hot paths rarely rewrite; instance target 30 days, threshold 7 days. - market-core: Market/Position bumps use ttl::market_target(close_time). - resolver: instance bumped via instance_get; all six enumerable per-market keys extended has()-guarded in bump_market_entries after every mutation; Attestation extended where written and, for configured signers, in finalize_signers; state() stays a pure view. - treasury: instance bumped wherever instance storage is read; exposure entries keyed to close_time (create-market arg, or a get_market read). - reasoning-registry: instance bumped on NextId access; Agent uses the long-lived target and is refreshed by every successful publish_trace. - docs/architecture.md documents the policy and links the keeper doc. Regression coverage: signer_vote_entries_survive_dispute_period (tally survives 61 days of silence), market_entry_survives_until_close_plus_horizon, position_ttl_tracks_close_time, instance_survives_after_long_idle, bump_market_entries_skips_missing_keys, instance_ttl_extended_on_agent_buy, agent_entry_extended_on_publish — all advancing sequence and timestamp together and asserting TTLs through testutils. Vendored wasm refresh and bindings regeneration land in the final commit of this branch (one refresh for all four issues). --- .../contracts/market-core/src/lib.rs | 161 +++++++++++--- .../contracts/market-core/src/ttl.rs | 107 ++++++++++ .../contracts/reasoning-registry/src/lib.rs | 117 +++++++++-- .../contracts/reasoning-registry/src/ttl.rs | 107 ++++++++++ .../contracts/resolver/src/lib.rs | 196 ++++++++++++++++-- .../contracts/resolver/src/ttl.rs | 107 ++++++++++ .../contracts/treasury/src/lib.rs | 78 ++++++- .../contracts/treasury/src/ttl.rs | 107 ++++++++++ oracledesk-stellar/docs/architecture.md | 22 ++ 9 files changed, 922 insertions(+), 80 deletions(-) create mode 100644 oracledesk-stellar/contracts/market-core/src/ttl.rs create mode 100644 oracledesk-stellar/contracts/reasoning-registry/src/ttl.rs create mode 100644 oracledesk-stellar/contracts/resolver/src/ttl.rs create mode 100644 oracledesk-stellar/contracts/treasury/src/ttl.rs diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index d8b8a69..5356b1d 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -1,6 +1,7 @@ #![no_std] mod math; +pub mod ttl; use math::{MathError, Settlement, BPS, MAX_SETS}; use soroban_sdk::{ @@ -12,11 +13,6 @@ use soroban_sdk::{ // Constants // --------------------------------------------------------------------------- -/// Ledger close time is ~5s, so 17_280 ledgers ~ 1 day. Tune if that changes. -const DAY_IN_LEDGERS: u32 = 17_280; -const BUMP_THRESHOLD: u32 = 30 * DAY_IN_LEDGERS; -const BUMP_TO: u32 = 60 * DAY_IN_LEDGERS; - /// Fee ceiling: 10%. const MAX_FEE_BPS: u32 = 1_000; /// Minimum seed liquidity: 100 USDC (7 decimals). Stops spam markets. @@ -345,6 +341,7 @@ impl MarketCore { yes: seed.creator_yes, no: seed.creator_no, }, + market.close_time, ); } @@ -393,12 +390,12 @@ impl MarketCore { 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 mut pos = Self::load_position(&env, market_id, &trader); + let mut pos = Self::load_position(&env, market_id, &trader, m.close_time); match outcome { Outcome::Yes => pos.yes += q.shares_out, Outcome::No => pos.no += q.shares_out, } - Self::save_position(&env, market_id, &trader, &pos); + Self::save_position(&env, market_id, &trader, &pos, m.close_time); Self::save_market(&env, market_id, &m); // interaction @@ -442,7 +439,7 @@ impl MarketCore { return Err(Error::SlippageExceeded); } - let mut pos = Self::load_position(&env, market_id, &trader); + let mut pos = Self::load_position(&env, market_id, &trader, m.close_time); let held = match outcome { Outcome::Yes => pos.yes, Outcome::No => pos.no, @@ -456,7 +453,7 @@ impl MarketCore { Outcome::Yes => pos.yes -= q.shares_in, Outcome::No => pos.no -= q.shares_in, } - Self::save_position(&env, market_id, &trader, &pos); + Self::save_position(&env, market_id, &trader, &pos, m.close_time); Self::set_reserves(&mut m, outcome, q.new_reserve_sold, q.new_reserve_other); m.sets_minted -= q.gross; m.fees_accrued = m.fees_accrued.checked_add(q.fee).ok_or(Error::Overflow)?; @@ -528,13 +525,19 @@ impl MarketCore { holder.require_auth(); let m = Self::load_market(&env, market_id)?; let kind = Self::settlement(&m)?; - let pos = Self::load_position(&env, market_id, &holder); + let pos = Self::load_position(&env, market_id, &holder, m.close_time); if pos.yes == 0 && pos.no == 0 { return Err(Error::NothingToRedeem); } let payout = math::settle_value(kind, pos.yes, pos.no); - Self::save_position(&env, market_id, &holder, &Position { yes: 0, no: 0 }); + Self::save_position( + &env, + market_id, + &holder, + &Position { yes: 0, no: 0 }, + m.close_time, + ); if payout > 0 { Self::token(&env)?.transfer(&env.current_contract_address(), &holder, &payout); } @@ -604,7 +607,14 @@ impl MarketCore { } pub fn get_position(env: Env, market_id: u64, holder: Address) -> Position { - Self::load_position(&env, market_id, &holder) + // The TTL of the position entry tracks the market's close time. With + // no market to anchor to (a stale id), fall back to the settlement + // horizon from now so the entry at least keeps that much cover. + let close_time = match Self::load_market(&env, market_id) { + Ok(m) => m.close_time, + Err(_) => env.ledger().timestamp(), + }; + Self::load_position(&env, market_id, &holder, close_time) } /// Spot price of `outcome` in bps (0..=10_000). YES + NO ~ 10_000. @@ -652,7 +662,7 @@ impl MarketCore { env: &Env, key: &DataKey, ) -> Result { - env.storage().instance().extend_ttl(BUMP_THRESHOLD, BUMP_TO); + ttl::bump_instance(env); env.storage() .instance() .get(key) @@ -731,27 +741,21 @@ impl MarketCore { .persistent() .get(&key) .ok_or(Error::MarketNotFound)?; - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(env, &key, ttl::market_target(env, m.close_time)); Ok(m) } fn save_market(env: &Env, id: u64, m: &Market) { let key = DataKey::Market(id); env.storage().persistent().set(&key, m); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(env, &key, ttl::market_target(env, m.close_time)); } - fn load_position(env: &Env, id: u64, holder: &Address) -> Position { + fn load_position(env: &Env, id: u64, holder: &Address, close_time: u64) -> Position { let key = DataKey::Position(id, holder.clone()); match env.storage().persistent().get::<_, Position>(&key) { Some(p) => { - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); p } None => Position { yes: 0, no: 0 }, @@ -759,15 +763,13 @@ impl MarketCore { } /// Empty positions are removed so storage does not grow with dust entries. - fn save_position(env: &Env, id: u64, holder: &Address, p: &Position) { + fn save_position(env: &Env, id: u64, holder: &Address, p: &Position, close_time: u64) { let key = DataKey::Position(id, holder.clone()); if p.yes == 0 && p.no == 0 { env.storage().persistent().remove(&key); } else { env.storage().persistent().set(&key, p); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); } } } @@ -776,7 +778,7 @@ impl MarketCore { mod test { use super::*; use soroban_sdk::{ - testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}, + testutils::{storage::Persistent as _, Address as _, Ledger, MockAuth, MockAuthInvoke}, token::StellarAssetClient, IntoVal, }; @@ -1143,4 +1145,107 @@ mod test { .try_resolve(&market_id, &Outcome::Yes) .is_err()); } + + // ----- TTL policy ------------------------------------------------------ + + /// Advances both clocks together, as they move on a real network + /// (one ledger per `ttl::LEDGER_SECS`). + fn advance_ledgers(env: &Env, secs: u64) { + assert_eq!(secs % ttl::LEDGER_SECS, 0); + env.ledger() + .set_sequence_number(env.ledger().sequence() + (secs / ttl::LEDGER_SECS) as u32); + env.ledger().set_timestamp(env.ledger().timestamp() + secs); + } + + fn persistent_ttl(env: &Env, contract: &Address, key: &DataKey) -> u32 { + env.as_contract(contract, || env.storage().persistent().get_ttl(key)) + } + + fn create_market_closing_in( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + secs_from_now: u64, + ) -> u64 { + client.create_market( + creator, + &BytesN::from_array(env, &[21u8; 32]), + &BytesN::from_array(env, &[121u8; 32]), + &String::from_str(env, "ipfs://ttl-policy"), + &Category::Other, + &(env.ledger().timestamp() + secs_from_now), + &(100 * 10_000_000_i128), + &5000u32, + ) + } + + #[test] + fn market_entry_survives_until_close_plus_horizon() { + let (env, client, creator, _, _, _) = setup_market(); + // Current network limits: persistent entries may live at most 180 + // days; the minimum is deliberately left low so the measured TTL is + // the contract's own bump, not the network floor. + env.ledger().set_max_entry_ttl(3_110_400); + env.ledger().set_min_persistent_entry_ttl(4_096); + + let market_id = create_market_closing_in(&env, &client, &creator, 90 * ttl::DAY_SECS); + let key = DataKey::Market(market_id); + // 90 days to close + the 90-day settlement horizon, at the 180-day cap. + assert!(persistent_ttl(&env, &client.address, &key) >= 3_110_399); + + // The old fixed 60-day bump let the entry die at day 60, twenty days + // before this market could even close. Lay idle past that, then past + // close, and resolution must still work. + advance_ledgers(&env, 80 * ttl::DAY_SECS); + assert!(persistent_ttl(&env, &client.address, &key) > 0); + advance_ledgers(&env, 11 * ttl::DAY_SECS); + + client.resolve(&market_id, &Outcome::Yes); + assert_eq!( + client.get_market(&market_id).status, + MarketStatus::Resolved(Outcome::Yes) + ); + } + + #[test] + fn position_ttl_tracks_close_time() { + let (env, client, creator, trader, _, _) = setup_market(); + env.ledger().set_max_entry_ttl(3_110_400); + env.ledger().set_min_persistent_entry_ttl(4_096); + + let short = create_market_closing_in(&env, &client, &creator, 10 * ttl::DAY_SECS); + let long = create_market_closing_in(&env, &client, &creator, 100 * ttl::DAY_SECS); + client.buy( + &trader, + &short, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + client.buy( + &trader, + &long, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + + let short_ttl = persistent_ttl( + &env, + &client.address, + &DataKey::Position(short, trader.clone()), + ); + let long_ttl = persistent_ttl( + &env, + &client.address, + &DataKey::Position(long, trader.clone()), + ); + + // 10 days to close + 90-day horizon = 100 days. + assert_eq!(short_ttl, 100 * ttl::DAY_IN_LEDGERS); + // 100 days to close exceeds the 180-day cap only together with the + // horizon, so the long market is clamped at the maximum. + assert!(long_ttl >= 3_110_399); + assert!(long_ttl > short_ttl); + } } diff --git a/oracledesk-stellar/contracts/market-core/src/ttl.rs b/oracledesk-stellar/contracts/market-core/src/ttl.rs new file mode 100644 index 0000000..0debebd --- /dev/null +++ b/oracledesk-stellar/contracts/market-core/src/ttl.rs @@ -0,0 +1,107 @@ +//! Close-time-aware storage TTL policy. +//! +//! Soroban archives a ledger entry once its TTL (in ledgers) runs out. An +//! archived persistent entry cannot be read or written until someone submits +//! a restore operation, and an archived **instance** entry makes the whole +//! contract uncallable until restored. Every contract in this workspace +//! therefore has to keep its entries alive at least as long as they can be +//! needed; this module is that policy. +//! +//! It is duplicated in all four contract crates because a contract crate +//! cannot depend on another contract crate (their `#[contractimpl]` wasm +//! exports collide on `__constructor`; see +//! `docs/adr/0002-cross-contract-calls.md`), and the copies must stay +//! byte-identical below this header. +//! +//! Policy +//! - A market's entries are kept until `close_time + SETTLEMENT_HORIZON`. +//! Under a fixed 60-day bump a market closing more than 60 days out could +//! archive before it closed, so the resolver's `get_market` cross-call +//! failed until someone restored it. +//! - Every target is clamped to `env.storage().max_ttl()` — never hardcode +//! network limits. Mainnet and testnet are both at 3_110_400 ledgers +//! (180 days) today, so a market further than 90 days from close is capped +//! at the network maximum and needs activity or the keeper +//! (`docs/wave-issues/14-storage-ttl-keeper.md`) to outlive one bump. +//! - Persistent entries are only re-extended once their remaining TTL has +//! dropped below half the target (`threshold_for`), so a busy hot path +//! does not pay an extension write on every call. +//! +//! Rent: an extension rents `ceil(bytes / 1024) * ledgers * +//! rent_fee_per_1kb(live_state_size) / persistent_rent_rate_denominator` +//! (CAP-0066; denominator 1215, per-1KB rate interpolates from a 1_000 +//! stroop floor to 10_000 as live Soroban state grows toward its target). +//! One 1-KB entry costs roughly 0.04-0.43 XLM per 30 days, so covering one +//! entry for the whole 90-day horizon costs roughly 0.13-1.28 XLM — cheap +//! next to a market that archives before it can be resolved, which blocks +//! resolution until a restore operation (also rent) is submitted. + +use soroban_sdk::Env; + +/// Stellar's target ledger close time. `DAY_IN_LEDGERS` assumes 5 s; if the +/// network ever re-targets close time, both values change together. +pub const LEDGER_SECS: u64 = 5; +pub const DAY_SECS: u64 = 86_400; +pub const DAY_IN_LEDGERS: u32 = (DAY_SECS / LEDGER_SECS) as u32; + +/// Instance storage is bumped on every entrypoint: extend once fewer than +/// 7 days remain, out to 30. A month of cover keeps a contract invocable +/// through normal lulls while bounding the rent a single call can pay. +pub const INSTANCE_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; +pub const INSTANCE_TARGET: u32 = 30 * DAY_IN_LEDGERS; + +/// Time a market may need after `close_time`: resolution, a signer dispute +/// window, and holder redemptions. +pub const SETTLEMENT_HORIZON_SECS: u64 = 90 * DAY_SECS; + +/// Target TTL for long-lived entries with no market association (registered +/// agents), so a routinely-used entry is never at risk; clamped by +/// `max_ttl()` in practice to the network maximum. +pub const LONG_LIVED_SECS: u64 = 365 * DAY_SECS; + +/// Ledgers from now until `deadline_ts`, clamped to the network maximum. +pub fn secs_to_ledgers_clamped(env: &Env, secs: u64) -> u32 { + let ledgers = secs / LEDGER_SECS; + core::cmp::min(ledgers, env.storage().max_ttl() as u64) as u32 +} + +/// How long a market's storage must live: until `close_time` plus the +/// settlement horizon, clamped to `max_ttl()`. +pub fn market_target(env: &Env, close_time: u64) -> u32 { + let now = env.ledger().timestamp(); + let until_close = close_time.saturating_sub(now); + secs_to_ledgers_clamped(env, until_close.saturating_add(SETTLEMENT_HORIZON_SECS)) +} + +/// Target TTL for entries with no market association. See `LONG_LIVED_SECS`. +pub fn long_lived_target(env: &Env) -> u32 { + secs_to_ledgers_clamped(env, LONG_LIVED_SECS) +} + +/// Persistent entries are only re-extended when their remaining TTL has +/// fallen below half the target, which keeps touching an entry cheap. +pub fn threshold_for(target: u32) -> u32 { + target / 2 +} + +/// Extends `key` toward `target` ledgers from now, if it exists and its +/// remaining TTL is under `threshold_for(target)`. Extending a missing key +/// panics, so callers must guard with `has()` where the key may be absent. +pub fn bump_persistent(env: &Env, key: &K, target: u32) +where + K: soroban_sdk::IntoVal, +{ + env.storage() + .persistent() + .extend_ttl(key, threshold_for(target), target); +} + +/// Extends instance storage to `INSTANCE_TARGET` when fewer than +/// `INSTANCE_THRESHOLD` ledgers remain. Called from every entrypoint that +/// reads instance storage — an archived instance entry bricks the contract +/// until restored, so this is the cheapest insurance the contract can buy. +pub fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_THRESHOLD, INSTANCE_TARGET); +} diff --git a/oracledesk-stellar/contracts/reasoning-registry/src/lib.rs b/oracledesk-stellar/contracts/reasoning-registry/src/lib.rs index 97a539b..8827f13 100644 --- a/oracledesk-stellar/contracts/reasoning-registry/src/lib.rs +++ b/oracledesk-stellar/contracts/reasoning-registry/src/lib.rs @@ -1,12 +1,16 @@ #![no_std] +pub mod ttl; + use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, Address, BytesN, Env, String, }; -const BUMP_THRESHOLD: u32 = 30 * 17_280; -const BUMP_TO: u32 = 60 * 17_280; +/// Rolling window for trace entries, unchanged from the pre-#16 policy: +/// 60 days, extended at half that. Registration (`Agent`) instead uses +/// `ttl::long_lived_target` and is refreshed by every `publish_trace`. +const TRACE_TARGET: u32 = 60 * ttl::DAY_IN_LEDGERS; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -84,9 +88,7 @@ impl ReasoningRegistry { return Err(Error::AgentAlreadyRegistered); } env.storage().persistent().set(&key, &true); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(&env, &key, ttl::long_lived_target(&env)); AgentRegistered { agent }.publish(&env); Ok(()) } @@ -101,6 +103,8 @@ impl ReasoningRegistry { Ok(()) } + /// Read-only view: no TTL writes, so an indexer's simulation stays cheap. + /// The `Agent` entry is refreshed by `register_agent`/`publish_trace`. pub fn is_registered(env: Env, agent: Address) -> bool { env.storage().persistent().has(&DataKey::Agent(agent)) } @@ -113,14 +117,16 @@ impl ReasoningRegistry { trace_hash: BytesN<32>, ipfs_cid: String, ) -> Result { + ttl::bump_instance(&env); agent.require_auth(); - if !env - .storage() - .persistent() - .has(&DataKey::Agent(agent.clone())) - { + let agent_key = DataKey::Agent(agent.clone()); + if !env.storage().persistent().has(&agent_key) { return Err(Error::AgentNotRegistered); } + // A long-running agent is refreshed here: without this, 60+ days of + // publishing would archive its registration and `publish_trace` + // would fail with `AgentNotRegistered`. + ttl::bump_persistent(&env, &agent_key, ttl::long_lived_target(&env)); if action.is_empty() { return Err(Error::EmptyAction); } @@ -145,21 +151,15 @@ impl ReasoningRegistry { }; let key = DataKey::Trace(id); env.storage().persistent().set(&key, &trace); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(&env, &key, TRACE_TARGET); let count_key = DataKey::MarketTraceCount(market_id); let index: u64 = env.storage().persistent().get(&count_key).unwrap_or(0); let trace_key = DataKey::MarketTrace(market_id, index); env.storage().persistent().set(&trace_key, &id); - env.storage() - .persistent() - .extend_ttl(&trace_key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(&env, &trace_key, TRACE_TARGET); env.storage().persistent().set(&count_key, &(index + 1)); - env.storage() - .persistent() - .extend_ttl(&count_key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(&env, &count_key, TRACE_TARGET); TracePublished { trace_id: id, @@ -174,6 +174,9 @@ impl ReasoningRegistry { Ok(id) } + /// Extends `Trace` on read, as before (a write in a view: simulation + /// footprints are read-write, kept for compatibility and noted in + /// `docs/architecture.md`). pub fn get_trace(env: Env, trace_id: u64) -> Result { let key = DataKey::Trace(trace_id); let trace: Trace = env @@ -181,9 +184,7 @@ impl ReasoningRegistry { .persistent() .get(&key) .ok_or(Error::TraceNotFound)?; - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(&env, &key, TRACE_TARGET); Ok(trace) } @@ -192,7 +193,10 @@ impl ReasoningRegistry { Ok(trace.trace_hash == received_hash) } + /// Reads the instance's `NextId`; the bump keeps the instance (and with + /// it the whole contract) alive under view-only traffic. pub fn trace_count(env: Env) -> u64 { + ttl::bump_instance(&env); env.storage().instance().get(&DataKey::NextId).unwrap_or(0) } @@ -215,6 +219,7 @@ impl ReasoningRegistry { impl ReasoningRegistry { fn require_admin(env: &Env) -> Result<(), Error> { + ttl::bump_instance(env); let admin: Address = env .storage() .instance() @@ -229,7 +234,10 @@ impl ReasoningRegistry { mod test { use super::*; use soroban_sdk::{ - testutils::{Address as _, MockAuth, MockAuthInvoke}, + testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, Ledger, MockAuth, MockAuthInvoke, + }, IntoVal, }; @@ -384,4 +392,67 @@ mod test { ) .is_err()); } + + fn advance_ledgers(env: &Env, secs: u64) { + assert_eq!(secs % ttl::LEDGER_SECS, 0); + env.ledger() + .set_sequence_number(env.ledger().sequence() + (secs / ttl::LEDGER_SECS) as u32); + env.ledger().set_timestamp(env.ledger().timestamp() + secs); + } + + fn persistent_ttl(env: &Env, contract: &Address, key: &DataKey) -> u32 { + env.as_contract(contract, || env.storage().persistent().get_ttl(key)) + } + + fn instance_ttl(env: &Env, contract: &Address) -> u32 { + env.as_contract(contract, || env.storage().instance().get_ttl()) + } + + fn publish_trace_at_day( + client: &ReasoningRegistryClient, + env: &Env, + agent: &Address, + day: u64, + ) { + let trace_hash = BytesN::from_array(env, &[day as u8; 32]); + let cid = String::from_str(env, "ipfs://publish-refresh"); + client.publish_trace( + agent, + &(100 + day), + &String::from_str(env, "trade"), + &trace_hash, + &cid, + ); + } + + #[test] + fn agent_entry_extended_on_publish() { + let (env, client, _, agent) = setup(); + let max_ttl = 3_110_400u32; + env.ledger().set_max_entry_ttl(max_ttl); + env.ledger().set_min_persistent_entry_ttl(4_096); + let agent_key = DataKey::Agent(agent.clone()); + + client.register_agent(&agent); + assert!(persistent_ttl(&env, &client.address, &agent_key) >= max_ttl - 1); + + // Day 0 and day 59 publishes; the day-100 publish only succeeds if the + // registration was not archived in between (it would be past the old + // 60-day window with no `Agent` extension on publish). + publish_trace_at_day(&client, &env, &agent, 0); + advance_ledgers(&env, 59 * ttl::DAY_SECS); + publish_trace_at_day(&client, &env, &agent, 59); + + advance_ledgers(&env, 41 * ttl::DAY_SECS); + let before = persistent_ttl(&env, &client.address, &agent_key); + assert!(before < max_ttl / 2, "refresh was already due at day 100"); + assert!(client.is_registered(&agent)); + + publish_trace_at_day(&client, &env, &agent, 100); + assert!( + persistent_ttl(&env, &client.address, &agent_key) >= max_ttl - 1, + "publish_trace did not refresh the Agent entry" + ); + assert_eq!(instance_ttl(&env, &client.address), ttl::INSTANCE_TARGET); + } } diff --git a/oracledesk-stellar/contracts/reasoning-registry/src/ttl.rs b/oracledesk-stellar/contracts/reasoning-registry/src/ttl.rs new file mode 100644 index 0000000..0debebd --- /dev/null +++ b/oracledesk-stellar/contracts/reasoning-registry/src/ttl.rs @@ -0,0 +1,107 @@ +//! Close-time-aware storage TTL policy. +//! +//! Soroban archives a ledger entry once its TTL (in ledgers) runs out. An +//! archived persistent entry cannot be read or written until someone submits +//! a restore operation, and an archived **instance** entry makes the whole +//! contract uncallable until restored. Every contract in this workspace +//! therefore has to keep its entries alive at least as long as they can be +//! needed; this module is that policy. +//! +//! It is duplicated in all four contract crates because a contract crate +//! cannot depend on another contract crate (their `#[contractimpl]` wasm +//! exports collide on `__constructor`; see +//! `docs/adr/0002-cross-contract-calls.md`), and the copies must stay +//! byte-identical below this header. +//! +//! Policy +//! - A market's entries are kept until `close_time + SETTLEMENT_HORIZON`. +//! Under a fixed 60-day bump a market closing more than 60 days out could +//! archive before it closed, so the resolver's `get_market` cross-call +//! failed until someone restored it. +//! - Every target is clamped to `env.storage().max_ttl()` — never hardcode +//! network limits. Mainnet and testnet are both at 3_110_400 ledgers +//! (180 days) today, so a market further than 90 days from close is capped +//! at the network maximum and needs activity or the keeper +//! (`docs/wave-issues/14-storage-ttl-keeper.md`) to outlive one bump. +//! - Persistent entries are only re-extended once their remaining TTL has +//! dropped below half the target (`threshold_for`), so a busy hot path +//! does not pay an extension write on every call. +//! +//! Rent: an extension rents `ceil(bytes / 1024) * ledgers * +//! rent_fee_per_1kb(live_state_size) / persistent_rent_rate_denominator` +//! (CAP-0066; denominator 1215, per-1KB rate interpolates from a 1_000 +//! stroop floor to 10_000 as live Soroban state grows toward its target). +//! One 1-KB entry costs roughly 0.04-0.43 XLM per 30 days, so covering one +//! entry for the whole 90-day horizon costs roughly 0.13-1.28 XLM — cheap +//! next to a market that archives before it can be resolved, which blocks +//! resolution until a restore operation (also rent) is submitted. + +use soroban_sdk::Env; + +/// Stellar's target ledger close time. `DAY_IN_LEDGERS` assumes 5 s; if the +/// network ever re-targets close time, both values change together. +pub const LEDGER_SECS: u64 = 5; +pub const DAY_SECS: u64 = 86_400; +pub const DAY_IN_LEDGERS: u32 = (DAY_SECS / LEDGER_SECS) as u32; + +/// Instance storage is bumped on every entrypoint: extend once fewer than +/// 7 days remain, out to 30. A month of cover keeps a contract invocable +/// through normal lulls while bounding the rent a single call can pay. +pub const INSTANCE_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; +pub const INSTANCE_TARGET: u32 = 30 * DAY_IN_LEDGERS; + +/// Time a market may need after `close_time`: resolution, a signer dispute +/// window, and holder redemptions. +pub const SETTLEMENT_HORIZON_SECS: u64 = 90 * DAY_SECS; + +/// Target TTL for long-lived entries with no market association (registered +/// agents), so a routinely-used entry is never at risk; clamped by +/// `max_ttl()` in practice to the network maximum. +pub const LONG_LIVED_SECS: u64 = 365 * DAY_SECS; + +/// Ledgers from now until `deadline_ts`, clamped to the network maximum. +pub fn secs_to_ledgers_clamped(env: &Env, secs: u64) -> u32 { + let ledgers = secs / LEDGER_SECS; + core::cmp::min(ledgers, env.storage().max_ttl() as u64) as u32 +} + +/// How long a market's storage must live: until `close_time` plus the +/// settlement horizon, clamped to `max_ttl()`. +pub fn market_target(env: &Env, close_time: u64) -> u32 { + let now = env.ledger().timestamp(); + let until_close = close_time.saturating_sub(now); + secs_to_ledgers_clamped(env, until_close.saturating_add(SETTLEMENT_HORIZON_SECS)) +} + +/// Target TTL for entries with no market association. See `LONG_LIVED_SECS`. +pub fn long_lived_target(env: &Env) -> u32 { + secs_to_ledgers_clamped(env, LONG_LIVED_SECS) +} + +/// Persistent entries are only re-extended when their remaining TTL has +/// fallen below half the target, which keeps touching an entry cheap. +pub fn threshold_for(target: u32) -> u32 { + target / 2 +} + +/// Extends `key` toward `target` ledgers from now, if it exists and its +/// remaining TTL is under `threshold_for(target)`. Extending a missing key +/// panics, so callers must guard with `has()` where the key may be absent. +pub fn bump_persistent(env: &Env, key: &K, target: u32) +where + K: soroban_sdk::IntoVal, +{ + env.storage() + .persistent() + .extend_ttl(key, threshold_for(target), target); +} + +/// Extends instance storage to `INSTANCE_TARGET` when fewer than +/// `INSTANCE_THRESHOLD` ledgers remain. Called from every entrypoint that +/// reads instance storage — an archived instance entry bricks the contract +/// until restored, so this is the cheapest insurance the contract can buy. +pub fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_THRESHOLD, INSTANCE_TARGET); +} diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index 211cfba..3415b16 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -1,5 +1,7 @@ #![no_std] +pub mod ttl; + use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, xdr::ToXdr, Address, BytesN, Env, Symbol, Vec, @@ -21,9 +23,6 @@ mod market_core { } use market_core::{Client as MarketCoreClient, Outcome}; -const BUMP_THRESHOLD: u32 = 30 * 17_280; -const BUMP_TO: u32 = 60 * 17_280; - #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum OracleAsset { @@ -182,16 +181,15 @@ impl Resolver { if spec.threshold < 0 || spec.max_staleness == 0 { return Err(Error::InvalidConfig); } - Self::assert_matches_commitment(&env, market_id, &ResolutionSpec::Price(spec.clone()))?; + let close_time = + Self::assert_matches_commitment(&env, market_id, &ResolutionSpec::Price(spec.clone()))?; let key = DataKey::Price(market_id); env.storage().persistent().set(&key, &spec); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); env.storage() .persistent() .set(&DataKey::State(market_id), &ResolutionState::Unconfigured); + Self::bump_market_entries(&env, market_id, close_time); Ok(()) } @@ -205,16 +203,18 @@ impl Resolver { { return Err(Error::InvalidConfig); } - Self::assert_matches_commitment(&env, market_id, &ResolutionSpec::Signers(spec.clone()))?; + let close_time = Self::assert_matches_commitment( + &env, + market_id, + &ResolutionSpec::Signers(spec.clone()), + )?; let key = DataKey::Signers(market_id); env.storage().persistent().set(&key, &spec); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); env.storage() .persistent() .set(&DataKey::State(market_id), &ResolutionState::SignersPending); + Self::bump_market_entries(&env, market_id, close_time); Ok(()) } @@ -227,7 +227,9 @@ impl Resolver { if !env.storage().persistent().has(&key) { return Err(Error::NoProposal); } + let close_time = Self::market_close_time(&env, market_id)?; env.storage().persistent().remove(&key); + Self::bump_market_entries(&env, market_id, close_time); Ok(()) } @@ -240,10 +242,12 @@ impl Resolver { return Err(Error::AlreadyFinalized); } let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; + let market = MarketCoreClient::new(&env, &market_core).get_market(&market_id); MarketCoreClient::new(&env, &market_core).void_market(&market_id); env.storage() .persistent() .set(&DataKey::State(market_id), &ResolutionState::Finalized); + Self::bump_market_entries(&env, market_id, market.close_time); Ok(()) } @@ -299,6 +303,7 @@ impl Resolver { env.storage() .persistent() .set(&DataKey::State(market_id), &ResolutionState::Finalized); + Self::bump_market_entries(&env, market_id, market.close_time); PriceResolution { market_id, price: data.price, @@ -335,9 +340,12 @@ impl Resolver { if env.ledger().timestamp() < market.close_time { return Err(Error::MarketNotClosed); } - env.storage().persistent().set( - &DataKey::Attestation(market_id, outcome.clone(), signer), - &true, + let attestation_key = DataKey::Attestation(market_id, outcome.clone(), signer); + env.storage().persistent().set(&attestation_key, &true); + ttl::bump_persistent( + &env, + &attestation_key, + ttl::market_target(&env, market.close_time), ); let count_key = DataKey::Count(market_id, outcome.clone()); let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); @@ -358,6 +366,7 @@ impl Resolver { } .publish(&env); } + Self::bump_market_entries(&env, market_id, market.close_time); Ok(()) } @@ -377,10 +386,19 @@ impl Resolver { return Err(Error::ThresholdNotReached); } let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; + let market = MarketCoreClient::new(&env, &market_core).get_market(&market_id); + let target = ttl::market_target(&env, market.close_time); + for signer in config.signers.iter() { + let key = DataKey::Attestation(market_id, proposal.outcome.clone(), signer); + if env.storage().persistent().has(&key) { + ttl::bump_persistent(&env, &key, target); + } + } MarketCoreClient::new(&env, &market_core).resolve(&market_id, &proposal.outcome); env.storage() .persistent() .set(&DataKey::State(market_id), &ResolutionState::Finalized); + Self::bump_market_entries(&env, market_id, market.close_time); ResolutionFinalized { market_id, outcome: proposal.outcome.clone(), @@ -390,6 +408,9 @@ impl Resolver { Ok(proposal.outcome) } + /// Pure view: no TTL extensions. The instance and the market's entries + /// are kept alive by the mutating entrypoints above, so a view-only + /// simulation stays read-only. pub fn state(env: Env, market_id: u64) -> ResolutionState { Self::resolution_state(&env, market_id) } @@ -400,6 +421,7 @@ impl Resolver { env: &Env, key: DataKey, ) -> Result { + ttl::bump_instance(env); env.storage() .instance() .get(&key) @@ -447,12 +469,14 @@ impl Resolver { Ok(()) } - /// Verifies `spec` hashes to the market's `resolution_hash` commitment. + /// Verifies `spec` hashes to the market's `resolution_hash` commitment and + /// returns the market's `close_time`, which callers need for TTL bumps — + /// reusing this market-core call avoids a second `get_market` cross-call. fn assert_matches_commitment( env: &Env, market_id: u64, spec: &ResolutionSpec, - ) -> Result<(), Error> { + ) -> Result { let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; let market = MarketCoreClient::new(env, &market_core).get_market(&market_id); let bytes = spec.clone().to_xdr(env); @@ -460,7 +484,37 @@ impl Resolver { if hash != market.resolution_hash { return Err(Error::SpecHashMismatch); } - Ok(()) + Ok(market.close_time) + } + + fn market_close_time(env: &Env, market_id: u64) -> Result { + let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; + Ok(MarketCoreClient::new(env, &market_core) + .get_market(&market_id) + .close_time) + } + + /// Extends every per-market entry to the market's close-time-aware target. + /// Called after each mutating entrypoint so an in-progress vote or a + /// finalized market cannot archive mid-settlement. `Attestation` entries + /// are not enumerable — each is extended where it is written, plus the + /// configured signers' in `finalize_signers` — so they are handled + /// separately. Absent keys are skipped: `extend_ttl` panics on them. + fn bump_market_entries(env: &Env, market_id: u64, close_time: u64) { + let target = ttl::market_target(env, close_time); + let keys = [ + DataKey::Price(market_id), + DataKey::Signers(market_id), + DataKey::Proposal(market_id), + DataKey::State(market_id), + DataKey::Count(market_id, Outcome::Yes), + DataKey::Count(market_id, Outcome::No), + ]; + for key in keys { + if env.storage().persistent().has(&key) { + ttl::bump_persistent(env, &key, target); + } + } } } @@ -470,7 +524,10 @@ mod test { use market_core::{Category, MarketStatus}; use soroban_sdk::{ contract, contractimpl, - testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}, + testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, Ledger, MockAuth, MockAuthInvoke, + }, token::StellarAssetClient, BytesN, IntoVal, }; @@ -701,4 +758,107 @@ mod test { .is_err()); let _ = f.admin; } + + fn advance_ledgers(env: &Env, secs: u64) { + assert_eq!(secs % ttl::LEDGER_SECS, 0); + env.ledger() + .set_sequence_number(env.ledger().sequence() + (secs / ttl::LEDGER_SECS) as u32); + env.ledger().set_timestamp(env.ledger().timestamp() + secs); + } + + fn persistent_ttl(env: &Env, contract: &Address, key: &DataKey) -> u32 { + env.as_contract(contract, || env.storage().persistent().get_ttl(key)) + } + + fn instance_ttl(env: &Env, contract: &Address) -> u32 { + env.as_contract(contract, || env.storage().instance().get_ttl()) + } + + #[test] + fn signer_vote_entries_survive_dispute_period() { + let f = fixture(|env, _r, a, b| ResolutionSpec::Signers(signer_spec(env, a, b))); + f.env.ledger().set_max_entry_ttl(3_110_400); + f.env.ledger().set_min_persistent_entry_ttl(4_096); + let ResolutionSpec::Signers(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_signer_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + f.resolver.attest(&f.market_id, &f.signer_a, &Outcome::No); + + // 61 days of silence — past the old 60-day cap and past any default + // persistent TTL. Without the write-time extension the tally would be + // archived here and the second attest would read `Count` == 0. + advance_ledgers(&f.env, 61 * ttl::DAY_SECS); + let count_key = DataKey::Count(f.market_id, Outcome::No); + assert!( + persistent_ttl(&f.env, &f.resolver_id, &count_key) > 0, + "vote tally archived during the dispute period" + ); + + f.resolver.attest(&f.market_id, &f.signer_b, &Outcome::No); + let count: Option = f.env.as_contract(&f.resolver_id, || { + f.env.storage().persistent().get(&count_key) + }); + assert_eq!(count, Some(2), "tally silently reset after archival"); + + f.env + .ledger() + .set_timestamp(f.env.ledger().timestamp() + 200); + assert_eq!(f.resolver.finalize_signers(&f.market_id), Outcome::No); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::Resolved(Outcome::No) + ); + } + + #[test] + fn instance_survives_after_long_idle() { + let f = fixture(|env, _r, a, b| ResolutionSpec::Signers(signer_spec(env, a, b))); + f.env.ledger().set_max_entry_ttl(3_110_400); + f.env.ledger().set_min_persistent_entry_ttl(4_096); + let ResolutionSpec::Signers(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_signer_spec(&f.market_id, &spec); + assert_eq!(instance_ttl(&f.env, &f.resolver_id), ttl::INSTANCE_TARGET); + + // 25 days idle drains the instance TTL below the 7-day threshold... + advance_ledgers(&f.env, 25 * ttl::DAY_SECS); + assert!(instance_ttl(&f.env, &f.resolver_id) < ttl::INSTANCE_THRESHOLD); + + // ...and the next entrypoint call re-extends it. + f.resolver.attest(&f.market_id, &f.signer_a, &Outcome::No); + assert_eq!(instance_ttl(&f.env, &f.resolver_id), ttl::INSTANCE_TARGET); + } + + #[test] + fn bump_market_entries_skips_missing_keys() { + let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); + f.env.ledger().set_max_entry_ttl(3_110_400); + f.env.ledger().set_min_persistent_entry_ttl(4_096); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_price_spec(&f.market_id, &spec); + + // Price and State exist; Signers, Proposal and both Counts do not. + // The sweep must skip absent keys — `extend_ttl` traps on them. + f.env.as_contract(&f.resolver_id, || { + Resolver::bump_market_entries(&f.env, f.market_id, 2_000); + }); + + let horizon_ledgers = (ttl::SETTLEMENT_HORIZON_SECS / ttl::LEDGER_SECS) as u32; + assert!( + persistent_ttl(&f.env, &f.resolver_id, &DataKey::Price(f.market_id)) >= horizon_ledgers + ); + assert!( + persistent_ttl(&f.env, &f.resolver_id, &DataKey::State(f.market_id)) >= horizon_ledgers + ); + assert!(!f.env.as_contract(&f.resolver_id, || f + .env + .storage() + .persistent() + .has(&DataKey::Signers(f.market_id)))); + } } diff --git a/oracledesk-stellar/contracts/resolver/src/ttl.rs b/oracledesk-stellar/contracts/resolver/src/ttl.rs new file mode 100644 index 0000000..0debebd --- /dev/null +++ b/oracledesk-stellar/contracts/resolver/src/ttl.rs @@ -0,0 +1,107 @@ +//! Close-time-aware storage TTL policy. +//! +//! Soroban archives a ledger entry once its TTL (in ledgers) runs out. An +//! archived persistent entry cannot be read or written until someone submits +//! a restore operation, and an archived **instance** entry makes the whole +//! contract uncallable until restored. Every contract in this workspace +//! therefore has to keep its entries alive at least as long as they can be +//! needed; this module is that policy. +//! +//! It is duplicated in all four contract crates because a contract crate +//! cannot depend on another contract crate (their `#[contractimpl]` wasm +//! exports collide on `__constructor`; see +//! `docs/adr/0002-cross-contract-calls.md`), and the copies must stay +//! byte-identical below this header. +//! +//! Policy +//! - A market's entries are kept until `close_time + SETTLEMENT_HORIZON`. +//! Under a fixed 60-day bump a market closing more than 60 days out could +//! archive before it closed, so the resolver's `get_market` cross-call +//! failed until someone restored it. +//! - Every target is clamped to `env.storage().max_ttl()` — never hardcode +//! network limits. Mainnet and testnet are both at 3_110_400 ledgers +//! (180 days) today, so a market further than 90 days from close is capped +//! at the network maximum and needs activity or the keeper +//! (`docs/wave-issues/14-storage-ttl-keeper.md`) to outlive one bump. +//! - Persistent entries are only re-extended once their remaining TTL has +//! dropped below half the target (`threshold_for`), so a busy hot path +//! does not pay an extension write on every call. +//! +//! Rent: an extension rents `ceil(bytes / 1024) * ledgers * +//! rent_fee_per_1kb(live_state_size) / persistent_rent_rate_denominator` +//! (CAP-0066; denominator 1215, per-1KB rate interpolates from a 1_000 +//! stroop floor to 10_000 as live Soroban state grows toward its target). +//! One 1-KB entry costs roughly 0.04-0.43 XLM per 30 days, so covering one +//! entry for the whole 90-day horizon costs roughly 0.13-1.28 XLM — cheap +//! next to a market that archives before it can be resolved, which blocks +//! resolution until a restore operation (also rent) is submitted. + +use soroban_sdk::Env; + +/// Stellar's target ledger close time. `DAY_IN_LEDGERS` assumes 5 s; if the +/// network ever re-targets close time, both values change together. +pub const LEDGER_SECS: u64 = 5; +pub const DAY_SECS: u64 = 86_400; +pub const DAY_IN_LEDGERS: u32 = (DAY_SECS / LEDGER_SECS) as u32; + +/// Instance storage is bumped on every entrypoint: extend once fewer than +/// 7 days remain, out to 30. A month of cover keeps a contract invocable +/// through normal lulls while bounding the rent a single call can pay. +pub const INSTANCE_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; +pub const INSTANCE_TARGET: u32 = 30 * DAY_IN_LEDGERS; + +/// Time a market may need after `close_time`: resolution, a signer dispute +/// window, and holder redemptions. +pub const SETTLEMENT_HORIZON_SECS: u64 = 90 * DAY_SECS; + +/// Target TTL for long-lived entries with no market association (registered +/// agents), so a routinely-used entry is never at risk; clamped by +/// `max_ttl()` in practice to the network maximum. +pub const LONG_LIVED_SECS: u64 = 365 * DAY_SECS; + +/// Ledgers from now until `deadline_ts`, clamped to the network maximum. +pub fn secs_to_ledgers_clamped(env: &Env, secs: u64) -> u32 { + let ledgers = secs / LEDGER_SECS; + core::cmp::min(ledgers, env.storage().max_ttl() as u64) as u32 +} + +/// How long a market's storage must live: until `close_time` plus the +/// settlement horizon, clamped to `max_ttl()`. +pub fn market_target(env: &Env, close_time: u64) -> u32 { + let now = env.ledger().timestamp(); + let until_close = close_time.saturating_sub(now); + secs_to_ledgers_clamped(env, until_close.saturating_add(SETTLEMENT_HORIZON_SECS)) +} + +/// Target TTL for entries with no market association. See `LONG_LIVED_SECS`. +pub fn long_lived_target(env: &Env) -> u32 { + secs_to_ledgers_clamped(env, LONG_LIVED_SECS) +} + +/// Persistent entries are only re-extended when their remaining TTL has +/// fallen below half the target, which keeps touching an entry cheap. +pub fn threshold_for(target: u32) -> u32 { + target / 2 +} + +/// Extends `key` toward `target` ledgers from now, if it exists and its +/// remaining TTL is under `threshold_for(target)`. Extending a missing key +/// panics, so callers must guard with `has()` where the key may be absent. +pub fn bump_persistent(env: &Env, key: &K, target: u32) +where + K: soroban_sdk::IntoVal, +{ + env.storage() + .persistent() + .extend_ttl(key, threshold_for(target), target); +} + +/// Extends instance storage to `INSTANCE_TARGET` when fewer than +/// `INSTANCE_THRESHOLD` ledgers remain. Called from every entrypoint that +/// reads instance storage — an archived instance entry bricks the contract +/// until restored, so this is the cheapest insurance the contract can buy. +pub fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_THRESHOLD, INSTANCE_TARGET); +} diff --git a/oracledesk-stellar/contracts/treasury/src/lib.rs b/oracledesk-stellar/contracts/treasury/src/lib.rs index 21f1276..1b2221e 100644 --- a/oracledesk-stellar/contracts/treasury/src/lib.rs +++ b/oracledesk-stellar/contracts/treasury/src/lib.rs @@ -1,5 +1,7 @@ #![no_std] +pub mod ttl; + use soroban_sdk::{ auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}, contract, contracterror, contractevent, contractimpl, contracttype, token, vec, Address, @@ -17,8 +19,6 @@ mod market_core { use market_core::{Category, Client as MarketCoreClient, Outcome}; const DAY_SECONDS: u64 = 86_400; -const BUMP_THRESHOLD: u32 = 30 * 17_280; -const BUMP_TO: u32 = 60 * 17_280; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -140,6 +140,7 @@ impl Treasury { } pub fn get_config(env: Env) -> Result { + ttl::bump_instance(&env); env.storage() .instance() .get(&DataKey::Config) @@ -232,7 +233,7 @@ impl Treasury { &seed_amount, &initial_yes_bps, ); - Self::reserve_capacity(&env, &config, market_id, seed_amount)?; + Self::reserve_capacity(&env, &config, market_id, seed_amount, close_time)?; Ok(market_id) } @@ -273,7 +274,13 @@ impl Treasury { &collateral_in, &min_shares_out, ); - Self::reserve_capacity(&env, &config, market_id, collateral_in)?; + // One extra `get_market` read per buy, to key the exposure entry's TTL + // to the market's close time rather than a fixed window (the market + // knows its own `close_time`; the treasury otherwise would not). + let close_time = MarketCoreClient::new(&env, &config.market_core) + .get_market(&market_id) + .close_time; + Self::reserve_capacity(&env, &config, market_id, collateral_in, close_time)?; Ok(shares) } @@ -313,7 +320,10 @@ impl Treasury { } exposure.open = false; let amount = exposure.amount; - Self::save_market(&env, market_id, &exposure); + let close_time = MarketCoreClient::new(&env, &config.market_core) + .get_market(&market_id) + .close_time; + Self::save_market(&env, market_id, &exposure, close_time); PositionClosed { market_id, amount }.publish(&env); Ok(amount) } @@ -345,6 +355,7 @@ impl Treasury { impl Treasury { fn require_admin(env: &Env) -> Result<(), Error> { + ttl::bump_instance(env); let admin: Address = env .storage() .instance() @@ -355,6 +366,7 @@ impl Treasury { } fn assert_not_paused(env: &Env) -> Result<(), Error> { + ttl::bump_instance(env); let paused: bool = env .storage() .instance() @@ -367,6 +379,7 @@ impl Treasury { } fn rolled_daily_usage(env: &Env) -> Result { + ttl::bump_instance(env); let usage: DailyUsage = env .storage() .instance() @@ -390,6 +403,7 @@ impl Treasury { config: &RiskConfig, market_id: u64, amount: i128, + close_time: u64, ) -> Result<(), Error> { let mut usage = Self::rolled_daily_usage(env)?; let mut exposure = Self::market_exposure(env, market_id); @@ -410,7 +424,7 @@ impl Treasury { exposure.amount = new_market; exposure.open = true; usage.amount = new_daily; - Self::save_market(env, market_id, &exposure); + Self::save_market(env, market_id, &exposure, close_time); env.storage().instance().set(&DataKey::Daily, &usage); TradeAuthorized { market_id, @@ -456,12 +470,10 @@ impl Treasury { }) } - fn save_market(env: &Env, market_id: u64, exposure: &MarketExposure) { + fn save_market(env: &Env, market_id: u64, exposure: &MarketExposure, close_time: u64) { let key = DataKey::Market(market_id); env.storage().persistent().set(&key, exposure); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); } } @@ -469,7 +481,10 @@ impl Treasury { mod test { use super::*; use soroban_sdk::{ - testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}, + testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, Ledger, MockAuth, MockAuthInvoke, + }, token::StellarAssetClient, BytesN, String, }; @@ -803,4 +818,45 @@ mod test { .agent_buy(&market_id, &outcome, &amount, &min_out); assert!(shares > 0); } + + fn advance_ledgers(env: &Env, secs: u64) { + assert_eq!(secs % ttl::LEDGER_SECS, 0); + env.ledger() + .set_sequence_number(env.ledger().sequence() + (secs / ttl::LEDGER_SECS) as u32); + env.ledger().set_timestamp(env.ledger().timestamp() + secs); + } + + fn instance_ttl(env: &Env, contract: &Address) -> u32 { + env.as_contract(contract, || env.storage().instance().get_ttl()) + } + + fn persistent_ttl(env: &Env, contract: &Address, key: &DataKey) -> u32 { + env.as_contract(contract, || env.storage().persistent().get_ttl(key)) + } + + #[test] + fn instance_ttl_extended_on_agent_buy() { + let f = fixture(); + f.env.ledger().set_max_entry_ttl(3_110_400); + f.env.ledger().set_min_persistent_entry_ttl(4_096); + + f.treasury + .agent_buy(&f.market_id, &Outcome::Yes, &(20 * 10_000_000_i128), &0); + assert_eq!(instance_ttl(&f.env, &f.treasury_id), ttl::INSTANCE_TARGET); + // The exposure entry's TTL is keyed to the market's close time: + // 30 days until close + the 90-day settlement horizon. + assert_eq!( + persistent_ttl(&f.env, &f.treasury_id, &DataKey::Market(f.market_id)), + 120 * ttl::DAY_IN_LEDGERS + ); + + // 25 days idle drains the instance TTL below the 7-day threshold... + advance_ledgers(&f.env, 25 * ttl::DAY_SECS); + assert!(instance_ttl(&f.env, &f.treasury_id) < ttl::INSTANCE_THRESHOLD); + + // ...and the next trade re-extends it. + f.treasury + .agent_buy(&f.market_id, &Outcome::Yes, &(20 * 10_000_000_i128), &0); + assert_eq!(instance_ttl(&f.env, &f.treasury_id), ttl::INSTANCE_TARGET); + } } diff --git a/oracledesk-stellar/contracts/treasury/src/ttl.rs b/oracledesk-stellar/contracts/treasury/src/ttl.rs new file mode 100644 index 0000000..0debebd --- /dev/null +++ b/oracledesk-stellar/contracts/treasury/src/ttl.rs @@ -0,0 +1,107 @@ +//! Close-time-aware storage TTL policy. +//! +//! Soroban archives a ledger entry once its TTL (in ledgers) runs out. An +//! archived persistent entry cannot be read or written until someone submits +//! a restore operation, and an archived **instance** entry makes the whole +//! contract uncallable until restored. Every contract in this workspace +//! therefore has to keep its entries alive at least as long as they can be +//! needed; this module is that policy. +//! +//! It is duplicated in all four contract crates because a contract crate +//! cannot depend on another contract crate (their `#[contractimpl]` wasm +//! exports collide on `__constructor`; see +//! `docs/adr/0002-cross-contract-calls.md`), and the copies must stay +//! byte-identical below this header. +//! +//! Policy +//! - A market's entries are kept until `close_time + SETTLEMENT_HORIZON`. +//! Under a fixed 60-day bump a market closing more than 60 days out could +//! archive before it closed, so the resolver's `get_market` cross-call +//! failed until someone restored it. +//! - Every target is clamped to `env.storage().max_ttl()` — never hardcode +//! network limits. Mainnet and testnet are both at 3_110_400 ledgers +//! (180 days) today, so a market further than 90 days from close is capped +//! at the network maximum and needs activity or the keeper +//! (`docs/wave-issues/14-storage-ttl-keeper.md`) to outlive one bump. +//! - Persistent entries are only re-extended once their remaining TTL has +//! dropped below half the target (`threshold_for`), so a busy hot path +//! does not pay an extension write on every call. +//! +//! Rent: an extension rents `ceil(bytes / 1024) * ledgers * +//! rent_fee_per_1kb(live_state_size) / persistent_rent_rate_denominator` +//! (CAP-0066; denominator 1215, per-1KB rate interpolates from a 1_000 +//! stroop floor to 10_000 as live Soroban state grows toward its target). +//! One 1-KB entry costs roughly 0.04-0.43 XLM per 30 days, so covering one +//! entry for the whole 90-day horizon costs roughly 0.13-1.28 XLM — cheap +//! next to a market that archives before it can be resolved, which blocks +//! resolution until a restore operation (also rent) is submitted. + +use soroban_sdk::Env; + +/// Stellar's target ledger close time. `DAY_IN_LEDGERS` assumes 5 s; if the +/// network ever re-targets close time, both values change together. +pub const LEDGER_SECS: u64 = 5; +pub const DAY_SECS: u64 = 86_400; +pub const DAY_IN_LEDGERS: u32 = (DAY_SECS / LEDGER_SECS) as u32; + +/// Instance storage is bumped on every entrypoint: extend once fewer than +/// 7 days remain, out to 30. A month of cover keeps a contract invocable +/// through normal lulls while bounding the rent a single call can pay. +pub const INSTANCE_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; +pub const INSTANCE_TARGET: u32 = 30 * DAY_IN_LEDGERS; + +/// Time a market may need after `close_time`: resolution, a signer dispute +/// window, and holder redemptions. +pub const SETTLEMENT_HORIZON_SECS: u64 = 90 * DAY_SECS; + +/// Target TTL for long-lived entries with no market association (registered +/// agents), so a routinely-used entry is never at risk; clamped by +/// `max_ttl()` in practice to the network maximum. +pub const LONG_LIVED_SECS: u64 = 365 * DAY_SECS; + +/// Ledgers from now until `deadline_ts`, clamped to the network maximum. +pub fn secs_to_ledgers_clamped(env: &Env, secs: u64) -> u32 { + let ledgers = secs / LEDGER_SECS; + core::cmp::min(ledgers, env.storage().max_ttl() as u64) as u32 +} + +/// How long a market's storage must live: until `close_time` plus the +/// settlement horizon, clamped to `max_ttl()`. +pub fn market_target(env: &Env, close_time: u64) -> u32 { + let now = env.ledger().timestamp(); + let until_close = close_time.saturating_sub(now); + secs_to_ledgers_clamped(env, until_close.saturating_add(SETTLEMENT_HORIZON_SECS)) +} + +/// Target TTL for entries with no market association. See `LONG_LIVED_SECS`. +pub fn long_lived_target(env: &Env) -> u32 { + secs_to_ledgers_clamped(env, LONG_LIVED_SECS) +} + +/// Persistent entries are only re-extended when their remaining TTL has +/// fallen below half the target, which keeps touching an entry cheap. +pub fn threshold_for(target: u32) -> u32 { + target / 2 +} + +/// Extends `key` toward `target` ledgers from now, if it exists and its +/// remaining TTL is under `threshold_for(target)`. Extending a missing key +/// panics, so callers must guard with `has()` where the key may be absent. +pub fn bump_persistent(env: &Env, key: &K, target: u32) +where + K: soroban_sdk::IntoVal, +{ + env.storage() + .persistent() + .extend_ttl(key, threshold_for(target), target); +} + +/// Extends instance storage to `INSTANCE_TARGET` when fewer than +/// `INSTANCE_THRESHOLD` ledgers remain. Called from every entrypoint that +/// reads instance storage — an archived instance entry bricks the contract +/// until restored, so this is the cheapest insurance the contract can buy. +pub fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_THRESHOLD, INSTANCE_TARGET); +} diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index 045e342..acd5fa5 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -82,6 +82,28 @@ events. An indexer (Soroban events → Postgres, replayable) is required for any UI that wants to list traces/trades older than that window — this is one of the seeded `docs/wave-issues/`. +## Storage TTL policy + +Soroban archives persistent/instance entries once their TTL runs out, and an +archived entry cannot be read or written until it is restored. Every contract +carries an identical `src/ttl.rs` (duplicated because contract crates cannot +depend on each other — [ADR 0002](adr/0002-cross-contract-calls.md)) with the +shared policy: + +- Per-market entries (`Market`, `Position`, resolver configs, treasury + exposure) are extended to `close_time` plus a 90-day settlement horizon, + clamped to the network's `max_ttl()`. The threshold is half the target, so + a frequently touched entry is only rewritten when its remaining TTL halves. +- Instance TTL is extended by every entrypoint that reads instance storage + (30-day target, 7-day threshold); pure views over persistent entries stay + read-only so indexer simulations keep read-only footprints. +- The registry additionally refreshes an agent's registration on every + successful `publish_trace`. +- Markets whose `close_time` is further out than `max_ttl` cannot be kept + alive by a single bump and need periodic touches or the keeper described in + [`docs/wave-issues/14-storage-ttl-keeper.md`](wave-issues/14-storage-ttl-keeper.md), + which also covers TTL monitoring and the rent cost model. + ## Off-chain pieces - **x402/**: an Express service selling reasoning traces for ~$0.01 via From f016b98ba252e3cf1132d74217e42542cb60214b Mon Sep 17 00:00:00 2001 From: Teescom Date: Fri, 16 Oct 2026 14:57:10 +0100 Subject: [PATCH 2/8] feat: outstanding-share tracking and a redemption deadline that reclaims residual collateral (#14) A final market's collateral could sit in market-core forever: nobody could tell 'fully settled' from 'waiting on holders' because there was no running total of user-held shares, and void per-holder floors left rounding dust unclaimable by design. - SettleState side entry (DataKey::Settle(u64)): outstanding_yes/no, finalized_at, horizon_secs, paid_out, closed. Maintained by create_market, buy, sell (now checked_sub on sets_minted), redeem (both sides decrement, even at payout 0) and claim_pool_remainder; resolve/void_market stamp finalized_at. - Per-market redemption horizon snapshotted at creation from an admin default (365 days, bounded to 180..=1825 days via set_default_redeem_horizon). - finalize_market: permissionless after finalized_at + horizon; reserves settle_upper (aggregate floor >= sum of per-holder void floors, proven in math.rs and the Python model) plus any unclaimed LP remainder, then sweeps the market's entire remaining balance - expired claims, dust, remainder, fees - to the treasury and closes the market. Redeem/claim after close return RedemptionClosed; legacy markets without a Settle entry return NotSupported and stay redeemable. - Views settle_state / redeem_deadline; MarketFinalized event; debug_assert of reserve+outstanding==sets on pre-payout transitions. - Docs: architecture.md lifecycle + TTL interaction, STATUS.md limitation. Treasury redeem() doc notes the agent must redeem before the deadline. --- .../contracts/market-core/src/lib.rs | 706 +++++++++++++++++- .../contracts/market-core/src/math.rs | 47 ++ .../contracts/treasury/src/lib.rs | 9 + oracledesk-stellar/docs/STATUS.md | 15 + oracledesk-stellar/docs/architecture.md | 31 +- oracledesk-stellar/model/fpmm_model.py | 28 + 6 files changed, 834 insertions(+), 2 deletions(-) diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 5356b1d..bab599e 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -18,6 +18,15 @@ const MAX_FEE_BPS: u32 = 1_000; /// Minimum seed liquidity: 100 USDC (7 decimals). Stops spam markets. const MIN_SEED: i128 = 100 * 10_000_000; +/// Redemption-window bounds, in seconds. A holder cannot be forced to redeem +/// within weeks of resolution (180-day floor), and a market cannot sit +/// waitable-forever (5-year ceiling). The 365-day default is snapshotted +/// into each market at creation, so an admin change never moves an +/// existing deadline. +const DEFAULT_REDEEM_HORIZON_SECS: u64 = 365 * ttl::DAY_SECS; +const MIN_REDEEM_HORIZON_SECS: u64 = 180 * ttl::DAY_SECS; +const MAX_REDEEM_HORIZON_SECS: u64 = 1_825 * ttl::DAY_SECS; + // --------------------------------------------------------------------------- // Types shared with the frontend/backend // --------------------------------------------------------------------------- @@ -92,6 +101,29 @@ pub struct Position { pub no: i128, } +/// Per-market settlement bookkeeping (issue #14). Lives in its own entry so +/// the `Market` XDR stays unchanged. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SettleState { + /// Outcome shares held outside the pool (user positions), still + /// redeemable. `redeem` decrements the redeeming holder's shares on both + /// sides, so a losing-side redemption (payout 0) still clears here. + pub outstanding_yes: i128, + pub outstanding_no: i128, + /// Timestamp of `resolve` / `void_market`; 0 while the market is Open. + pub finalized_at: u64, + /// Redemption window, snapshotted at creation from the admin default. + /// `redeem_deadline = finalized_at + horizon_secs`. + pub horizon_secs: u64, + /// Collateral already paid to holders and the LP (`redeem` + + /// `claim_pool_remainder` payouts); tracks what has left the market. + pub paid_out: i128, + /// `finalize_market` has swept the market's remaining balance; no + /// redemption or claim can ever pay out again. + pub closed: bool, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Config { @@ -117,9 +149,11 @@ pub enum DataKey { DefaultFeeBps, Paused, NextId, + DefaultRedeemHorizon, // persistent storage (one entry each, own TTL) Market(u64), Position(u64, Address), + Settle(u64), } // --------------------------------------------------------------------------- @@ -148,6 +182,15 @@ pub enum Error { NothingToRedeem = 16, AlreadyClaimed = 17, Overflow = 18, + // 19 is reserved for the TWAP accumulator's `ObservationTooOld` (#11). + /// `finalize_market` called before the redemption deadline. + RedemptionWindowOpen = 20, + /// Market already closed by `finalize_market`; redemption is over. + RedemptionClosed = 21, + /// Legacy market created before `SettleState` existed: outstanding shares + /// cannot be reconstructed on-chain, so it has no deadline and cannot be + /// finalized (still fully redeemable). + NotSupported = 22, } impl From for Error { @@ -215,6 +258,19 @@ pub struct FeesSwept { pub amount: i128, } +#[contractevent] +pub struct MarketFinalized { + #[topic] + pub market_id: u64, + /// Everything swept to the treasury: expired user claims, an unclaimed + /// LP remainder, rounding dust and accrued fees. + pub swept: i128, + /// The part of `swept` that was reserved for shares never redeemed. + pub unclaimed_user: i128, + /// Rounding left over after per-holder void floors were accounted for. + pub dust: i128, +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -244,6 +300,7 @@ impl MarketCore { s.set(&DataKey::DefaultFeeBps, &default_fee_bps); s.set(&DataKey::Paused, &false); s.set(&DataKey::NextId, &0u64); + s.set(&DataKey::DefaultRedeemHorizon, &DEFAULT_REDEEM_HORIZON_SECS); } // ----- admin ---------------------------------------------------------- @@ -278,6 +335,20 @@ impl MarketCore { Ok(()) } + /// Affects markets created after the call; existing markets keep the + /// horizon snapshotted at their creation. Must be within + /// [180 days, 1825 days]. + pub fn set_default_redeem_horizon(env: Env, secs: u64) -> Result<(), Error> { + Self::require_admin(&env)?; + if !(MIN_REDEEM_HORIZON_SECS..=MAX_REDEEM_HORIZON_SECS).contains(&secs) { + return Err(Error::InvalidAmount); + } + env.storage() + .instance() + .set(&DataKey::DefaultRedeemHorizon, &secs); + Ok(()) + } + // ----- market lifecycle ----------------------------------------------- /// Creates a market and seeds the pool. `creator` becomes the sole LP. @@ -315,6 +386,9 @@ impl MarketCore { let fee_bps: u32 = s .get(&DataKey::DefaultFeeBps) .ok_or(Error::NotInitialized)?; + let horizon_secs: u64 = s + .get(&DataKey::DefaultRedeemHorizon) + .ok_or(Error::NotInitialized)?; let market = Market { creator: creator.clone(), @@ -344,6 +418,17 @@ impl MarketCore { market.close_time, ); } + // The creator's seed shares count as *user* outstanding, not pool. + let settle = SettleState { + outstanding_yes: seed.creator_yes, + outstanding_no: seed.creator_no, + finalized_at: 0, + horizon_secs, + paid_out: 0, + closed: false, + }; + Self::debug_assert_share_invariant(&market, &settle); + Self::save_settle(&env, id, &settle); Self::token(&env)?.transfer(&creator, env.current_contract_address(), &seed_amount); @@ -397,6 +482,25 @@ impl MarketCore { } Self::save_position(&env, market_id, &trader, &pos, m.close_time); Self::save_market(&env, market_id, &m); + // Legacy markets (no `Settle` entry) predate this bookkeeping. + if let Some(mut settle) = Self::load_settle_opt(&env, market_id) { + match outcome { + Outcome::Yes => { + settle.outstanding_yes = settle + .outstanding_yes + .checked_add(q.shares_out) + .ok_or(Error::Overflow)? + } + Outcome::No => { + settle.outstanding_no = settle + .outstanding_no + .checked_add(q.shares_out) + .ok_or(Error::Overflow)? + } + } + Self::debug_assert_share_invariant(&m, &settle); + Self::save_settle(&env, market_id, &settle); + } // interaction Self::token(&env)?.transfer(&trader, env.current_contract_address(), &collateral_in); @@ -455,9 +559,27 @@ impl MarketCore { } Self::save_position(&env, market_id, &trader, &pos, m.close_time); Self::set_reserves(&mut m, outcome, q.new_reserve_sold, q.new_reserve_other); - m.sets_minted -= q.gross; + m.sets_minted = m.sets_minted.checked_sub(q.gross).ok_or(Error::Overflow)?; m.fees_accrued = m.fees_accrued.checked_add(q.fee).ok_or(Error::Overflow)?; Self::save_market(&env, market_id, &m); + if let Some(mut settle) = Self::load_settle_opt(&env, market_id) { + match outcome { + Outcome::Yes => { + settle.outstanding_yes = settle + .outstanding_yes + .checked_sub(q.shares_in) + .ok_or(Error::Overflow)? + } + Outcome::No => { + settle.outstanding_no = settle + .outstanding_no + .checked_sub(q.shares_in) + .ok_or(Error::Overflow)? + } + } + Self::debug_assert_share_invariant(&m, &settle); + Self::save_settle(&env, market_id, &settle); + } // interaction Self::token(&env)?.transfer(&env.current_contract_address(), &trader, &collateral_out); @@ -492,6 +614,7 @@ impl MarketCore { } m.status = MarketStatus::Resolved(outcome); Self::save_market(&env, market_id, &m); + Self::mark_finalized(&env, market_id); MarketResolved { market_id, outcome: Some(outcome), @@ -509,6 +632,7 @@ impl MarketCore { } m.status = MarketStatus::Void; Self::save_market(&env, market_id, &m); + Self::mark_finalized(&env, market_id); MarketResolved { market_id, outcome: None, @@ -525,6 +649,11 @@ impl MarketCore { holder.require_auth(); let m = Self::load_market(&env, market_id)?; let kind = Self::settlement(&m)?; + if let Some(settle) = Self::load_settle_opt(&env, market_id) { + if settle.closed { + return Err(Error::RedemptionClosed); + } + } let pos = Self::load_position(&env, market_id, &holder, m.close_time); if pos.yes == 0 && pos.no == 0 { return Err(Error::NothingToRedeem); @@ -538,6 +667,20 @@ impl MarketCore { &Position { yes: 0, no: 0 }, m.close_time, ); + // Both sides decrement even when only one pays: the shares are no + // longer outstanding, so a losing-side redemption still clears here. + if let Some(mut settle) = Self::load_settle_opt(&env, market_id) { + settle.outstanding_yes = settle + .outstanding_yes + .checked_sub(pos.yes) + .ok_or(Error::Overflow)?; + settle.outstanding_no = settle + .outstanding_no + .checked_sub(pos.no) + .ok_or(Error::Overflow)?; + settle.paid_out = settle.paid_out.checked_add(payout).ok_or(Error::Overflow)?; + Self::save_settle(&env, market_id, &settle); + } if payout > 0 { Self::token(&env)?.transfer(&env.current_contract_address(), &holder, &payout); } @@ -555,6 +698,11 @@ impl MarketCore { let mut m = Self::load_market(&env, market_id)?; m.creator.require_auth(); let kind = Self::settlement(&m)?; + if let Some(settle) = Self::load_settle_opt(&env, market_id) { + if settle.closed { + return Err(Error::RedemptionClosed); + } + } if m.lp_claimed { return Err(Error::AlreadyClaimed); } @@ -564,12 +712,90 @@ impl MarketCore { m.lp_claimed = true; let creator = m.creator.clone(); Self::save_market(&env, market_id, &m); + if let Some(mut settle) = Self::load_settle_opt(&env, market_id) { + settle.paid_out = settle.paid_out.checked_add(payout).ok_or(Error::Overflow)?; + Self::save_settle(&env, market_id, &settle); + } if payout > 0 { Self::token(&env)?.transfer(&env.current_contract_address(), &creator, &payout); } Ok(payout) } + /// Permissionless close-out of a final market after its redemption + /// deadline. Sweeps the market's entire remaining balance — value + /// reserved for shares never redeemed, an unclaimed LP remainder, + /// per-holder rounding dust and accrued fees — to the treasury, and + /// marks the market closed so nothing can ever pay out again. + /// + /// The deadline is `redeem_deadline(market_id)` = `finalized_at + + /// horizon_secs`, the horizon being snapshotted at creation from the + /// admin default. Anyone who redeems before the deadline is paid the + /// exact `settle_value` they would have received without this call; + /// only shares still unredeemed at the deadline are forfeit, and their + /// value goes to the treasury rather than the LP so the LP cannot + /// profit from suppressing redemptions. + /// + /// Returns the amount swept. The swept total is exactly the market's + /// contract balance: sets plus fees minus everything already paid out. + pub fn finalize_market(env: Env, market_id: u64) -> Result { + let mut m = Self::load_market(&env, market_id)?; + let kind = Self::settlement(&m)?; + let mut settle = Self::load_settle(&env, market_id)?; + if settle.closed { + return Err(Error::RedemptionClosed); + } + if env.ledger().timestamp() < settle.finalized_at.saturating_add(settle.horizon_secs) { + return Err(Error::RedemptionWindowOpen); + } + // Upper bound on what the outstanding shares can still claim. For + // Yes/No this is exact; for Void, `floor((oy + on) / 2)` is at least + // the sum of per-holder floors (`settle_value` floors each pair), so + // reserving the aggregate floor never under-reserves. + let unclaimed_user = + math::settle_upper(kind, settle.outstanding_yes, settle.outstanding_no); + let lp_claim = if m.lp_claimed { + 0 + } else { + math::settle_value(kind, m.reserve_yes, m.reserve_no) + }; + // Sets are collateralized 1:1 and `paid_out` tracks what already + // left the market, so remaining claims minus those two figures is + // non-negative: it is exactly the per-holder void rounding dust + // (zero for Yes/No markets). checked_sub makes that an assertion. + let dust = m + .sets_minted + .checked_sub(settle.paid_out) + .and_then(|v| v.checked_sub(unclaimed_user)) + .and_then(|v| v.checked_sub(lp_claim)) + .ok_or(Error::Overflow)?; + let swept = dust + .checked_add(unclaimed_user) + .and_then(|v| v.checked_add(lp_claim)) + .and_then(|v| v.checked_add(m.fees_accrued)) + .ok_or(Error::Overflow)?; + + m.reserve_yes = 0; + m.reserve_no = 0; + m.lp_claimed = true; + m.fees_accrued = 0; + settle.outstanding_yes = 0; + settle.outstanding_no = 0; + settle.closed = true; + Self::save_market(&env, market_id, &m); + Self::save_settle(&env, market_id, &settle); + let treasury: Address = Self::instance_get(&env, &DataKey::Treasury)?; + Self::token(&env)?.transfer(&env.current_contract_address(), &treasury, &swept); + MarketFinalized { + market_id, + swept, + unclaimed_user, + dust, + } + .publish(&env); + Ok(swept) + } + /// Anyone can move accrued fees to the treasury. pub fn sweep_fees(env: Env, market_id: u64) -> Result { let mut m = Self::load_market(&env, market_id)?; @@ -617,6 +843,24 @@ impl MarketCore { Self::load_position(&env, market_id, &holder, close_time) } + /// Outstanding-share accounting for a market (issue #14). + /// `NotSupported` for markets created before this bookkeeping existed. + pub fn settle_state(env: Env, market_id: u64) -> Result { + Self::load_market(&env, market_id)?; + Self::load_settle(&env, market_id) + } + + /// Timestamp after which unredeemed shares are forfeit (see + /// `finalize_market`). `None` while the market is still Open — its + /// deadline is not known yet — and for legacy markets, which have no + /// deadline and cannot be finalized. + pub fn redeem_deadline(env: Env, market_id: u64) -> Option { + match Self::load_settle_opt(&env, market_id) { + Some(s) if s.finalized_at > 0 => Some(s.finalized_at.saturating_add(s.horizon_secs)), + _ => None, + } + } + /// Spot price of `outcome` in bps (0..=10_000). YES + NO ~ 10_000. pub fn get_price(env: Env, market_id: u64, outcome: Outcome) -> Result { let m = Self::load_market(&env, market_id)?; @@ -772,6 +1016,57 @@ impl MarketCore { ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); } } + + /// Records the finality timestamp on `resolve` / `void_market` (both + /// already require status == Open, so it is never overwritten). No-op + /// for legacy markets without a `Settle` entry. + fn mark_finalized(env: &Env, id: u64) { + if let Some(mut settle) = Self::load_settle_opt(env, id) { + settle.finalized_at = env.ledger().timestamp(); + Self::save_settle(env, id, &settle); + } + } + + /// `None` for legacy markets created before `SettleState` existed; their + /// outstanding shares cannot be reconstructed on-chain, so they have no + /// deadline and `finalize_market` refuses them (`NotSupported`) while + /// `redeem` / `claim_pool_remainder` keep working as before. + fn load_settle_opt(env: &Env, id: u64) -> Option { + let key = DataKey::Settle(id); + let settle = env.storage().persistent().get::<_, SettleState>(&key)?; + // The deadline can be up to 5 years out, past `max_ttl()`; the entry + // is kept as long as the network allows and then needs a restore or + // the keeper (docs/wave-issues/14) until the market is finalized. + ttl::bump_persistent(env, &key, ttl::long_lived_target(env)); + Some(settle) + } + + fn load_settle(env: &Env, id: u64) -> Result { + Self::load_settle_opt(env, id).ok_or(Error::NotSupported) + } + + fn save_settle(env: &Env, id: u64, settle: &SettleState) { + let key = DataKey::Settle(id); + env.storage().persistent().set(&key, settle); + ttl::bump_persistent(env, &key, ttl::long_lived_target(env)); + } + + /// Debug-build check of the share-count invariant + /// `reserve_ + outstanding_ == sets_minted`. + /// + /// Only valid while no collateral has left the market (`paid_out == 0` + /// and the LP unclaimed), which is exactly every Open market plus a + /// final one nobody redeemed from yet: redeeming a losing-side position + /// (payout 0) burns outstanding shares without a matching payout, so + /// after the first payout collateral conservation is tracked by + /// `paid_out` instead and enforced by `finalize_market`'s checked + /// residual. Exercised on create / buy / sell and in the tests. + fn debug_assert_share_invariant(m: &Market, settle: &SettleState) { + if settle.paid_out == 0 && !m.lp_claimed { + debug_assert_eq!(m.reserve_yes + settle.outstanding_yes, m.sets_minted); + debug_assert_eq!(m.reserve_no + settle.outstanding_no, m.sets_minted); + } + } } #[cfg(test)] @@ -1248,4 +1543,413 @@ mod test { assert!(long_ttl >= 3_110_399); assert!(long_ttl > short_ttl); } + + // ----- Outstanding shares + redemption deadline (#14) ------------------ + + fn create_market_with( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + yes_bps: u32, + close_in_secs: u64, + ) -> u64 { + client.create_market( + creator, + &BytesN::from_array(env, &[41u8; 32]), + &BytesN::from_array(env, &[141u8; 32]), + &String::from_str(env, "ipfs://settle"), + &Category::Macro, + &(env.ledger().timestamp() + close_in_secs), + &(100 * 10_000_000_i128), + &yes_bps, + ) + } + + /// `reserve_ + outstanding_ == sets_minted`, checked through + /// the public views. Only meaningful while no payout has left the market + /// (before any redeem/claim); afterwards collateral conservation is + /// tracked by `paid_out` and enforced by `finalize_market`'s checked + /// residual — see the settle tests below. + fn assert_share_invariant(client: &MarketCoreClient, market_id: u64) { + let m = client.get_market(&market_id); + let s = client.settle_state(&market_id); + assert_eq!(s.paid_out, 0, "payouts must be zero for this assertion"); + assert_eq!(m.reserve_yes + s.outstanding_yes, m.sets_minted); + assert_eq!(m.reserve_no + s.outstanding_no, m.sets_minted); + } + + #[test] + fn invariant_reserve_plus_outstanding_equals_sets() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + assert_share_invariant(&client, market_id); + + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + assert_share_invariant(&client, market_id); + client.buy( + &trader, + &market_id, + &Outcome::No, + &(7 * 10_000_000_i128), + &0_i128, + ); + assert_share_invariant(&client, market_id); + + client.sell( + &trader, + &market_id, + &Outcome::Yes, + &(3 * 10_000_000_i128), + &i128::MAX, + ); + assert_share_invariant(&client, market_id); + client.sell( + &trader, + &market_id, + &Outcome::No, + &(2 * 10_000_000_i128), + &i128::MAX, + ); + assert_share_invariant(&client, market_id); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + assert_share_invariant(&client, market_id); + } + + #[test] + fn outstanding_tracks_buy_sell_redeem() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + + let s0 = client.settle_state(&market_id); + let m0 = client.get_market(&market_id); + assert_eq!(s0.finalized_at, 0); + assert_eq!(s0.horizon_secs, DEFAULT_REDEEM_HORIZON_SECS); + assert_eq!(s0.paid_out, 0); + assert!(!s0.closed); + assert_eq!(client.redeem_deadline(&market_id), None); + // The creator's seed shares count as user outstanding, not pool. + assert_eq!(s0.outstanding_yes, m0.sets_minted - m0.reserve_yes); + assert_eq!(s0.outstanding_no, 0); + + let bought = client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let s1 = client.settle_state(&market_id); + assert_eq!(s1.outstanding_yes, s0.outstanding_yes + bought); + + let sold = client.sell( + &trader, + &market_id, + &Outcome::Yes, + &(5 * 10_000_000_i128), + &i128::MAX, + ); + let s2 = client.settle_state(&market_id); + assert_eq!(s2.outstanding_yes, s1.outstanding_yes - sold); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let resolved_at = env.ledger().timestamp(); + assert_eq!( + client.redeem_deadline(&market_id), + Some(resolved_at + DEFAULT_REDEEM_HORIZON_SECS) + ); + + let trader_pos = client.get_position(&market_id, &trader); + let creator_pos = client.get_position(&market_id, &creator); + let trader_payout = client.redeem(&trader, &market_id); + assert_eq!(trader_payout, trader_pos.yes); + let s3 = client.settle_state(&market_id); + assert_eq!(s3.outstanding_yes, s2.outstanding_yes - trader_pos.yes); + assert_eq!(s3.paid_out, trader_payout); + assert_eq!(s3.finalized_at, resolved_at); + + let creator_payout = client.redeem(&creator, &market_id); + assert_eq!(creator_payout, creator_pos.yes); + let s4 = client.settle_state(&market_id); + assert_eq!(s4.outstanding_yes, 0); + assert_eq!(s4.paid_out, trader_payout + creator_payout); + + // The LP remainder still sits in the pool until claimed. + let before_claim = client.get_market(&market_id); + let lp_payout = client.claim_pool_remainder(&market_id); + assert_eq!(lp_payout, before_claim.reserve_yes); + let s5 = client.settle_state(&market_id); + assert_eq!(s5.paid_out, s4.paid_out + lp_payout); + } + + #[test] + #[should_panic(expected = "Error(Contract, #20)")] + fn finalize_before_deadline_rejected() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let deadline = client.redeem_deadline(&market_id).unwrap(); + env.ledger().set_timestamp(deadline - 1); + client.finalize_market(&market_id); + } + + #[test] + fn finalize_after_deadline_sweeps_exact_residual() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let trader2 = Address::generate(&env); + StellarAssetClient::new(&env, &collateral).mint(&trader2, &10_000_000_000_i128); + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + client.buy( + &trader2, + &market_id, + &Outcome::No, + &(7 * 10_000_000_i128), + &0_i128, + ); + + // A void with a mix of odd positions: per-holder floors are what + // leaves rounding dust behind. + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.void_market(&market_id); + + // One holder redeems before the deadline; the other never does. + let trader_payout = client.redeem(&trader, &market_id); + let deadline = client.redeem_deadline(&market_id).unwrap(); + env.ledger().set_timestamp(deadline + 1); + + let m_before = client.get_market(&market_id); + let s_before = client.settle_state(&market_id); + let expected_unclaimed = (s_before.outstanding_yes + s_before.outstanding_no) / 2; + let expected_lp = (m_before.reserve_yes + m_before.reserve_no) / 2; + let expected_dust = + m_before.sets_minted - s_before.paid_out - expected_unclaimed - expected_lp; + assert!(expected_dust >= 0); + let expected_swept = + expected_dust + expected_unclaimed + expected_lp + m_before.fees_accrued; + + let balance_before = token.balance(&client.address); + let treasury_before = token.balance(&client.get_config().treasury); + let swept = client.finalize_market(&market_id); + + // The sweep drains the market's entire balance — nothing stranded. + assert_eq!(swept, expected_swept); + assert_eq!(balance_before, swept); + assert_eq!(token.balance(&client.address), 0); + assert_eq!( + token.balance(&client.get_config().treasury), + treasury_before + swept + ); + + let m_after = client.get_market(&market_id); + assert!(m_after.lp_claimed); + assert_eq!(m_after.reserve_yes, 0); + assert_eq!(m_after.reserve_no, 0); + assert_eq!(m_after.fees_accrued, 0); + let s_after = client.settle_state(&market_id); + assert!(s_after.closed); + assert_eq!(s_after.outstanding_yes, 0); + assert_eq!(s_after.outstanding_no, 0); + // `paid_out` keeps only the holder payouts that ever happened. + assert_eq!(s_after.paid_out, trader_payout); + + // The never-redeemed holder is forfeit, not unpaid-but-pending. + assert!(client.try_redeem(&trader2, &market_id).is_err()); + } + + #[test] + fn redeem_before_deadline_never_underpaid() { + let (env, client, creator, trader, _, _) = setup_market(); + // Two identical markets: one is finalized after the deadline, the + // other is finalized too — the redemptions in between must be paid + // exactly the same in both worlds. + let a = create_market_with(&env, &client, &creator, 6800, 60_000); + let b = create_market_with(&env, &client, &creator, 6800, 60_000); + for id in [a, b] { + client.buy( + &trader, + &id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + } + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&a, &Outcome::Yes); + client.resolve(&b, &Outcome::Yes); + + let deadline_a = client.redeem_deadline(&a).unwrap(); + let payout_a = client.redeem(&trader, &a); + let payout_b = client.redeem(&trader, &b); + assert_eq!(payout_a, payout_b); + assert!(payout_a > 0); + + // Now let both deadlines pass and finalize both. + env.ledger().set_timestamp(deadline_a + 1); + let swept_b = client.finalize_market(&b); + let swept_a = client.finalize_market(&a); + assert_eq!(swept_a, swept_b); + // The redeemer's payout was never touched by the finalize. + assert_eq!(client.settle_state(&a).paid_out, payout_a); + } + + #[test] + #[should_panic(expected = "Error(Contract, #21)")] + fn redeem_after_close_rejected() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let deadline = client.redeem_deadline(&market_id).unwrap(); + env.ledger().set_timestamp(deadline + 1); + client.finalize_market(&market_id); + client.redeem(&trader, &market_id); + } + + #[test] + #[should_panic(expected = "Error(Contract, #21)")] + fn claim_after_close_rejected() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let deadline = client.redeem_deadline(&market_id).unwrap(); + env.ledger().set_timestamp(deadline + 1); + client.finalize_market(&market_id); + client.claim_pool_remainder(&market_id); + } + + #[test] + #[should_panic(expected = "Error(Contract, #21)")] + fn finalize_twice_rejected() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let deadline = client.redeem_deadline(&market_id).unwrap(); + env.ledger().set_timestamp(deadline + 1); + client.finalize_market(&market_id); + client.finalize_market(&market_id); + } + + #[test] + #[should_panic(expected = "Error(Contract, #22)")] + fn legacy_market_cannot_finalize() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + // Simulate a market created before `Settle` entries existed. + env.as_contract(&client.address, || { + env.storage() + .persistent() + .remove(&DataKey::Settle(market_id)); + }); + client.finalize_market(&market_id); + } + + #[test] + fn legacy_market_still_redeems_without_deadline() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + env.as_contract(&client.address, || { + env.storage() + .persistent() + .remove(&DataKey::Settle(market_id)); + }); + + // No deadline, no finalize — but the old redemption paths keep working. + assert_eq!(client.redeem_deadline(&market_id), None); + assert!(client.try_settle_state(&market_id).is_err()); + let payout = client.redeem(&trader, &market_id); + assert!(payout > 0); + assert!(client.claim_pool_remainder(&market_id) > 0); + } + + #[test] + fn redeem_horizon_default_snapshot_and_bounds() { + let (env, client, creator, _, _, _) = setup_market(); + let long_horizon = 200 * ttl::DAY_SECS; + client.set_default_redeem_horizon(&long_horizon); + assert!(client + .try_set_default_redeem_horizon(&(100 * ttl::DAY_SECS)) + .is_err()); + assert!(client + .try_set_default_redeem_horizon(&(2_000 * ttl::DAY_SECS)) + .is_err()); + + let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); + assert_eq!(client.settle_state(&market_id).horizon_secs, long_horizon); + assert_eq!(client.redeem_deadline(&market_id), None); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&market_id, &Outcome::Yes); + let resolved_at = env.ledger().timestamp(); + assert_eq!( + client.redeem_deadline(&market_id), + Some(resolved_at + long_horizon) + ); + } + + #[test] + fn wrong_caller_cannot_set_default_redeem_horizon() { + let (env, client, _, trader, _, _) = setup_market(); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: &trader, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "set_default_redeem_horizon", + args: (365 * ttl::DAY_SECS,).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_set_default_redeem_horizon(&(365 * ttl::DAY_SECS)) + .is_err()); + } } diff --git a/oracledesk-stellar/contracts/market-core/src/math.rs b/oracledesk-stellar/contracts/market-core/src/math.rs index 332ce36..a356ded 100644 --- a/oracledesk-stellar/contracts/market-core/src/math.rs +++ b/oracledesk-stellar/contracts/market-core/src/math.rs @@ -203,6 +203,23 @@ pub fn settle_value(kind: Settlement, yes: i128, no: i128) -> i128 { } } +/// Upper bound on the total collateral `settle_value` will pay out for a +/// set of per-holder (yes, no) claims. Exact for `Yes`/`No`; for `Void` +/// each individual claim floors its own half, so the sum of what holders +/// can still redeem is at most the floor of the summed halves: +/// `floor(a/2) + floor(b/2) <= floor((a + b)/2)` for all `a, b >= 0` +/// (the floors give back at most 1 unit and `a + b` is integral, so the +/// per-holder rounding never lets redemptions exceed this bound). +/// `finalize_market` reserves exactly this much for outstanding claims; +/// anything the rounding then leaves unclaimed is swept, never stranded. +pub fn settle_upper(kind: Settlement, yes: i128, no: i128) -> i128 { + match kind { + Settlement::Yes => yes, + Settlement::No => no, + Settlement::Void => (yes + no) / 2, + } +} + #[cfg(test)] mod tests { use super::*; @@ -265,4 +282,34 @@ mod tests { assert_eq!(settle_value(Settlement::Void, 3, 4), 3); assert!(settle_value(Settlement::Void, 5, 5) <= 5); } + + #[test] + fn settle_upper_exact_for_yes_no() { + assert_eq!(settle_upper(Settlement::Yes, 7, 3), 7); + assert_eq!(settle_upper(Settlement::No, 7, 3), 3); + } + + #[test] + fn void_sum_of_floors_le_floor_of_sum() { + // The per-holder floor in `settle_value` must never add up to more + // than the single floor over the summed claims, or finalize would + // reserve less than holders can redeem. + let mut x: u64 = 0x2545_F491_4F6C_DD1D; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (x >> 33) as i128 + }; + for _ in 0..2_000 { + let a = next() % 1_000_000_000; + let b = next() % 1_000_000_000; + let summed = settle_upper(Settlement::Void, a, b); + let part = settle_value(Settlement::Void, a, 0) + settle_value(Settlement::Void, 0, b); + assert!(part <= summed, "a={a} b={b} part={part} summed={summed}"); + } + // Boundary shapes around odd sums. + assert!(settle_value(Settlement::Void, 1, 0) + settle_value(Settlement::Void, 0, 1) <= 1); + assert!(settle_value(Settlement::Void, 1, 1) + settle_value(Settlement::Void, 0, 1) <= 1); + } } diff --git a/oracledesk-stellar/contracts/treasury/src/lib.rs b/oracledesk-stellar/contracts/treasury/src/lib.rs index 1b2221e..b598206 100644 --- a/oracledesk-stellar/contracts/treasury/src/lib.rs +++ b/oracledesk-stellar/contracts/treasury/src/lib.rs @@ -329,6 +329,15 @@ impl Treasury { } /// Redeems the treasury's position in a resolved/voided market. + /// + /// Deadline (market-core issue #14): every market has a redemption + /// window snapshotted at creation, and once `redeem_deadline(market_id)` + /// passes anyone can `finalize_market`, which forfeits anything still + /// unredeemed to the fee recipient. Nothing here tracks deadlines or + /// auto-redeems, so the agent is responsible for calling this before the + /// window expires. (This wrapper must exist: a contract address can only + /// authorize itself from inside its own call stack, so nobody outside + /// the treasury can redeem the treasury's position.) pub fn redeem(env: Env, market_id: u64) -> Result { let config = Self::get_config(env.clone())?; config.agent.require_auth(); diff --git a/oracledesk-stellar/docs/STATUS.md b/oracledesk-stellar/docs/STATUS.md index 4adba1b..1750a77 100644 --- a/oracledesk-stellar/docs/STATUS.md +++ b/oracledesk-stellar/docs/STATUS.md @@ -206,6 +206,21 @@ markers in the files themselves: independent agent deployments from trading against the same `market-core` markets. The treasury's caps bound one deployment's own risk, not systemic market behavior. +- **Unredeemed shares expire after the redemption window.** Each market + snapshots a redemption horizon at creation (default 365 days; the admin + default is bounded to 180–1825 days). Once `finalized_at + horizon_secs` + has passed, anyone can call `finalize_market`, which sweeps the market's + **entire** remaining balance — expired user claims, void rounding dust, + any unclaimed LP remainder and accrued fees — to the treasury and closes + redemption (`redeem`/`claim_pool_remainder` then revert with + `RedemptionClosed`). Redeeming before the deadline always pays the exact + `settle_value`; expired claims go to the treasury, not the LP. Markets + created before this `SettleState` bookkeeping (issue #14) have no deadline + and cannot be finalized (`NotSupported`), and stay redeemable through the + old paths. A market waiting on a multi-year deadline can be + TTL-archived before it is finalized and needs a restore (or the keeper, + `docs/wave-issues/14-storage-ttl-keeper.md`) first; the deadline itself is + on-chain state, so a late finalization is still exact. - **Single admin key per contract**, no multisig/timelock. A compromised admin key has a large blast radius per contract (see `docs/wave-issues/12-architecture-deep-dive-and-threat-model.md`, which diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index acd5fa5..1b253f4 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -27,7 +27,29 @@ 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. +for the creator/LP, `sweep_fees` (permissionless) to the treasury → +`finalize_market` (permissionless, after the redemption deadline) closes the +market and sweeps the residual. + +A per-market `SettleState` side entry (views `settle_state` and +`redeem_deadline`) tracks user-held outstanding shares, the finality +timestamp, a per-market redemption horizon snapshotted at creation (default +365 days; admin-set default bounded to 180–1825 days), what has already been +paid out, and whether the market is closed. `redeem` and +`claim_pool_remainder` maintain it on every payout. Once +`finalized_at + horizon_secs` has passed, anyone can call `finalize_market`: +it reserves exactly what the outstanding shares can still claim +(`settle_upper` — for void markets the aggregate floor, which is at least +the sum of the per-holder floors) plus any unclaimed LP remainder, then +sweeps the market's **entire** remaining balance — expired user claims, +void rounding dust, the unclaimed remainder and accrued fees — to the +treasury, marks the market `RedemptionClosed`, and zeroes the market's +balance. A holder who redeems before the deadline is paid the exact +`settle_value` they would have received without finalization; expired claims +go to the treasury rather than the LP, so the LP cannot profit from +suppressing redemptions. Markets created before this bookkeeping existed +have no `Settle` entry: they have no deadline and cannot be finalized +(`NotSupported`), and stay redeemable through the old paths. ### resolver @@ -99,6 +121,13 @@ shared policy: read-only so indexer simulations keep read-only footprints. - The registry additionally refreshes an agent's registration on every successful `publish_trace`. +- The market-core `Settle` entry (outstanding shares, redemption deadline) is + kept to the long-lived target — 365 days, clamped to `max_ttl()`. A market + whose redemption deadline is more than one network-max TTL away can archive + before it is finalized and needs touches or a restore first; + `finalize_market` works after a restore, and the deadline is computed from + on-chain state (`finalized_at + horizon_secs`), so a late finalization is + still exact. - Markets whose `close_time` is further out than `max_ttl` cannot be kept alive by a single bump and need periodic touches or the keeper described in [`docs/wave-issues/14-storage-ttl-keeper.md`](wave-issues/14-storage-ttl-keeper.md), diff --git a/oracledesk-stellar/model/fpmm_model.py b/oracledesk-stellar/model/fpmm_model.py index 53ec152..f7ab917 100644 --- a/oracledesk-stellar/model/fpmm_model.py +++ b/oracledesk-stellar/model/fpmm_model.py @@ -131,6 +131,15 @@ def settle_value(kind: str, yes: int, no: int) -> int: raise ValueError(f"unknown settlement kind {kind!r}") +def settle_upper(kind: str, yes: int, no: int) -> int: + """Mirror of `math::settle_upper`: an upper bound on the total + `settle_value` pays out for a set of per-holder (yes, no) claims. Exact + for yes/no; for void, per-holder floors sum to at most the floor of the + summed halves, so `finalize_market` reserving the aggregate never + under-reserves (see `_check_void_sum_of_floors`).""" + return settle_value(kind, yes, no) + + def gross_for_shares(r_sold: int, r_other: int, shares_in: int, fee_bps: int) -> int: """Off-chain inverse of calc_sell: how many complete sets (`gross`) the pool must burn so that selling exactly `shares_in` shares balances the @@ -247,12 +256,31 @@ def _check_void_never_overpays(): assert settle_value("void", 5, 5) <= 5 +def _check_void_sum_of_floors(trials: int = 2_000, seed: int = 13): + """A void market's finalize must reserve at least what remaining holders + can still redeem. Each holder's claim floors its own half, so the sum of + per-holder floors is at most the aggregate floor `settle_upper`: + floor(a/2) + floor(b/2) <= floor((a + b)/2) for all a, b >= 0.""" + rng = random.Random(seed) + for _ in range(trials): + a = rng.randint(0, 1_000_000_000) + b = rng.randint(0, 1_000_000_000) + part = settle_value("void", a, 0) + settle_value("void", 0, b) + assert part <= settle_upper("void", a, b), (a, b, part) + assert settle_upper("yes", a, b) == settle_value("yes", a, b) + assert settle_upper("no", a, b) == settle_value("no", a, b) + # Boundary shapes around odd sums. + assert settle_value("void", 1, 0) + settle_value("void", 0, 1) <= 1 + assert settle_value("void", 1, 1) + settle_value("void", 0, 1) <= 1 + + def 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_void_sum_of_floors() print("fpmm_model: all property checks passed") From 0aec7d31a98d1b557cc83293ea6b9cac8b0cdcd6 Mon Sep 17 00:00:00 2001 From: Teescom Date: Fri, 16 Oct 2026 15:14:16 +0100 Subject: [PATCH 3/8] feat: cumulative-price TWAP accumulator with manipulation-resistant reads (#11) Spot price alone is a single-trade-grabbable reference; anything automated (agents, frontends) needs a price that requires holding a distortion. - Per-market accumulator (Uniswap-v2 semantics): every buy/sell folds the pre-trade price into price_cumulative before reserves move. Same-timestamp trades only refresh the price - nothing accumulates within one ledger, so moving the TWAP requires holding the price across a ledger boundary. - State lives in Oracle(id) and a 16-slot ring of Obs(id, k) entries, so the Market entry's XDR is unchanged and existing readers/decoders keep working. - Views: observe(id, seconds_ago), twap(id, window_secs) (floor division) and price_cumulative(id) (= observe at now). Between bracketing observations the cumulative is chord-interpolated (exact - piecewise-linear between trades); past the newest observation the last price is extended counterfactually; earlier than the surviving ring returns ObservationTooOld; legacy markets return NotSupported. - Overflow: checked i128 arithmetic throughout; the 10-year alternating-trade test bounds the accumulator at elapsed * MAX_PROB_BPS, ~24 orders of magnitude below i128::MAX. - Oracle/Obs entries share the market's close-time-aware TTL target (#16); unwritten ring slots are never written and missing slots are skipped. - agents: MarketState.twapBps and decideTrade({priceSource: "spot"|"twap"}), readMarket() in the stellar adapter, trader PRICE_SOURCE/TWAP_WINDOW_SECS env knobs, twapFromObservations() BigInt mirror + tests. - Bindings regenerated; architecture.md, STATUS.md and frontend-integration.md document the semantics and caveats. --- oracledesk-stellar/.env.example | 8 + oracledesk-stellar/agents/bin/trader.ts | 32 +- .../agents/core/strategy.test.ts | 68 +- oracledesk-stellar/agents/core/strategy.ts | 101 ++- oracledesk-stellar/agents/stellar/adapter.ts | 61 ++ .../contracts/market-core/src/lib.rs | 636 +++++++++++++++++- oracledesk-stellar/docs/STATUS.md | 11 + oracledesk-stellar/docs/architecture.md | 20 + .../docs/frontend-integration.md | 37 + .../bindings/market-core/package-lock.json | 28 +- .../bindings/market-core/src/index.ts | 230 ++++++- 11 files changed, 1192 insertions(+), 40 deletions(-) diff --git a/oracledesk-stellar/.env.example b/oracledesk-stellar/.env.example index 9b10991..09bcd1e 100644 --- a/oracledesk-stellar/.env.example +++ b/oracledesk-stellar/.env.example @@ -37,6 +37,14 @@ TESTNET_FACILITATOR_API_KEY= # --- Agents (agents/) ------------------------------------------------------- # "dry-run" (default, never signs or submits) or "live" (real testnet txs). AGENT_MODE=dry-run +# Market the trader agent reads and trades (see agents/stellar/adapter.ts). +MARKET_ID=0 +# Which price the trader compares to its fair-value estimate: +# spot = market-core's instantaneous `get_price` (moves with any trade) +# twap = the manipulation-resistant time-weighted average over +# TWAP_WINDOW_SECS, read via market-core's `twap` view +PRICE_SOURCE=spot +TWAP_WINDOW_SECS=900 # 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= diff --git a/oracledesk-stellar/agents/bin/trader.ts b/oracledesk-stellar/agents/bin/trader.ts index dca8740..9af9a6b 100644 --- a/oracledesk-stellar/agents/bin/trader.ts +++ b/oracledesk-stellar/agents/bin/trader.ts @@ -12,12 +12,29 @@ import { buildTrace, LocalTraceStore } from "../core/trace.js"; import { decideTrade, type MarketState } from "../core/strategy.js"; import { loadDeployments, StellarAdapter } from "../stellar/adapter.js"; -/** Placeholder in place of a real market read + reference-price lookup — - * see docs/wave-issues for wiring get_market/get_price and a - * ReferencePriceSource as open issues. */ -async function fetchMarketAndFairValue(): Promise<{ market: MarketState; fairValueBps: number }> { +const PRICE_SOURCE = (process.env.PRICE_SOURCE ?? "spot") as "spot" | "twap"; +const TWAP_WINDOW_SECS = Number(process.env.TWAP_WINDOW_SECS ?? 900); + +/** Reads the market on-chain (`readMarket`); the fair-value estimate is + * still a placeholder — wiring a read-only ReferencePriceSource (e.g. a + * Polymarket mid-price) is tracked in docs/wave-issues. */ +async function fetchMarketAndFairValue( + adapter: StellarAdapter, +): Promise<{ market: MarketState; fairValueBps: number }> { + if (PRICE_SOURCE !== "spot" && PRICE_SOURCE !== "twap") { + throw new Error(`PRICE_SOURCE must be "spot" or "twap", got "${PRICE_SOURCE}"`); + } + const read = await adapter.readMarket(BigInt(process.env.MARKET_ID ?? "0"), { + useTwapSecs: PRICE_SOURCE === "twap" ? TWAP_WINDOW_SECS : undefined, + }); return { - market: { marketId: 0, question: "demo", yesBps: 4000, closeTime: Math.floor(Date.now() / 1000) + 3600 }, + market: { + marketId: read.marketId, + question: read.metaUri, + yesBps: read.yesBps, + twapBps: read.twapBps, + closeTime: read.closeTime, + }, fairValueBps: 6000, }; } @@ -34,11 +51,12 @@ async function main() { }); const traceStore = new LocalTraceStore(process.env.TRACE_STORE_DIR ?? "./.traces"); - console.log(`[trader] mode=${mode}`); - const { market, fairValueBps } = await fetchMarketAndFairValue(); + console.log(`[trader] mode=${mode} priceSource=${PRICE_SOURCE}`); + const { market, fairValueBps } = await fetchMarketAndFairValue(adapter); const decision = decideTrade(market, fairValueBps, { minEdgeBps: Number(process.env.MIN_EDGE_BPS ?? 500), tradeSizeStroops: BigInt(process.env.TRADE_SIZE_STROOPS ?? "100000000"), + priceSource: PRICE_SOURCE, }); console.log(`[trader] ${decision.action}: ${decision.reasoning}`); if (decision.action === "skip") return; diff --git a/oracledesk-stellar/agents/core/strategy.test.ts b/oracledesk-stellar/agents/core/strategy.test.ts index bdc7442..38d5210 100644 --- a/oracledesk-stellar/agents/core/strategy.test.ts +++ b/oracledesk-stellar/agents/core/strategy.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { decideMarketProposal, decideTrade } from "./strategy.js"; +import { decideMarketProposal, decideTrade, twapFromObservations } from "./strategy.js"; test("decideMarketProposal skips a signal with no actionable estimate", () => { const d = decideMarketProposal( @@ -68,3 +68,69 @@ test("decideTrade buys No when the market overprices Yes", () => { assert.equal(d.action, "buy"); assert.equal(d.outcome, "No"); }); + +test("decideTrade uses twapBps when priceSource is twap", () => { + // Spot sits right on the fair value (no edge), but the TWAP reveals the + // real mispricing the spot price was dragged away from. + const d = decideTrade( + { marketId: 7, question: "q", yesBps: 6000, twapBps: 4000, closeTime: 0 }, + 6000, + { minEdgeBps: 100, tradeSizeStroops: 10_000_000n, priceSource: "twap" }, + ); + assert.equal(d.action, "buy"); + assert.equal(d.outcome, "Yes"); + assert.match(d.reasoning, /4000bps \(twap\)/); + + // The same market read as spot skips. + const spot = decideTrade( + { marketId: 7, question: "q", yesBps: 6000, twapBps: 4000, closeTime: 0 }, + 6000, + { minEdgeBps: 100, tradeSizeStroops: 10_000_000n, priceSource: "spot" }, + ); + assert.equal(spot.action, "skip"); +}); + +test("decideTrade with priceSource twap requires a twap price", () => { + assert.throws( + () => + decideTrade( + { marketId: 7, question: "q", yesBps: 6000, closeTime: 0 }, + 6000, + { minEdgeBps: 100, tradeSizeStroops: 10_000_000n, priceSource: "twap" }, + ), + /twapBps/, + ); +}); + +test("twapFromObservations matches contract example", () => { + // Mirrors market-core's `twap_weights_by_time` arithmetic with round + // numbers: seeded at 6800bps, trade at t=1000 leaving 5000bps, trade at + // t=1100 leaving 6000bps, then the live edge counterfactually extended to + // t=1400. The 400s window from t=1000 to t=1400 averages + // (5000*100 + 6000*300) / 400 = 5750. + const observations = [ + { ts: 0, cumulative: 0n }, + { ts: 1000, cumulative: 6_800_000n }, + { ts: 1100, cumulative: 6_800_000n + 100n * 5_000n }, + { ts: 1400, cumulative: 6_800_000n + 100n * 5_000n + 300n * 6_000n }, + ]; + assert.equal(twapFromObservations(observations, 400), 5750); + // The last 300s were held at 6000bps exactly. + assert.equal(twapFromObservations(observations, 300), 6000); + // Windows may start strictly between observations (interpolated chord): + // t = 950 sits in the seeded-price stretch, so cum(950) = 6800 * 950. + assert.equal( + twapFromObservations(observations, 450), + Math.floor((9_100_000 - (6_800_000 * 950) / 1000) / 450), + ); +}); + +test("twapFromObservations throws when the window predates the ring", () => { + const observations = [ + { ts: 1000, cumulative: 0n }, + { ts: 2000, cumulative: 5_000_000n }, + ]; + assert.throws(() => twapFromObservations(observations, 1001), /ObservationTooOld/); + assert.throws(() => twapFromObservations([], 100), /no observations/); + assert.throws(() => twapFromObservations(observations, 0), /positive integer/); +}); diff --git a/oracledesk-stellar/agents/core/strategy.ts b/oracledesk-stellar/agents/core/strategy.ts index eab6f51..f2d4c9f 100644 --- a/oracledesk-stellar/agents/core/strategy.ts +++ b/oracledesk-stellar/agents/core/strategy.ts @@ -82,7 +82,11 @@ export function decideMarketProposal( export interface MarketState { marketId: number; question: string; - yesBps: number; // current market-implied probability + yesBps: number; // current market-implied probability (spot) + /** Time-weighted average YES price in bps, when the caller read it + * (market-core's `twap`). Required by `decideTrade` when `priceSource` is + * `"twap"`; spot reads may leave it undefined. */ + twapBps?: number; closeTime: number; } @@ -97,24 +101,111 @@ export interface TraderDecision { * 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. + * + * `priceSource` picks which of the market's prices is compared: `"spot"` + * (default) uses `MarketState.yesBps`, which a single trade can move + * arbitrarily within one ledger; `"twap"` uses `MarketState.twapBps`, the + * manipulation-resistant time-weighted average. Prefer `"twap"` for + * automated trading loops. */ export function decideTrade( market: MarketState, fairValueBps: number, - opts: { minEdgeBps: number; tradeSizeStroops: bigint }, + opts: { minEdgeBps: number; tradeSizeStroops: bigint; priceSource?: "spot" | "twap" }, ): TraderDecision { - const edge = fairValueBps - market.yesBps; // positive => YES underpriced + const priceSource = opts.priceSource ?? "spot"; + let marketBps: number; + if (priceSource === "twap") { + if (market.twapBps === undefined) { + throw new Error( + `priceSource "twap" requires market.twapBps on market ${market.marketId} — read it with readMarket({ useTwapSecs })`, + ); + } + marketBps = market.twapBps; + } else { + marketBps = market.yesBps; + } + + const edge = fairValueBps - marketBps; // positive => YES underpriced if (Math.abs(edge) < opts.minEdgeBps) { return { action: "skip", - reasoning: `edge ${edge}bps on market ${market.marketId} is below the ${opts.minEdgeBps}bps threshold`, + reasoning: `edge ${edge}bps (${priceSource}) on market ${market.marketId} is below the ${opts.minEdgeBps}bps threshold`, }; } const outcome = edge > 0 ? "Yes" : "No"; 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 ${marketBps}bps (${priceSource}) vs fair value ${fairValueBps}bps (edge ${edge}bps) — buying ${outcome}`, outcome, collateralInStroops: opts.tradeSizeStroops, }; } + +/** One point of market-core's TWAP ring, as returned by the `Obs` entries + * (or the `observe` view): the cumulative YES-price-seconds at `ts`. */ +export interface PriceObservation { + /** unix seconds */ + ts: number; + /** `price_cumulative` (bps × seconds) as of `ts` */ + cumulative: bigint; +} + +/** + * BigInt mirror of market-core's `observe`/`twap` math, for consumers that + * cannot simulate the contract (e.g. rendering historical charts from + * indexed observations). Linearly interpolates the cumulative between + * observations — exact there, because the accumulator is linear on each + * interval (its slope is the price that held) — evaluates the window ending + * at the newest observation's timestamp, and floors, exactly like the + * contract. + * + * The contract counterfactually extends the accumulator from the last trade + * to the current time (the last price holds). To reproduce that, append the + * live edge — `(last_ts, price_cumulative)` is what `observe(marketId, 0)` + * returns — as the final observation. + * + * Throws a `RangeError` when the window reaches before the first + * observation (the contract's `ObservationTooOld`), or when the inputs are + * degenerate. `observations` must be ordered by non-decreasing `ts`; exact + * duplicates (same ts and cumulative) are tolerated. + */ +export function twapFromObservations( + observations: readonly PriceObservation[], + windowSecs: number, +): number { + if (observations.length === 0) { + throw new RangeError("twapFromObservations: no observations"); + } + if (!Number.isInteger(windowSecs) || windowSecs <= 0) { + throw new RangeError(`twapFromObservations: window must be a positive integer, got ${windowSecs}`); + } + const now = observations[observations.length - 1].ts; + const t = now - windowSecs; + if (t < observations[0].ts) { + throw new RangeError("twapFromObservations: window reaches before the oldest observation (ObservationTooOld)"); + } + const delta = cumulativeAt(observations, now) - cumulativeAt(observations, t); + return Number(delta / BigInt(windowSecs)); +} + +/** Newest observation with `ts <= t`, interpolated along the next chord if + * `t` falls strictly between two observations. */ +function cumulativeAt(observations: readonly PriceObservation[], t: number): bigint { + let lo = 0; + let hi = observations.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (observations[mid].ts <= t) { + lo = mid; + } else { + hi = mid - 1; + } + } + const a = observations[lo]; + if (a.ts === t) { + return a.cumulative; + } + const b = observations[lo + 1]; + return a.cumulative + ((b.cumulative - a.cumulative) * BigInt(t - a.ts)) / BigInt(b.ts - a.ts); +} diff --git a/oracledesk-stellar/agents/stellar/adapter.ts b/oracledesk-stellar/agents/stellar/adapter.ts index b7d2c77..799473d 100644 --- a/oracledesk-stellar/agents/stellar/adapter.ts +++ b/oracledesk-stellar/agents/stellar/adapter.ts @@ -59,6 +59,22 @@ export type LiveResult = { dryRun: false; value: T }; export type AdapterResult = DryRunResult | LiveResult; +/** Snapshot of a market's read-only state, from `readMarket`. */ +export interface MarketRead { + marketId: number; + /** IPFS CID / URI of the question metadata (the question text itself is + * stored off-chain; see docs/frontend-integration.md). */ + metaUri: string; + closeTime: number; + reserveYes: bigint; + reserveNo: bigint; + /** YES spot price in bps (0..=10_000), from `get_price`. */ + yesBps: number; + /** YES time-weighted average over the requested `useTwapSecs` window, + * from `twap`. Present only when `useTwapSecs` was passed. */ + twapBps?: number; +} + /** * Thin, mode-aware wrapper. Each method builds the same call regardless of * mode; in dry-run it stops after simulate/describe and never signs, so a @@ -160,4 +176,49 @@ export class StellarAdapter { (client) => client.publish_trace(args), ); } + + /** + * Reads a market's price state. Unlike the write wrappers above, a read + * always queries the network — through the bindings' simulation, which + * never signs or submits — so it works identically in dry-run and live + * mode and there is no offline stand-in to return. + * + * `useTwapSecs` additionally reads `twap(market_id, useTwapSecs)`: the + * time-weighted average over that trailing window, which a single + * transaction cannot move (see market-core's accumulator, issue #11). + * Prefer it over spot for any automated trading loop. + */ + async readMarket( + marketId: bigint, + opts: { useTwapSecs?: number } = {}, + ): Promise { + const client = await this.client("market_core", "@oracledesk/bindings-market-core"); + const marketTx = await client.get_market({ market_id: marketId }); + const market = marketTx.result.unwrap() as { + meta_uri: string; + close_time: bigint; + reserve_yes: bigint; + reserve_no: bigint; + }; + const priceTx = await client.get_price({ + market_id: marketId, + outcome: { tag: "Yes", values: undefined }, + }); + const read: MarketRead = { + marketId: Number(marketId), + metaUri: market.meta_uri, + closeTime: Number(market.close_time), // unix seconds, safe as number + reserveYes: market.reserve_yes, + reserveNo: market.reserve_no, + yesBps: priceTx.result.unwrap() as number, + }; + if (opts.useTwapSecs !== undefined) { + const twapTx = await client.twap({ + market_id: marketId, + window_secs: BigInt(opts.useTwapSecs), + }); + read.twapBps = twapTx.result.unwrap() as number; + } + return read; + } } diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index bab599e..18081be 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -27,6 +27,12 @@ const DEFAULT_REDEEM_HORIZON_SECS: u64 = 365 * ttl::DAY_SECS; const MIN_REDEEM_HORIZON_SECS: u64 = 180 * ttl::DAY_SECS; const MAX_REDEEM_HORIZON_SECS: u64 = 1_825 * ttl::DAY_SECS; +/// Observations kept per market for the TWAP ring buffer (issue #11). One +/// observation is written per distinct trade timestamp; a consumer asking +/// for a time older than the oldest surviving observation gets +/// `ObservationTooOld` instead of a wrong answer. +const ORACLE_RING: u32 = 16; + // --------------------------------------------------------------------------- // Types shared with the frontend/backend // --------------------------------------------------------------------------- @@ -124,6 +130,47 @@ pub struct SettleState { pub closed: bool, } +/// Per-market TWAP accumulator (issue #11). Lives in its own entry so the +/// `Market` XDR stays unchanged. +/// +/// Semantics: at the start of every trade the price that held since the last +/// trade is folded into `price_cumulative` over the elapsed interval; the +/// post-trade price then becomes `last_price_bps` until the next trade. That +/// is the Uniswap-v2 model, and it is what makes manipulation expensive — +/// moving the average requires holding the price away from fair value across +/// one or more ledger boundaries, not just within a single transaction. +/// +/// Overflow analysis: `last_price_bps <= 10_000` and each interval adds +/// `price * dt` seconds. A 100-year market lifetime accumulates at most +/// `10_000 * 3.2e9 ~ 3.2e13`; even a pathological `dt = u64::MAX` gives +/// `1.8e19 * 1e4 = 1.8e23`. `i128::MAX` is ~1.7e38, so there is no feasible +/// lifetime that overflows — and every step is `checked_*` anyway, so a +/// violation rejects the transaction instead of corrupting the accumulator. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleState { + /// Running sum of `last_price_bps * seconds_elapsed` since creation. + pub price_cumulative: i128, + /// Timestamp the accumulator was last advanced to (the timestamp of the + /// most recent trade). The last traded price holds from here onward. + pub last_ts: u64, + /// YES price in bps as of the most recent trade. + pub last_price_bps: u32, + /// Ring slot of the newest observation, in `0..ORACLE_RING`. + pub obs_index: u32, + /// Observations written so far, capped at `ORACLE_RING`. + pub obs_count: u32, +} + +/// One point in the TWAP ring buffer. At most one per distinct timestamp. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Observation { + pub ts: u64, + /// `OracleState::price_cumulative` as of `ts`. + pub cumulative: i128, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Config { @@ -154,6 +201,8 @@ pub enum DataKey { Market(u64), Position(u64, Address), Settle(u64), + Oracle(u64), + Obs(u64, u32), } // --------------------------------------------------------------------------- @@ -182,7 +231,9 @@ pub enum Error { NothingToRedeem = 16, AlreadyClaimed = 17, Overflow = 18, - // 19 is reserved for the TWAP accumulator's `ObservationTooOld` (#11). + /// `observe` / `twap` asked for a time older than every surviving + /// observation of the market (the ring keeps the most recent 16). + ObservationTooOld = 19, /// `finalize_market` called before the redemption deadline. RedemptionWindowOpen = 20, /// Market already closed by `finalize_market`; redemption is over. @@ -430,6 +481,28 @@ impl MarketCore { Self::debug_assert_share_invariant(&market, &settle); Self::save_settle(&env, id, &settle); + // TWAP accumulator: cumulative 0 at creation; the seeded price holds + // from here until the first trade (slot 0 is the creation point). + let now = env.ledger().timestamp(); + let oracle = OracleState { + price_cumulative: 0, + last_ts: now, + last_price_bps: math::price_yes_bps(market.reserve_yes, market.reserve_no)? as u32, + obs_index: 0, + obs_count: 1, + }; + Self::save_oracle(&env, id, &oracle, market.close_time); + Self::save_obs( + &env, + id, + 0, + &Observation { + ts: now, + cumulative: 0, + }, + market.close_time, + ); + Self::token(&env)?.transfer(&creator, env.current_contract_address(), &seed_amount); MarketCreated { @@ -460,6 +533,13 @@ impl MarketCore { trader.require_auth(); let mut m = Self::load_market(&env, market_id)?; Self::assert_tradable(&env, &m)?; + // TWAP: fold the pre-trade price over the elapsed interval before + // reserves move; `last_price_bps` is refreshed from the post-trade + // reserves below and the state is persisted once (issue #11). + let mut oracle = Self::load_oracle_opt(&env, market_id, m.close_time); + if let Some(st) = oracle.as_mut() { + Self::accumulate(&env, market_id, &m, st)?; + } let (r_bought, r_other) = Self::reserves_for(&m, outcome); let q = math::calc_buy(r_bought, r_other, collateral_in, m.fee_bps)?; @@ -482,6 +562,10 @@ impl MarketCore { } Self::save_position(&env, market_id, &trader, &pos, m.close_time); Self::save_market(&env, market_id, &m); + if let Some(mut st) = oracle { + st.last_price_bps = math::price_yes_bps(m.reserve_yes, m.reserve_no)? as u32; + Self::save_oracle(&env, market_id, &st, m.close_time); + } // Legacy markets (no `Settle` entry) predate this bookkeeping. if let Some(mut settle) = Self::load_settle_opt(&env, market_id) { match outcome { @@ -536,6 +620,12 @@ impl MarketCore { trader.require_auth(); let mut m = Self::load_market(&env, market_id)?; Self::assert_tradable(&env, &m)?; + // TWAP: same rule as `buy` — fold the pre-trade price in first, then + // refresh `last_price_bps` from the post-trade reserves below. + let mut oracle = Self::load_oracle_opt(&env, market_id, m.close_time); + if let Some(st) = oracle.as_mut() { + Self::accumulate(&env, market_id, &m, st)?; + } let (r_sold, r_other) = Self::reserves_for(&m, outcome); let q = math::calc_sell(r_sold, r_other, collateral_out, m.fee_bps)?; @@ -562,6 +652,10 @@ impl MarketCore { m.sets_minted = m.sets_minted.checked_sub(q.gross).ok_or(Error::Overflow)?; m.fees_accrued = m.fees_accrued.checked_add(q.fee).ok_or(Error::Overflow)?; Self::save_market(&env, market_id, &m); + if let Some(mut st) = oracle { + st.last_price_bps = math::price_yes_bps(m.reserve_yes, m.reserve_no)? as u32; + Self::save_oracle(&env, market_id, &st, m.close_time); + } if let Some(mut settle) = Self::load_settle_opt(&env, market_id) { match outcome { Outcome::Yes => { @@ -872,6 +966,51 @@ impl MarketCore { Ok(p as u32) } + /// Cumulative `price_bps * seconds` for YES since creation, extrapolated + /// to now: `observe(market_id, 0)`. Ready-made for consumers that store + /// their own checkpoints and compute TWAPs off-chain. + pub fn price_cumulative(env: Env, market_id: u64) -> Result { + let m = Self::load_market(&env, market_id)?; + let st = Self::load_oracle(&env, market_id, m.close_time)?; + Self::observe_at(&env, market_id, &st, env.ledger().timestamp()) + } + + /// Cumulative YES-price-seconds as of `now - seconds_ago`. + /// + /// The accumulator is piecewise linear in time (the price only moves when + /// a trade lands), so between observations the value is interpolated + /// along the chord, and times after the last trade extrapolate at the + /// last traded price. Fails with `ObservationTooOld` when `seconds_ago` + /// reaches before the oldest surviving observation (the ring keeps the + /// most recent 16), and with `NotSupported` for markets created before + /// the accumulator existed. A missing (archived) ring slot is skipped, + /// never a panic. + pub fn observe(env: Env, market_id: u64, seconds_ago: u64) -> Result { + let m = Self::load_market(&env, market_id)?; + let st = Self::load_oracle(&env, market_id, m.close_time)?; + let now = env.ledger().timestamp(); + Self::observe_at(&env, market_id, &st, now.saturating_sub(seconds_ago)) + } + + /// Time-weighted average YES price in bps over the trailing + /// `window_secs`, floored. Rejects a zero window (`InvalidAmount`). + /// A window reaching before the oldest observation fails with + /// `ObservationTooOld`; a window extending past `close_time` keeps + /// averaging the final price, because the accumulator only moves when + /// trades are possible. + pub fn twap(env: Env, market_id: u64, window_secs: u64) -> Result { + if window_secs == 0 { + return Err(Error::InvalidAmount); + } + let m = Self::load_market(&env, market_id)?; + let st = Self::load_oracle(&env, market_id, m.close_time)?; + let now = env.ledger().timestamp(); + let cum_now = Self::observe_at(&env, market_id, &st, now)?; + let cum_then = Self::observe_at(&env, market_id, &st, now.saturating_sub(window_secs))?; + let delta = cum_now.checked_sub(cum_then).ok_or(Error::Overflow)?; + Ok((delta / window_secs as i128) as u32) + } + /// Shares a `buy` of `collateral_in` would return right now. pub fn quote_buy( env: Env, @@ -1051,6 +1190,136 @@ impl MarketCore { ttl::bump_persistent(env, &key, ttl::long_lived_target(env)); } + /// `None` for markets created before the accumulator existed; unlike a + /// missing `Settle` entry this cannot be reconstructed on-chain, so the + /// oracle views refuse them (`NotSupported`) while trading keeps working + /// (with no TWAP support) for backwards compatibility. + fn load_oracle_opt(env: &Env, id: u64, close_time: u64) -> Option { + let key = DataKey::Oracle(id); + let st = env.storage().persistent().get::<_, OracleState>(&key)?; + ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); + Some(st) + } + + fn load_oracle(env: &Env, id: u64, close_time: u64) -> Result { + Self::load_oracle_opt(env, id, close_time).ok_or(Error::NotSupported) + } + + fn save_oracle(env: &Env, id: u64, st: &OracleState, close_time: u64) { + let key = DataKey::Oracle(id); + env.storage().persistent().set(&key, st); + ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); + } + + fn save_obs(env: &Env, id: u64, slot: u32, obs: &Observation, close_time: u64) { + let key = DataKey::Obs(id, slot); + env.storage().persistent().set(&key, obs); + ttl::bump_persistent(env, &key, ttl::market_target(env, close_time)); + } + + /// Advances the accumulator to `now` using the pre-trade price and + /// writes one ring observation when `now` is a new timestamp. + /// + /// Same-timestamp trades do not accumulate and do not write an + /// observation; only `last_price_bps` is refreshed by the caller after + /// the trade lands. That is what makes the accumulator + /// manipulation-resistant: an attacker trading back and forth inside one + /// ledger can move spot, but the price that counts toward future time is + /// whatever the last trade of the ledger left behind — which arbitrageurs + /// can punish as soon as the next ledger opens. + fn accumulate(env: &Env, id: u64, m: &Market, st: &mut OracleState) -> Result<(), Error> { + let now = env.ledger().timestamp(); + if now <= st.last_ts { + return Ok(()); + } + let dt = (now - st.last_ts) as i128; + st.price_cumulative = st + .price_cumulative + .checked_add( + (st.last_price_bps as i128) + .checked_mul(dt) + .ok_or(Error::Overflow)?, + ) + .ok_or(Error::Overflow)?; + st.last_ts = now; + st.obs_index = (st.obs_index + 1) % ORACLE_RING; + st.obs_count = core::cmp::min(st.obs_count + 1, ORACLE_RING); + Self::save_obs( + env, + id, + st.obs_index, + &Observation { + ts: now, + cumulative: st.price_cumulative, + }, + m.close_time, + ); + Ok(()) + } + + /// `price_cumulative` as of time `t`. Per-slot reads only, so a missing + /// (never-written or archived) slot is skipped, never a panic. + fn observe_at(env: &Env, id: u64, st: &OracleState, t: u64) -> Result { + if t >= st.last_ts { + // Counterfactual extension: the last traded price holds. + let ext = (st.last_price_bps as i128) + .checked_mul((t - st.last_ts) as i128) + .ok_or(Error::Overflow)?; + return st.price_cumulative.checked_add(ext).ok_or(Error::Overflow); + } + // `obs_count >= 1` for every live market: slot 0 is the creation + // point. `oldest` is the ring position of the oldest written slot. + let oldest = (st.obs_index + ORACLE_RING + 1 - st.obs_count) % ORACLE_RING; + let mut a: Option = None; // newest point with ts <= t + let mut b: Option = None; // oldest point with ts >= t + for k in 0..st.obs_count { + let slot = (oldest + k) % ORACLE_RING; + let Some(obs) = env + .storage() + .persistent() + .get::<_, Observation>(&DataKey::Obs(id, slot)) + else { + continue; + }; + if obs.ts <= t && a.is_none_or(|p| obs.ts > p.ts) { + a = Some(obs); + } + if obs.ts >= t && b.is_none_or(|p| obs.ts < p.ts) { + b = Some(obs); + } + } + // If the newest ring slot was archived, the live edge still brackets + // `t` from above; it is always at least as new as every observation. + if b.is_none_or(|p| st.last_ts < p.ts) { + b = Some(Observation { + ts: st.last_ts, + cumulative: st.price_cumulative, + }); + } + let Some(a) = a else { + return Err(Error::ObservationTooOld); + }; + let Some(b) = b else { + return Err(Error::ObservationTooOld); + }; + if a.ts == t { + return Ok(a.cumulative); + } + if b.ts == t { + return Ok(b.cumulative); + } + // Chord interpolation: exact between adjacent observations (the + // accumulator is linear on each interval, slope = the price that + // held), an approximation across a lost slot. + let interp = b + .cumulative + .checked_sub(a.cumulative) + .and_then(|d| d.checked_mul((t - a.ts) as i128)) + .ok_or(Error::Overflow)? + / (b.ts - a.ts) as i128; + a.cumulative.checked_add(interp).ok_or(Error::Overflow) + } + /// Debug-build check of the share-count invariant /// `reserve_ + outstanding_ == sets_minted`. /// @@ -1952,4 +2221,369 @@ mod test { .try_set_default_redeem_horizon(&(365 * ttl::DAY_SECS)) .is_err()); } + + // ----- TWAP accumulator (#11) ------------------------------------------ + + fn oracle_state(env: &Env, client: &MarketCoreClient, market_id: u64) -> OracleState { + env.as_contract(&client.address, || { + env.storage() + .persistent() + .get::<_, OracleState>(&DataKey::Oracle(market_id)) + .unwrap() + }) + } + + fn obs_at( + env: &Env, + client: &MarketCoreClient, + market_id: u64, + slot: u32, + ) -> Option { + env.as_contract(&client.address, || { + env.storage() + .persistent() + .get::<_, Observation>(&DataKey::Obs(market_id, slot)) + }) + } + + #[test] + fn twap_equals_spot_without_trades() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + let spot = client.get_price(&market_id, &Outcome::Yes); + + env.ledger().set_timestamp(3_600); + // No trade yet: every second so far held the seeded price. + assert_eq!(client.twap(&market_id, &3_600), spot); + assert_eq!(client.twap(&market_id, &60), spot); + assert_eq!(client.price_cumulative(&market_id), (spot as i128) * 3_600); + assert_eq!(client.observe(&market_id, &1_800), (spot as i128) * 1_800); + } + + #[test] + fn twap_weights_by_time() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + + env.ledger().set_timestamp(1_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let p_a = client.get_price(&market_id, &Outcome::Yes) as i128; + + env.ledger().set_timestamp(1_100); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(5 * 10_000_000_i128), + &0_i128, + ); + let p_b = client.get_price(&market_id, &Outcome::Yes) as i128; + + env.ledger().set_timestamp(1_400); + // [1000, 1100) held p_a, [1100, 1400) held p_b (extrapolated past + // the last trade). The first 1000 s of seeded price are outside the + // window and must not contribute. + let expected = (p_a * 100 + p_b * 300) / 400; + assert_eq!(client.twap(&market_id, &400) as i128, expected); + assert_eq!(client.twap(&market_id, &300), p_b as u32); + } + + #[test] + fn same_timestamp_trades_do_not_accumulate() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + + env.ledger().set_timestamp(1_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let p1 = client.get_price(&market_id, &Outcome::Yes); + let cum1 = client.price_cumulative(&market_id); + let twap1 = client.twap(&market_id, &500); + let st1 = oracle_state(&env, &client, market_id); + assert_eq!(st1.obs_count, 2, "creation slot + first trade"); + assert_eq!(st1.obs_index, 1); + + // A second trade in the same ledger moves spot but must not write an + // observation or advance the cumulative. + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let p2 = client.get_price(&market_id, &Outcome::Yes); + assert!(p2 > p1); + + let st2 = oracle_state(&env, &client, market_id); + assert_eq!(st2.obs_count, 2); + assert_eq!(st2.obs_index, 1); + assert_eq!(st2.price_cumulative, st1.price_cumulative); + assert_eq!(client.price_cumulative(&market_id), cum1); + assert_eq!(client.twap(&market_id, &500), twap1); + } + + #[test] + fn manipulation_within_one_ledger_does_not_move_twap() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5_000, 600_000); + + env.ledger().set_timestamp(5_000); + let spot_before = client.get_price(&market_id, &Outcome::Yes); + let twap_before = client.twap(&market_id, &1_000); + + // Big buy, then a partial sell-back — all inside one ledger. + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(50 * 10_000_000_i128), + &0_i128, + ); + let spot_after_buy = client.get_price(&market_id, &Outcome::Yes); + assert!( + spot_after_buy > spot_before + 200, + "the buy must move spot materially ({spot_before} -> {spot_after_buy})" + ); + assert_eq!(client.twap(&market_id, &1_000), twap_before); + + client.sell( + &trader, + &market_id, + &Outcome::Yes, + &(40 * 10_000_000_i128), + &i128::MAX, + ); + assert_eq!(client.twap(&market_id, &1_000), twap_before); + } + + #[test] + fn observe_interpolates_between_observations() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + let p0 = client.get_price(&market_id, &Outcome::Yes) as i128; + + env.ledger().set_timestamp(1_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + let p_a = client.get_price(&market_id, &Outcome::Yes) as i128; + + env.ledger().set_timestamp(1_100); + // t = 950 sits between the creation point (0) and the t=1000 + // observation; the chord has slope p0 (the price that held). + assert_eq!(client.observe(&market_id, &150), p0 * 950); + // Exact at a stored observation. + assert_eq!(client.observe(&market_id, &100), p0 * 1_000); + // The live edge: cumulative at t=1000 plus p_a over the 100 s since. + assert_eq!(client.price_cumulative(&market_id), p0 * 1_000 + p_a * 100); + } + + #[test] + fn observe_rejects_too_old() { + let (env, client, creator, trader, _, _) = setup_market(); + env.ledger().set_timestamp(1_000); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + env.ledger().set_timestamp(2_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + + // 1001 s ago is one second before the creation observation. + assert_eq!( + client.try_observe(&market_id, &1_001), + Err(Ok(Error::ObservationTooOld)) + ); + // A window reaching back that far fails the same way. + assert_eq!( + client.try_twap(&market_id, &1_001), + Err(Ok(Error::ObservationTooOld)) + ); + // Landing exactly on the creation observation is fine. + assert_eq!(client.observe(&market_id, &1_000), 0); + } + + #[test] + fn ring_buffer_wraps_after_16_distinct_timestamps() { + let (env, client, creator, trader, _, _) = setup_market(); + env.ledger().set_timestamp(1_000); + let market_id = create_market_with(&env, &client, &creator, 5_000, 600_000); + + for k in 1..=17u64 { + env.ledger().set_timestamp(1_000 + 100 * k); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + ); + } + + // 1 creation + 17 trade observations into 16 slots: the oldest + // surviving slot holds t=1200, the newest holds t=2700. + let st = oracle_state(&env, &client, market_id); + assert_eq!(st.obs_count, 16); + assert_eq!(st.obs_index, 1); + assert_eq!(obs_at(&env, &client, market_id, 2).unwrap().ts, 1_200); + assert_eq!(obs_at(&env, &client, market_id, 0).unwrap().ts, 2_600); + assert_eq!(obs_at(&env, &client, market_id, 1).unwrap().ts, 2_700); + + // The oldest surviving observation still answers; one second + // earlier is gone from the ring. + assert!(client.observe(&market_id, &1_500) > 0); + assert_eq!( + client.try_observe(&market_id, &1_501), + Err(Ok(Error::ObservationTooOld)) + ); + } + + #[test] + fn quote_views_do_not_write_oracle_state() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 600_000); + env.ledger().set_timestamp(1_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + env.ledger().set_timestamp(1_100); + + let st_before = oracle_state(&env, &client, market_id); + let slots_before: [Option; ORACLE_RING as usize] = + core::array::from_fn(|k| obs_at(&env, &client, market_id, k as u32)); + + let _ = client.quote_buy(&market_id, &Outcome::Yes, &(10 * 10_000_000_i128)); + let _ = client.quote_sell(&market_id, &Outcome::No, &(5 * 10_000_000_i128)); + let _ = client.get_price(&market_id, &Outcome::No); + let _ = client.get_market(&market_id); + let _ = client.observe(&market_id, &50); + let _ = client.twap(&market_id, &100); + let _ = client.price_cumulative(&market_id); + + assert_eq!(oracle_state(&env, &client, market_id), st_before); + let slots_after: [Option; ORACLE_RING as usize] = + core::array::from_fn(|k| obs_at(&env, &client, market_id, k as u32)); + assert_eq!(slots_before, slots_after); + } + + #[test] + fn accumulator_is_overflow_safe_over_ten_years() { + let (env, client, creator, trader, _, _) = setup_market(); + let ten_years: u64 = 10 * 365 * ttl::DAY_SECS; + env.ledger().set_timestamp(1_000); + let market_id = create_market_with( + &env, + &client, + &creator, + 5_000, + ten_years + 30 * ttl::DAY_SECS, + ); + + // Monthly trades for 10 years, alternating sides so the price stays + // in a sane band. Each step feeds a ~30-day dt through the checked + // accumulator arithmetic; the final cumulative is ~p_avg * 3.2e8. + let step = ten_years / 120; + for m in 1..=119u64 { + env.ledger().set_timestamp(1_000 + m * step); + let outcome = if m % 2 == 0 { + Outcome::Yes + } else { + Outcome::No + }; + client.buy(&trader, &market_id, &outcome, &10_000_000_i128, &0_i128); + } + env.ledger().set_timestamp(1_000 + 120 * step); + let p_held_for_last_day = client.get_price(&market_id, &Outcome::Yes); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &10_000_000_i128, + &0_i128, + ); + + let cum = client.price_cumulative(&market_id); + let elapsed = (env.ledger().timestamp() - 1_000) as i128; + assert!(cum > 0); + // Every second contributes at most 10_000 bps. + assert!(cum <= elapsed * 10_000); + // The trailing day was held at the pre-final-trade price the whole + // time (the final trade lands exactly at `now`). + assert_eq!(client.twap(&market_id, &ttl::DAY_SECS), p_held_for_last_day); + assert_eq!(oracle_state(&env, &client, market_id).obs_count, 16); + } + + #[test] + fn legacy_market_oracle_views_not_supported() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); + // Simulate a market created before the accumulator existed. + env.as_contract(&client.address, || { + env.storage() + .persistent() + .remove(&DataKey::Oracle(market_id)); + env.storage() + .persistent() + .remove(&DataKey::Obs(market_id, 0)); + }); + assert_eq!( + client.try_observe(&market_id, &1), + Err(Ok(Error::NotSupported)) + ); + assert_eq!( + client.try_twap(&market_id, &10), + Err(Ok(Error::NotSupported)) + ); + assert_eq!( + client.try_price_cumulative(&market_id), + Err(Ok(Error::NotSupported)) + ); + } + + /// Cost harness for the numbers reported in the PR for issue #11. Run + /// explicitly with: `cargo test -p market-core measure_buy_resources -- + /// --ignored --nocapture`. The same test body run against the parent + /// commit's build gives the before/after delta for `buy`. + #[test] + #[ignore = "measurement harness; run explicitly with --ignored --nocapture"] + fn measure_buy_resources() { + extern crate std; + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = create_market_with(&env, &client, &creator, 5_000, 10_000_000); + env.ledger().set_timestamp(1_000); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + std::println!( + "buy (new timestamp, with accumulator): {:?}", + env.cost_estimate().resources() + ); + } } diff --git a/oracledesk-stellar/docs/STATUS.md b/oracledesk-stellar/docs/STATUS.md index 1750a77..8fadff5 100644 --- a/oracledesk-stellar/docs/STATUS.md +++ b/oracledesk-stellar/docs/STATUS.md @@ -221,6 +221,17 @@ markers in the files themselves: TTL-archived before it is finalized and needs a restore (or the keeper, `docs/wave-issues/14-storage-ttl-keeper.md`) first; the deadline itself is on-chain state, so a late finalization is still exact. +- **The TWAP ring keeps only the most recent 16 observations.** Each + observation is written per trade timestamp, so 16 distinct-timestamp trades + supersede the creation point: `observe`/`twap` windows that reach further + back than the surviving ring revert with `ObservationTooOld` rather than + returning a value. Windows that extend past the newest observation keep + averaging the last traded price (the accumulator extends + counterfactually), including past `close_time` until a resolver action. + `twap` divides with floor rounding; off-chain mirroring code must floor + identically. Markets created before the accumulator (issue #11) return + `NotSupported` from `observe`/`twap`/`price_cumulative`, and an archived + `Oracle`/`Obs` entry needs a restore like any other entry. - **Single admin key per contract**, no multisig/timelock. A compromised admin key has a large blast radius per contract (see `docs/wave-issues/12-architecture-deep-dive-and-threat-model.md`, which diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index 1b253f4..a0353e4 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -51,6 +51,22 @@ suppressing redemptions. Markets created before this bookkeeping existed have no `Settle` entry: they have no deadline and cannot be finalized (`NotSupported`), and stay redeemable through the old paths. +A per-market **TWAP accumulator** records a time-weighted price. Every trade +(`buy`/`sell`) folds the market's pre-trade price into a cumulative +`price × time` value before reserves move (Uniswap-v2 semantics), with state +in `Oracle(id)` and a 16-slot ring of `Obs(id, k)` observations so the +`Market` entry's XDR is unchanged. `observe(id, seconds_ago)`, +`twap(id, window_secs)` and `price_cumulative(id)` read it back: between two +bracketing observations the cumulative is interpolated (exact — the +cumulative is piecewise-linear between trades, since no price moves inside +an interval), past the newest observation the last price +is extended counterfactually, and a time earlier than the oldest surviving +observation fails with `ObservationTooOld`. Same-timestamp trades only +refresh the spot price — nothing is accumulated within one ledger, so a +manipulator cannot move the TWAP without holding the price across a ledger +boundary. Markets created before the accumulator existed return +`NotSupported` from these views. + ### resolver Two independent resolution modes, both ultimately calling @@ -128,6 +144,10 @@ shared policy: `finalize_market` works after a restore, and the deadline is computed from on-chain state (`finalized_at + horizon_secs`), so a late finalization is still exact. +- The market-core `Oracle` entry and every written `Obs` slot are extended to + the same close-time-aware target as the market's other entries. Unwritten + ring slots are never extended and the read path skips missing slots, so a + half-used ring costs nothing and never fails a read. - Markets whose `close_time` is further out than `max_ttl` cannot be kept alive by a single bump and need periodic touches or the keeper described in [`docs/wave-issues/14-storage-ttl-keeper.md`](wave-issues/14-storage-ttl-keeper.md), diff --git a/oracledesk-stellar/docs/frontend-integration.md b/oracledesk-stellar/docs/frontend-integration.md index 63007e5..6da8c63 100644 --- a/oracledesk-stellar/docs/frontend-integration.md +++ b/oracledesk-stellar/docs/frontend-integration.md @@ -79,6 +79,43 @@ 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). +## Reading TWAP prices + +`get_price(market_id)` is the instantaneous spot price; it is trivially +movable by a single trade. For anything automated (bots, alerts, fair-value +displays) prefer the accumulated oracle views: + +```ts +const { result } = await contract.twap({ market_id: 0n, window_secs: 900n }); +// price in bps of YES, time-weighted over the trailing 15 minutes +``` + +- `twap(market_id, window_secs)` — time-weighted average price in bps over + the trailing `window_secs`, floor-rounded. +- `observe(market_id, seconds_ago)` — the cumulative `price × seconds` + value at a past timestamp; difference two of them and divide for a + custom window. +- `price_cumulative(market_id)` — the same value at "now", for streaming + consumers that just want the accumulator. + +Caveats to design around: + +- Observations are written once per trade *timestamp*, so at most the most + recent 16 distinct-timestamp trades are covered; a `seconds_ago` (or + `window_secs` span) that reaches past them reverts with + `ObservationTooOld`. For long windows on busy markets, poll + `price_cumulative`/`observe` regularly and difference snapshots client-side, + or mirror the ring with `agents/core/strategy.ts`'s + `twapFromObservations`. +- Past the newest observation the last traded price is extended + counterfactually (this includes after `close_time`, until resolution), so + a quiet market's TWAP converges to its last price rather than failing. +- Interpolation between two observations is exact — price is piecewise + constant between trades — but `twap` floors, so off-chain comparisons + should floor identically. +- Markets created before the accumulator existed revert `NotSupported`; + fall back to `get_price` for them. + ## Category enum `Category` is a plain enum (`Crypto | Macro | Geopolitics | Sports | Culture diff --git a/oracledesk-stellar/packages/bindings/market-core/package-lock.json b/oracledesk-stellar/packages/bindings/market-core/package-lock.json index 098fe8a..9f4f26e 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", @@ -47,9 +47,9 @@ } }, "node_modules/@stellar/stellar-sdk": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.0.tgz", - "integrity": "sha512-pnB1t4bdnkN8M7tRIcLXnNboq+Hof62NzPMnQrl5MWBNC/d1vUbdrzXgy7yw8I+0hbfz5YN64uVb84BqzIpYiQ==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.1.tgz", + "integrity": "sha512-PPM1JA51NChSPyMkh+T9sHxJtP/v9e9Gcyc2BMNvnGvbZwqoKJclRSdPgmalLDAmTnTewGs7VMM9nRxDeCzzgQ==", "license": "Apache-2.0", "dependencies": { "@noble/ed25519": "^3.1.0", @@ -311,9 +311,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", @@ -534,9 +534,9 @@ } }, "node_modules/smol-toml": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", - "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -560,9 +560,9 @@ } }, "node_modules/uint8array-extras": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", - "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", + "integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==", "license": "MIT", "engines": { "node": ">=18" diff --git a/oracledesk-stellar/packages/bindings/market-core/src/index.ts b/oracledesk-stellar/packages/bindings/market-core/src/index.ts index 536442a..05d6422 100644 --- a/oracledesk-stellar/packages/bindings/market-core/src/index.ts +++ b/oracledesk-stellar/packages/bindings/market-core/src/index.ts @@ -51,7 +51,26 @@ export const Errors = { 15: {message:"MarketTooLarge"}, 16: {message:"NothingToRedeem"}, 17: {message:"AlreadyClaimed"}, - 18: {message:"Overflow"} + 18: {message:"Overflow"}, + /** + * `observe` / `twap` asked for a time older than every surviving + * observation of the market (the ring keeps the most recent 16). + */ + 19: {message:"ObservationTooOld"}, + /** + * `finalize_market` called before the redemption deadline. + */ + 20: {message:"RedemptionWindowOpen"}, + /** + * Market already closed by `finalize_market`; redemption is over. + */ + 21: {message:"RedemptionClosed"}, + /** + * Legacy market created before `SettleState` existed: outstanding shares + * cannot be reconstructed on-chain, so it has no deadline and cannot be + * finalized (still fully redeemable). + */ + 22: {message:"NotSupported"} } @@ -111,7 +130,7 @@ 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: "Paused", values: void} | {tag: "NextId", values: void} | {tag: "DefaultRedeemHorizon", values: void} | {tag: "Market", values: readonly [u64]} | {tag: "Position", values: readonly [u64, string]} | {tag: "Settle", values: readonly [u64]} | {tag: "Oracle", values: readonly [u64]} | {tag: "Obs", values: readonly [u64, u32]}; export type Outcome = {tag: "Yes", values: void} | {tag: "No", values: void}; @@ -125,10 +144,100 @@ export interface Position { + +/** + * One point in the TWAP ring buffer. At most one per distinct timestamp. + */ +export interface Observation { + /** + * `OracleState::price_cumulative` as of `ts`. + */ +cumulative: i128; + ts: u64; +} + + +/** + * Per-market TWAP accumulator (issue #11). Lives in its own entry so the + * `Market` XDR stays unchanged. + * + * Semantics: at the start of every trade the price that held since the last + * trade is folded into `price_cumulative` over the elapsed interval; the + * post-trade price then becomes `last_price_bps` until the next trade. That + * is the Uniswap-v2 model, and it is what makes manipulation expensive — + * moving the average requires holding the price away from fair value across + * one or more ledger boundaries, not just within a single transaction. + * + * Overflow analysis: `last_price_bps <= 10_000` and each interval adds + * `price * dt` seconds. A 100-year market lifetime accumulates at most + * `10_000 * 3.2e9 ~ 3.2e13`; even a pathological `dt = u64::MAX` gives + * `1.8e19 * 1e4 = 1.8e23`. `i128::MAX` is ~1.7e38, so there is no feasible + * lifetime that overflows — and every step is `checked_*` anyway, so a + * violation rejects the transaction instead of corrupting the accumulator. + */ +export interface OracleState { + /** + * YES price in bps as of the most recent trade. + */ +last_price_bps: u32; + /** + * Timestamp the accumulator was last advanced to (the timestamp of the + * most recent trade). The last traded price holds from here onward. + */ +last_ts: u64; + /** + * Observations written so far, capped at `ORACLE_RING`. + */ +obs_count: u32; + /** + * Ring slot of the newest observation, in `0..ORACLE_RING`. + */ +obs_index: u32; + /** + * Running sum of `last_price_bps * seconds_elapsed` since creation. + */ +price_cumulative: i128; +} + + +/** + * Per-market settlement bookkeeping (issue #14). Lives in its own entry so + * the `Market` XDR stays unchanged. + */ +export interface SettleState { + /** + * `finalize_market` has swept the market's remaining balance; no + * redemption or claim can ever pay out again. + */ +closed: boolean; + /** + * Timestamp of `resolve` / `void_market`; 0 while the market is Open. + */ +finalized_at: u64; + /** + * Redemption window, snapshotted at creation from the admin default. + * `redeem_deadline = finalized_at + horizon_secs`. + */ +horizon_secs: u64; + outstanding_no: i128; + /** + * Outcome shares held outside the pool (user positions), still + * redeemable. `redeem` decrements the redeeming holder's shares on both + * sides, so a losing-side redemption (payout 0) still clears here. + */ +outstanding_yes: i128; + /** + * Collateral already paid to holders and the LP (`redeem` + + * `claim_pool_remainder` payouts); tracks what has left the market. + */ +paid_out: i128; +} + export type MarketStatus = {tag: "Open", values: void} | {tag: "Resolved", values: readonly [Outcome]} | {tag: "Void", values: void}; + 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. @@ -146,6 +255,17 @@ export interface Client { */ 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 twap 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. + * Time-weighted average YES price in bps over the trailing + * `window_secs`, floored. Rejects a zero window (`InvalidAmount`). + * A window reaching before the oldest observation fails with + * `ObservationTooOld`; a window extending past `close_time` keeps + * averaging the final price, because the accumulator only moves when + * trades are possible. + */ + twap: ({market_id, window_secs}: {market_id: u64, window_secs: 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. @@ -153,6 +273,21 @@ export interface Client { */ redeem: ({holder, market_id}: {holder: string, market_id: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a observe 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. + * Cumulative YES-price-seconds as of `now - seconds_ago`. + * + * The accumulator is piecewise linear in time (the price only moves when + * a trade lands), so between observations the value is interpolated + * along the chord, and times after the last trade extrapolate at the + * last traded price. Fails with `ObservationTooOld` when `seconds_ago` + * reaches before the oldest surviving observation (the ring keeps the + * most recent 16), and with `NotSupported` for markets created before + * the accumulator existed. A missing (archived) ring slot is skipped, + * never a panic. + */ + observe: ({market_id, seconds_ago}: {market_id: u64, seconds_ago: u64}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a resolve 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. */ @@ -223,6 +358,13 @@ export interface Client { */ set_treasury: ({treasury}: {treasury: string}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a settle_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. + * Outstanding-share accounting for a market (issue #14). + * `NotSupported` for markets created before this bookkeeping existed. + */ + settle_state: ({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 +373,44 @@ 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 finalize_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. + * Permissionless close-out of a final market after its redemption + * deadline. Sweeps the market's entire remaining balance — value + * reserved for shares never redeemed, an unclaimed LP remainder, + * per-holder rounding dust and accrued fees — to the treasury, and + * marks the market closed so nothing can ever pay out again. + * + * The deadline is `redeem_deadline(market_id)` = `finalized_at + + * horizon_secs`, the horizon being snapshotted at creation from the + * admin default. Anyone who redeems before the deadline is paid the + * exact `settle_value` they would have received without this call; + * only shares still unredeemed at the deadline are forfeit, and their + * value goes to the treasury rather than the LP so the LP cannot + * profit from suppressing redemptions. + * + * Returns the amount swept. The swept total is exactly the market's + * contract balance: sets plus fees minus everything already paid out. + */ + finalize_market: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a redeem_deadline 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. + * Timestamp after which unredeemed shares are forfeit (see + * `finalize_market`). `None` while the market is still Open — its + * deadline is not known yet — and for legacy markets, which have no + * deadline and cannot be finalized. + */ + redeem_deadline: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a price_cumulative 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. + * Cumulative `price_bps * seconds` for YES since creation, extrapolated + * to now: `observe(market_id, 0)`. Ready-made for consumers that store + * their own checkpoints and compute TWAPs off-chain. + */ + price_cumulative: ({market_id}: {market_id: u64}, 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. @@ -243,6 +423,14 @@ export interface Client { */ claim_pool_remainder: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a set_default_redeem_horizon 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 + * horizon snapshotted at their creation. Must be within + * [180 days, 1825 days]. + */ + set_default_redeem_horizon: ({secs}: {secs: u64}, options?: MethodOptions) => Promise>> + } export class Client extends ContractClient { static async deploy( @@ -263,21 +451,29 @@ export class Client extends ContractClient { } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAEgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAABlBhdXNlZAAAAAAAAgAAAAAAAAAKSW52YWxpZEZlZQAAAAAAAwAAAAAAAAANSW52YWxpZEFtb3VudAAAAAAAAAQAAAAAAAAAEkludmFsaWRQcm9iYWJpbGl0eQAAAAAABQAAAAAAAAAQSW52YWxpZENsb3NlVGltZQAAAAYAAAAAAAAADk1hcmtldE5vdEZvdW5kAAAAAAAHAAAAAAAAAA1NYXJrZXROb3RPcGVuAAAAAAAACAAAAAAAAAAMTWFya2V0Q2xvc2VkAAAACQAAAAAAAAAMTm90Q2xvc2VkWWV0AAAACgAAAAAAAAAITm90RmluYWwAAAALAAAAAAAAABBTbGlwcGFnZUV4Y2VlZGVkAAAADAAAAAAAAAASSW5zdWZmaWNpZW50U2hhcmVzAAAAAAANAAAAAAAAABVJbnN1ZmZpY2llbnRMaXF1aWRpdHkAAAAAAAAOAAAAAAAAAA5NYXJrZXRUb29MYXJnZQAAAAAADwAAAAAAAAAPTm90aGluZ1RvUmVkZWVtAAAAABAAAAAAAAAADkFscmVhZHlDbGFpbWVkAAAAAAARAAAAAAAAAAhPdmVyZmxvdwAAABI=", + new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAFgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAABlBhdXNlZAAAAAAAAgAAAAAAAAAKSW52YWxpZEZlZQAAAAAAAwAAAAAAAAANSW52YWxpZEFtb3VudAAAAAAAAAQAAAAAAAAAEkludmFsaWRQcm9iYWJpbGl0eQAAAAAABQAAAAAAAAAQSW52YWxpZENsb3NlVGltZQAAAAYAAAAAAAAADk1hcmtldE5vdEZvdW5kAAAAAAAHAAAAAAAAAA1NYXJrZXROb3RPcGVuAAAAAAAACAAAAAAAAAAMTWFya2V0Q2xvc2VkAAAACQAAAAAAAAAMTm90Q2xvc2VkWWV0AAAACgAAAAAAAAAITm90RmluYWwAAAALAAAAAAAAABBTbGlwcGFnZUV4Y2VlZGVkAAAADAAAAAAAAAASSW5zdWZmaWNpZW50U2hhcmVzAAAAAAANAAAAAAAAABVJbnN1ZmZpY2llbnRMaXF1aWRpdHkAAAAAAAAOAAAAAAAAAA5NYXJrZXRUb29MYXJnZQAAAAAADwAAAAAAAAAPTm90aGluZ1RvUmVkZWVtAAAAABAAAAAAAAAADkFscmVhZHlDbGFpbWVkAAAAAAARAAAAAAAAAAhPdmVyZmxvdwAAABIAAAB9YG9ic2VydmVgIC8gYHR3YXBgIGFza2VkIGZvciBhIHRpbWUgb2xkZXIgdGhhbiBldmVyeSBzdXJ2aXZpbmcKb2JzZXJ2YXRpb24gb2YgdGhlIG1hcmtldCAodGhlIHJpbmcga2VlcHMgdGhlIG1vc3QgcmVjZW50IDE2KS4AAAAAAAART2JzZXJ2YXRpb25Ub29PbGQAAAAAAAATAAAAOGBmaW5hbGl6ZV9tYXJrZXRgIGNhbGxlZCBiZWZvcmUgdGhlIHJlZGVtcHRpb24gZGVhZGxpbmUuAAAAFFJlZGVtcHRpb25XaW5kb3dPcGVuAAAAFAAAAD9NYXJrZXQgYWxyZWFkeSBjbG9zZWQgYnkgYGZpbmFsaXplX21hcmtldGA7IHJlZGVtcHRpb24gaXMgb3Zlci4AAAAAEFJlZGVtcHRpb25DbG9zZWQAAAAVAAAAsExlZ2FjeSBtYXJrZXQgY3JlYXRlZCBiZWZvcmUgYFNldHRsZVN0YXRlYCBleGlzdGVkOiBvdXRzdGFuZGluZyBzaGFyZXMKY2Fubm90IGJlIHJlY29uc3RydWN0ZWQgb24tY2hhaW4sIHNvIGl0IGhhcyBubyBkZWFkbGluZSBhbmQgY2Fubm90IGJlCmZpbmFsaXplZCAoc3RpbGwgZnVsbHkgcmVkZWVtYWJsZSkuAAAADE5vdFN1cHBvcnRlZAAAABY=", "AAAABQAAAAAAAAAAAAAABVRyYWRlAAAAAAAAAQAAAAV0cmFkZQAAAAAAAAkAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAZ0cmFkZXIAAAAAABMAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAZpc19idXkAAAAAAAEAAAAAAAAAAAAAAApjb2xsYXRlcmFsAAAAAAALAAAAAAAAAAAAAAAGc2hhcmVzAAAAAAALAAAAAAAAAAAAAAADZmVlAAAAAAsAAAAAAAAAAAAAAAtyZXNlcnZlX3llcwAAAAALAAAAAAAAAAAAAAAKcmVzZXJ2ZV9ubwAAAAAACwAAAAAAAAAC", "AAAAAQAAAAAAAAAAAAAABkNvbmZpZwAAAAAABgAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAApjb2xsYXRlcmFsAAAAAAATAAAAAAAAAA9kZWZhdWx0X2ZlZV9icHMAAAAABAAAAAAAAAAGcGF1c2VkAAAAAAABAAAAAAAAAAhyZXNvbHZlcgAAABMAAAAAAAAACHRyZWFzdXJ5AAAAEw==", "AAAAAQAAAAAAAAAAAAAABk1hcmtldAAAAAAADQAAAAAAAAAIY2F0ZWdvcnkAAAfQAAAACENhdGVnb3J5AAAAPFVuaXggc2Vjb25kcyAobGVkZ2VyIHRpbWVzdGFtcCkuIFRyYWRpbmcgc3RvcHMgYXQgdGhpcyB0aW1lLgAAAApjbG9zZV90aW1lAAAAAAAGAAAAAAAAAAdjcmVhdG9yAAAAABMAAABGU25hcHNob3Qgb2YgdGhlIGZlZSBhdCBjcmVhdGlvbiBzbyBsYXRlciBjb25maWcgY2hhbmdlcyBuZXZlciBtb3ZlIGl0LgAAAAAAB2ZlZV9icHMAAAAABAAAACZUcmFkaW5nIGZlZXMgd2FpdGluZyBmb3IgYHN3ZWVwX2ZlZXNgLgAAAAAADGZlZXNfYWNjcnVlZAAAAAsAAAA4Q3JlYXRvciBoYXMgd2l0aGRyYXduIHRoZSBwb29sIHJlbWFpbmRlciBhZnRlciBmaW5hbGl0eS4AAAAKbHBfY2xhaW1lZAAAAAAAAQAAAChJUEZTIENJRCAvIFVSSSBvZiB0aGUgcXVlc3Rpb24gbWV0YWRhdGEuAAAACG1ldGFfdXJpAAAAEAAAAIZzaGEyNTYgb2YgdGhlIGNhbm9uaWNhbCBxdWVzdGlvbiBKU09OIHN0b3JlZCBvZmYtY2hhaW4gKElQRlMpLCBzbyBhCmNsaWVudCBjYW4gcHJvdmUgdGhlIHRleHQgaXQgc2hvd3MgaXMgdGhlIHRleHQgdGhhdCB3YXMgY29tbWl0dGVkLgAAAAAADXF1ZXN0aW9uX2hhc2gAAAAAAAPuAAAAIAAAAAAAAAAKcmVzZXJ2ZV9ubwAAAAAACwAAAAAAAAALcmVzZXJ2ZV95ZXMAAAAACwAAAS9Db21taXRtZW50IHRvIGhvdyB0aGlzIG1hcmtldCB3aWxsIGJlIHJlc29sdmVkLCBmaXhlZCBhdCBjcmVhdGlvbiBzbwp0aGUgY3JlYXRvciBjYW5ub3QgcGljayBmYXZvcmFibGUgcmVzb2x1dGlvbiBydWxlcyBhZnRlciBzZWVpbmcKdHJhZGluZyBhY3Rpdml0eS4gVGhlIHJlc29sdmVyIGNvbnRyYWN0IHZlcmlmaWVzIGEgcmV2ZWFsZWQgc3BlYwphZ2FpbnN0IHRoaXMgaGFzaCBiZWZvcmUgY29uZmlndXJpbmcgaXRzZWxmIGZvciB0aGUgbWFya2V0LiBTZWUKYGRvY3MvYWRyLzAwMDEtcmVzb2x1dGlvbi1jb21taXRtZW50Lm1kYC4AAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAEBDb21wbGV0ZSBzZXRzIGluIGV4aXN0ZW5jZSA9PSBjb2xsYXRlcmFsIGxvY2tlZCBmb3IgdGhpcyBtYXJrZXQuAAAAC3NldHNfbWludGVkAAAAAAsAAAAAAAAABnN0YXR1cwAAAAAH0AAAAAxNYXJrZXRTdGF0dXM=", - "AAAAAAAAAIFTcGVuZCBgY29sbGF0ZXJhbF9pbmAgdG8gYnV5IGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMgc2hhcmVzIHJlY2VpdmVkLgpSZXZlcnRzIGlmIGZld2VyIHRoYW4gYG1pbl9zaGFyZXNfb3V0YCB3b3VsZCBiZSByZWNlaXZlZC4AAAAAAAADYnV5AAAAAAUAAAAAAAAABnRyYWRlcgAAAAAAEwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA1jb2xsYXRlcmFsX2luAAAAAAAACwAAAAAAAAAObWluX3NoYXJlc19vdXQAAAAAAAsAAAABAAAD6QAAAAsAAAAD", - "AAAAAgAAAAAAAAAAAAAAB0RhdGFLZXkAAAAACQAAAAAAAAAAAAAABUFkbWluAAAAAAAAAAAAAAAAAAAKQ29sbGF0ZXJhbAAAAAAAAAAAAAAAAAAIVHJlYXN1cnkAAAAAAAAAAAAAAAhSZXNvbHZlcgAAAAAAAAAAAAAADURlZmF1bHRGZWVCcHMAAAAAAAAAAAAAAAAAAAZQYXVzZWQAAAAAAAAAAAAAAAAABk5leHRJZAAAAAAAAQAAAAAAAAAGTWFya2V0AAAAAAABAAAABgAAAAEAAAAAAAAACFBvc2l0aW9uAAAAAgAAAAYAAAAT", + "AAAAAgAAAAAAAAAAAAAAB0RhdGFLZXkAAAAADQAAAAAAAAAAAAAABUFkbWluAAAAAAAAAAAAAAAAAAAKQ29sbGF0ZXJhbAAAAAAAAAAAAAAAAAAIVHJlYXN1cnkAAAAAAAAAAAAAAAhSZXNvbHZlcgAAAAAAAAAAAAAADURlZmF1bHRGZWVCcHMAAAAAAAAAAAAAAAAAAAZQYXVzZWQAAAAAAAAAAAAAAAAABk5leHRJZAAAAAAAAAAAAAAAAAAURGVmYXVsdFJlZGVlbUhvcml6b24AAAABAAAAAAAAAAZNYXJrZXQAAAAAAAEAAAAGAAAAAQAAAAAAAAAIUG9zaXRpb24AAAACAAAABgAAABMAAAABAAAAAAAAAAZTZXR0bGUAAAAAAAEAAAAGAAAAAQAAAAAAAAAGT3JhY2xlAAAAAAABAAAABgAAAAEAAAAAAAAAA09icwAAAAACAAAABgAAAAQ=", "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA", - "AAAAAAAAARBSZWNlaXZlIGV4YWN0bHkgYGNvbGxhdGVyYWxfb3V0YCBieSBzZWxsaW5nIGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMKc2hhcmVzIHNvbGQuIFJldmVydHMgaWYgbW9yZSB0aGFuIGBtYXhfc2hhcmVzX2luYCB3b3VsZCBiZSBuZWVkZWQuCkNsaWVudHMgdHVybiAic2VsbCBOIHNoYXJlcyIgaW50byBgY29sbGF0ZXJhbF9vdXRgIG9mZi1jaGFpbgooc2VlIGBtYXRoOjpjYWxjX3NlbGxgIGRvY3MpIGFuZCBhZGQgYSBzbGlwcGFnZSBtYXJnaW4gdG8gYG1heF9zaGFyZXNfaW5gLgAAAARzZWxsAAAABQAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADmNvbGxhdGVyYWxfb3V0AAAAAAALAAAAAAAAAA1tYXhfc2hhcmVzX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAgAAAAAAAAAAAAAACENhdGVnb3J5AAAABgAAAAAAAAAAAAAABkNyeXB0bwAAAAAAAAAAAAAAAAAFTWFjcm8AAAAAAAAAAAAAAAAAAAtHZW9wb2xpdGljcwAAAAAAAAAAAAAAAAZTcG9ydHMAAAAAAAAAAAAAAAAAB0N1bHR1cmUAAAAAAAAAAAAAAAAFT3RoZXIAAAA=", "AAAAAQAAAAAAAAAAAAAACFBvc2l0aW9uAAAAAgAAAAAAAAACbm8AAAAAAAsAAAAAAAAAA3llcwAAAAAL", "AAAABQAAAAAAAAAAAAAACFJlZGVlbWVkAAAAAQAAAAhyZWRlZW1lZAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAZob2xkZXIAAAAAABMAAAABAAAAAAAAAAZwYXlvdXQAAAAAAAsAAAAAAAAAAg==", - "AAAAAAAAAGlQYXlzIG91dCBgaG9sZGVyYCdzIHBvc2l0aW9uIGZvciBhIGZpbmFsIG1hcmtldCBhbmQgY2xlYXJzIGl0LgpMb3NpbmcgcG9zaXRpb25zIGNsZWFyIHdpdGggYSBwYXlvdXQgb2YgMC4AAAAAAAAGcmVkZWVtAAAAAAACAAAAAAAAAAZob2xkZXIAAAAAABMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", "AAAABQAAAAAAAAAAAAAACUZlZXNTd2VwdAAAAAAAAAEAAAAKZmVlc19zd2VwdAAAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAC", - "AAAAAAAAAAAAAAAHcmVzb2x2ZQAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAABAAAD6QAAAAIAAAAD", + "AAAAAQAAAEZPbmUgcG9pbnQgaW4gdGhlIFRXQVAgcmluZyBidWZmZXIuIEF0IG1vc3Qgb25lIHBlciBkaXN0aW5jdCB0aW1lc3RhbXAuAAAAAAAAAAAAC09ic2VydmF0aW9uAAAAAAIAAAArYE9yYWNsZVN0YXRlOjpwcmljZV9jdW11bGF0aXZlYCBhcyBvZiBgdHNgLgAAAAAKY3VtdWxhdGl2ZQAAAAAACwAAAAAAAAACdHMAAAAAAAY=", + "AAAAAQAAA8FQZXItbWFya2V0IFRXQVAgYWNjdW11bGF0b3IgKGlzc3VlICMxMSkuIExpdmVzIGluIGl0cyBvd24gZW50cnkgc28gdGhlCmBNYXJrZXRgIFhEUiBzdGF5cyB1bmNoYW5nZWQuCgpTZW1hbnRpY3M6IGF0IHRoZSBzdGFydCBvZiBldmVyeSB0cmFkZSB0aGUgcHJpY2UgdGhhdCBoZWxkIHNpbmNlIHRoZSBsYXN0CnRyYWRlIGlzIGZvbGRlZCBpbnRvIGBwcmljZV9jdW11bGF0aXZlYCBvdmVyIHRoZSBlbGFwc2VkIGludGVydmFsOyB0aGUKcG9zdC10cmFkZSBwcmljZSB0aGVuIGJlY29tZXMgYGxhc3RfcHJpY2VfYnBzYCB1bnRpbCB0aGUgbmV4dCB0cmFkZS4gVGhhdAppcyB0aGUgVW5pc3dhcC12MiBtb2RlbCwgYW5kIGl0IGlzIHdoYXQgbWFrZXMgbWFuaXB1bGF0aW9uIGV4cGVuc2l2ZSDigJQKbW92aW5nIHRoZSBhdmVyYWdlIHJlcXVpcmVzIGhvbGRpbmcgdGhlIHByaWNlIGF3YXkgZnJvbSBmYWlyIHZhbHVlIGFjcm9zcwpvbmUgb3IgbW9yZSBsZWRnZXIgYm91bmRhcmllcywgbm90IGp1c3Qgd2l0aGluIGEgc2luZ2xlIHRyYW5zYWN0aW9uLgoKT3ZlcmZsb3cgYW5hbHlzaXM6IGBsYXN0X3ByaWNlX2JwcyA8PSAxMF8wMDBgIGFuZCBlYWNoIGludGVydmFsIGFkZHMKYHByaWNlICogZHRgIHNlY29uZHMuIEEgMTAwLXllYXIgbWFya2V0IGxpZmV0aW1lIGFjY3VtdWxhdGVzIGF0IG1vc3QKYDEwXzAwMCAqIDMuMmU5IH4gMy4yZTEzYDsgZXZlbiBhIHBhdGhvbG9naWNhbCBgZHQgPSB1NjQ6Ok1BWGAgZ2l2ZXMKYDEuOGUxOSAqIDFlNCA9IDEuOGUyM2AuIGBpMTI4OjpNQVhgIGlzIH4xLjdlMzgsIHNvIHRoZXJlIGlzIG5vIGZlYXNpYmxlCmxpZmV0aW1lIHRoYXQgb3ZlcmZsb3dzIOKAlCBhbmQgZXZlcnkgc3RlcCBpcyBgY2hlY2tlZF8qYCBhbnl3YXksIHNvIGEKdmlvbGF0aW9uIHJlamVjdHMgdGhlIHRyYW5zYWN0aW9uIGluc3RlYWQgb2YgY29ycnVwdGluZyB0aGUgYWNjdW11bGF0b3IuAAAAAAAAAAAAAAtPcmFjbGVTdGF0ZQAAAAAFAAAALVlFUyBwcmljZSBpbiBicHMgYXMgb2YgdGhlIG1vc3QgcmVjZW50IHRyYWRlLgAAAAAAAA5sYXN0X3ByaWNlX2JwcwAAAAAABAAAAIZUaW1lc3RhbXAgdGhlIGFjY3VtdWxhdG9yIHdhcyBsYXN0IGFkdmFuY2VkIHRvICh0aGUgdGltZXN0YW1wIG9mIHRoZQptb3N0IHJlY2VudCB0cmFkZSkuIFRoZSBsYXN0IHRyYWRlZCBwcmljZSBob2xkcyBmcm9tIGhlcmUgb253YXJkLgAAAAAAB2xhc3RfdHMAAAAABgAAADVPYnNlcnZhdGlvbnMgd3JpdHRlbiBzbyBmYXIsIGNhcHBlZCBhdCBgT1JBQ0xFX1JJTkdgLgAAAAAAAAlvYnNfY291bnQAAAAAAAAEAAAAOVJpbmcgc2xvdCBvZiB0aGUgbmV3ZXN0IG9ic2VydmF0aW9uLCBpbiBgMC4uT1JBQ0xFX1JJTkdgLgAAAAAAAAlvYnNfaW5kZXgAAAAAAAAEAAAAQVJ1bm5pbmcgc3VtIG9mIGBsYXN0X3ByaWNlX2JwcyAqIHNlY29uZHNfZWxhcHNlZGAgc2luY2UgY3JlYXRpb24uAAAAAAAAEHByaWNlX2N1bXVsYXRpdmUAAAAL", + "AAAAAQAAAGpQZXItbWFya2V0IHNldHRsZW1lbnQgYm9va2tlZXBpbmcgKGlzc3VlICMxNCkuIExpdmVzIGluIGl0cyBvd24gZW50cnkgc28KdGhlIGBNYXJrZXRgIFhEUiBzdGF5cyB1bmNoYW5nZWQuAAAAAAAAAAAAC1NldHRsZVN0YXRlAAAAAAYAAABqYGZpbmFsaXplX21hcmtldGAgaGFzIHN3ZXB0IHRoZSBtYXJrZXQncyByZW1haW5pbmcgYmFsYW5jZTsgbm8KcmVkZW1wdGlvbiBvciBjbGFpbSBjYW4gZXZlciBwYXkgb3V0IGFnYWluLgAAAAAABmNsb3NlZAAAAAAAAQAAAENUaW1lc3RhbXAgb2YgYHJlc29sdmVgIC8gYHZvaWRfbWFya2V0YDsgMCB3aGlsZSB0aGUgbWFya2V0IGlzIE9wZW4uAAAAAAxmaW5hbGl6ZWRfYXQAAAAGAAAAc1JlZGVtcHRpb24gd2luZG93LCBzbmFwc2hvdHRlZCBhdCBjcmVhdGlvbiBmcm9tIHRoZSBhZG1pbiBkZWZhdWx0LgpgcmVkZWVtX2RlYWRsaW5lID0gZmluYWxpemVkX2F0ICsgaG9yaXpvbl9zZWNzYC4AAAAADGhvcml6b25fc2VjcwAAAAYAAAAAAAAADm91dHN0YW5kaW5nX25vAAAAAAALAAAAw091dGNvbWUgc2hhcmVzIGhlbGQgb3V0c2lkZSB0aGUgcG9vbCAodXNlciBwb3NpdGlvbnMpLCBzdGlsbApyZWRlZW1hYmxlLiBgcmVkZWVtYCBkZWNyZW1lbnRzIHRoZSByZWRlZW1pbmcgaG9sZGVyJ3Mgc2hhcmVzIG9uIGJvdGgKc2lkZXMsIHNvIGEgbG9zaW5nLXNpZGUgcmVkZW1wdGlvbiAocGF5b3V0IDApIHN0aWxsIGNsZWFycyBoZXJlLgAAAAAPb3V0c3RhbmRpbmdfeWVzAAAAAAsAAAB7Q29sbGF0ZXJhbCBhbHJlYWR5IHBhaWQgdG8gaG9sZGVycyBhbmQgdGhlIExQIChgcmVkZWVtYCArCmBjbGFpbV9wb29sX3JlbWFpbmRlcmAgcGF5b3V0cyk7IHRyYWNrcyB3aGF0IGhhcyBsZWZ0IHRoZSBtYXJrZXQuAAAAAAhwYWlkX291dAAAAAs=", "AAAAAgAAAAAAAAAAAAAADE1hcmtldFN0YXR1cwAAAAMAAAAAAAAAQlRyYWRpbmcgYWxsb3dlZCB1bnRpbCBgY2xvc2VfdGltZWAsIHRoZW4gd2FpdGluZyBmb3IgdGhlIHJlc29sdmVyLgAAAAAABE9wZW4AAAABAAAAIUZpbmFsLiBXaW5uaW5nIHNoYXJlcyByZWRlZW0gMToxLgAAAAAAAAhSZXNvbHZlZAAAAAEAAAfQAAAAB091dGNvbWUAAAAAAAAAACtJbnZhbGlkIG1hcmtldC4gRXZlcnkgc2hhcmUgcmVkZWVtcyBhdCAwLjUuAAAAAARWb2lk", + "AAAABQAAAAAAAAAAAAAADU1hcmtldENyZWF0ZWQAAAAAAAABAAAADm1hcmtldF9jcmVhdGVkAAAAAAAGAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAAAAAAAIY2F0ZWdvcnkAAAfQAAAACENhdGVnb3J5AAAAAAAAAAAAAAAKY2xvc2VfdGltZQAAAAAABgAAAAAAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAAAAAAA9pbml0aWFsX3llc19icHMAAAAABAAAAAAAAAAC", + "AAAABQAAAAAAAAAAAAAADk1hcmtldFJlc29sdmVkAAAAAAABAAAAD21hcmtldF9yZXNvbHZlZAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAACFOb25lIG1lYW5zIHRoZSBtYXJrZXQgd2FzIHZvaWRlZC4AAAAAAAAHb3V0Y29tZQAAAAPoAAAH0AAAAAdPdXRjb21lAAAAAAAAAAAC", + "AAAABQAAAAAAAAAAAAAAD01hcmtldEZpbmFsaXplZAAAAAABAAAAEG1hcmtldF9maW5hbGl6ZWQAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAHFFdmVyeXRoaW5nIHN3ZXB0IHRvIHRoZSB0cmVhc3VyeTogZXhwaXJlZCB1c2VyIGNsYWltcywgYW4gdW5jbGFpbWVkCkxQIHJlbWFpbmRlciwgcm91bmRpbmcgZHVzdCBhbmQgYWNjcnVlZCBmZWVzLgAAAAAAAAVzd2VwdAAAAAAAAAsAAAAAAAAAQFRoZSBwYXJ0IG9mIGBzd2VwdGAgdGhhdCB3YXMgcmVzZXJ2ZWQgZm9yIHNoYXJlcyBuZXZlciByZWRlZW1lZC4AAAAOdW5jbGFpbWVkX3VzZXIAAAAAAAsAAAAAAAAAQ1JvdW5kaW5nIGxlZnQgb3ZlciBhZnRlciBwZXItaG9sZGVyIHZvaWQgZmxvb3JzIHdlcmUgYWNjb3VudGVkIGZvci4AAAAABGR1c3QAAAALAAAAAAAAAAI=", + "AAAAAAAAAIFTcGVuZCBgY29sbGF0ZXJhbF9pbmAgdG8gYnV5IGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMgc2hhcmVzIHJlY2VpdmVkLgpSZXZlcnRzIGlmIGZld2VyIHRoYW4gYG1pbl9zaGFyZXNfb3V0YCB3b3VsZCBiZSByZWNlaXZlZC4AAAAAAAADYnV5AAAAAAUAAAAAAAAABnRyYWRlcgAAAAAAEwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA1jb2xsYXRlcmFsX2luAAAAAAAACwAAAAAAAAAObWluX3NoYXJlc19vdXQAAAAAAAsAAAABAAAD6QAAAAsAAAAD", + "AAAAAAAAARBSZWNlaXZlIGV4YWN0bHkgYGNvbGxhdGVyYWxfb3V0YCBieSBzZWxsaW5nIGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMKc2hhcmVzIHNvbGQuIFJldmVydHMgaWYgbW9yZSB0aGFuIGBtYXhfc2hhcmVzX2luYCB3b3VsZCBiZSBuZWVkZWQuCkNsaWVudHMgdHVybiAic2VsbCBOIHNoYXJlcyIgaW50byBgY29sbGF0ZXJhbF9vdXRgIG9mZi1jaGFpbgooc2VlIGBtYXRoOjpjYWxjX3NlbGxgIGRvY3MpIGFuZCBhZGQgYSBzbGlwcGFnZSBtYXJnaW4gdG8gYG1heF9zaGFyZXNfaW5gLgAAAARzZWxsAAAABQAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADmNvbGxhdGVyYWxfb3V0AAAAAAALAAAAAAAAAA1tYXhfc2hhcmVzX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", + "AAAAAAAAAUxUaW1lLXdlaWdodGVkIGF2ZXJhZ2UgWUVTIHByaWNlIGluIGJwcyBvdmVyIHRoZSB0cmFpbGluZwpgd2luZG93X3NlY3NgLCBmbG9vcmVkLiBSZWplY3RzIGEgemVybyB3aW5kb3cgKGBJbnZhbGlkQW1vdW50YCkuCkEgd2luZG93IHJlYWNoaW5nIGJlZm9yZSB0aGUgb2xkZXN0IG9ic2VydmF0aW9uIGZhaWxzIHdpdGgKYE9ic2VydmF0aW9uVG9vT2xkYDsgYSB3aW5kb3cgZXh0ZW5kaW5nIHBhc3QgYGNsb3NlX3RpbWVgIGtlZXBzCmF2ZXJhZ2luZyB0aGUgZmluYWwgcHJpY2UsIGJlY2F1c2UgdGhlIGFjY3VtdWxhdG9yIG9ubHkgbW92ZXMgd2hlbgp0cmFkZXMgYXJlIHBvc3NpYmxlLgAAAAR0d2FwAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAALd2luZG93X3NlY3MAAAAABgAAAAEAAAPpAAAABAAAAAM=", + "AAAAAAAAAGlQYXlzIG91dCBgaG9sZGVyYCdzIHBvc2l0aW9uIGZvciBhIGZpbmFsIG1hcmtldCBhbmQgY2xlYXJzIGl0LgpMb3NpbmcgcG9zaXRpb25zIGNsZWFyIHdpdGggYSBwYXlvdXQgb2YgMC4AAAAAAAAGcmVkZWVtAAAAAAACAAAAAAAAAAZob2xkZXIAAAAAABMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", + "AAAAAAAAAiRDdW11bGF0aXZlIFlFUy1wcmljZS1zZWNvbmRzIGFzIG9mIGBub3cgLSBzZWNvbmRzX2Fnb2AuCgpUaGUgYWNjdW11bGF0b3IgaXMgcGllY2V3aXNlIGxpbmVhciBpbiB0aW1lICh0aGUgcHJpY2Ugb25seSBtb3ZlcyB3aGVuCmEgdHJhZGUgbGFuZHMpLCBzbyBiZXR3ZWVuIG9ic2VydmF0aW9ucyB0aGUgdmFsdWUgaXMgaW50ZXJwb2xhdGVkCmFsb25nIHRoZSBjaG9yZCwgYW5kIHRpbWVzIGFmdGVyIHRoZSBsYXN0IHRyYWRlIGV4dHJhcG9sYXRlIGF0IHRoZQpsYXN0IHRyYWRlZCBwcmljZS4gRmFpbHMgd2l0aCBgT2JzZXJ2YXRpb25Ub29PbGRgIHdoZW4gYHNlY29uZHNfYWdvYApyZWFjaGVzIGJlZm9yZSB0aGUgb2xkZXN0IHN1cnZpdmluZyBvYnNlcnZhdGlvbiAodGhlIHJpbmcga2VlcHMgdGhlCm1vc3QgcmVjZW50IDE2KSwgYW5kIHdpdGggYE5vdFN1cHBvcnRlZGAgZm9yIG1hcmtldHMgY3JlYXRlZCBiZWZvcmUKdGhlIGFjY3VtdWxhdG9yIGV4aXN0ZWQuIEEgbWlzc2luZyAoYXJjaGl2ZWQpIHJpbmcgc2xvdCBpcyBza2lwcGVkLApuZXZlciBhIHBhbmljLgAAAAdvYnNlcnZlAAAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAAC3NlY29uZHNfYWdvAAAAAAYAAAABAAAD6QAAAAsAAAAD", + "AAAAAAAAAAAAAAAHcmVzb2x2ZQAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAD9TcG90IHByaWNlIG9mIGBvdXRjb21lYCBpbiBicHMgKDAuLj0xMF8wMDApLiBZRVMgKyBOTyB+IDEwXzAwMC4AAAAACWdldF9wcmljZQAAAAAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAEAAAPpAAAABAAAAAM=", "AAAAAAAAADlTaGFyZXMgYSBgYnV5YCBvZiBgY29sbGF0ZXJhbF9pbmAgd291bGQgcmV0dXJuIHJpZ2h0IG5vdy4AAAAAAAAJcXVvdGVfYnV5AAAAAAAAAwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA1jb2xsYXRlcmFsX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAAAAAAAKZ2V0X2NvbmZpZwAAAAAAAAAAAAEAAAPpAAAH0AAAAAZDb25maWcAAAAAAAM=", @@ -285,24 +481,29 @@ export class Client extends ContractClient { "AAAAAAAAAEVTaGFyZXMgYSBgc2VsbGAgZm9yIGV4YWN0bHkgYGNvbGxhdGVyYWxfb3V0YCB3b3VsZCBjb25zdW1lIHJpZ2h0IG5vdy4AAAAAAAAKcXVvdGVfc2VsbAAAAAAAAwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA5jb2xsYXRlcmFsX291dAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAAAAAAAKc2V0X3BhdXNlZAAAAAAAAQAAAAAAAAAGcGF1c2VkAAAAAAABAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAC1BbnlvbmUgY2FuIG1vdmUgYWNjcnVlZCBmZWVzIHRvIHRoZSB0cmVhc3VyeS4AAAAAAAAKc3dlZXBfZmVlcwAAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAACwAAAAM=", - "AAAABQAAAAAAAAAAAAAADU1hcmtldENyZWF0ZWQAAAAAAAABAAAADm1hcmtldF9jcmVhdGVkAAAAAAAGAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAAAAAAAIY2F0ZWdvcnkAAAfQAAAACENhdGVnb3J5AAAAAAAAAAAAAAAKY2xvc2VfdGltZQAAAAAABgAAAAAAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAAAAAAA9pbml0aWFsX3llc19icHMAAAAABAAAAAAAAAAC", "AAAAAAAAAEFJbnZhbGlkIG9yIHVucmVzb2x2YWJsZSBtYXJrZXQuIEFsbG93ZWQgYW55IHRpbWUgYmVmb3JlIGZpbmFsaXR5LgAAAAAAAAt2b2lkX21hcmtldAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==", - "AAAABQAAAAAAAAAAAAAADk1hcmtldFJlc29sdmVkAAAAAAABAAAAD21hcmtldF9yZXNvbHZlZAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAACFOb25lIG1lYW5zIHRoZSBtYXJrZXQgd2FzIHZvaWRlZC4AAAAAAAAHb3V0Y29tZQAAAAPoAAAH0AAAAAdPdXRjb21lAAAAAAAAAAAC", "AAAAAAAAAAAAAAAMZ2V0X3Bvc2l0aW9uAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAGaG9sZGVyAAAAAAATAAAAAQAAB9AAAAAIUG9zaXRpb24=", "AAAAAAAAAAAAAAAMbWFya2V0X2NvdW50AAAAAAAAAAEAAAAG", "AAAAAAAAAAAAAAAMc2V0X3Jlc29sdmVyAAAAAQAAAAAAAAAIcmVzb2x2ZXIAAAATAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAAAAAAAMc2V0X3RyZWFzdXJ5AAAAAQAAAAAAAAAIdHJlYXN1cnkAAAATAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAHpPdXRzdGFuZGluZy1zaGFyZSBhY2NvdW50aW5nIGZvciBhIG1hcmtldCAoaXNzdWUgIzE0KS4KYE5vdFN1cHBvcnRlZGAgZm9yIG1hcmtldHMgY3JlYXRlZCBiZWZvcmUgdGhpcyBib29ra2VlcGluZyBleGlzdGVkLgAAAAAADHNldHRsZV9zdGF0ZQAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAALU2V0dGxlU3RhdGUAAAAAAw==", "AAAAAAAAAEdSdW5zIGF0b21pY2FsbHkgYXQgZGVwbG95IHRpbWUsIHNvIG5vYm9keSBjYW4gZnJvbnQtcnVuIGluaXRpYWxpemF0aW9uLgAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAUAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAKY29sbGF0ZXJhbAAAAAAAEwAAAAAAAAAIdHJlYXN1cnkAAAATAAAAAAAAAAhyZXNvbHZlcgAAABMAAAAAAAAAD2RlZmF1bHRfZmVlX2JwcwAAAAAEAAAAAA==", "AAAAAAAAAMhDcmVhdGVzIGEgbWFya2V0IGFuZCBzZWVkcyB0aGUgcG9vbC4gYGNyZWF0b3JgIGJlY29tZXMgdGhlIHNvbGUgTFAuCmBpbml0aWFsX3llc19icHNgIGluIFsxMDAsIDk5MDBdIHNldHMgdGhlIG9wZW5pbmcgWUVTIHByb2JhYmlsaXR5Owp0aGUgY3JlYXRvciByZWNlaXZlcyB0aGUgbGVmdG92ZXIgc2hhcmVzIG9mIHRoZSBsaWtlbGllciBvdXRjb21lLgAAAA1jcmVhdGVfbWFya2V0AAAAAAAACAAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAA1xdWVzdGlvbl9oYXNoAAAAAAAD7gAAACAAAAAAAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAAAAAAAIbWV0YV91cmkAAAAQAAAAAAAAAAhjYXRlZ29yeQAAB9AAAAAIQ2F0ZWdvcnkAAAAAAAAACmNsb3NlX3RpbWUAAAAAAAYAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAD2luaXRpYWxfeWVzX2JwcwAAAAAEAAAAAQAAA+kAAAAGAAAAAw==", + "AAAAAAAAA3FQZXJtaXNzaW9ubGVzcyBjbG9zZS1vdXQgb2YgYSBmaW5hbCBtYXJrZXQgYWZ0ZXIgaXRzIHJlZGVtcHRpb24KZGVhZGxpbmUuIFN3ZWVwcyB0aGUgbWFya2V0J3MgZW50aXJlIHJlbWFpbmluZyBiYWxhbmNlIOKAlCB2YWx1ZQpyZXNlcnZlZCBmb3Igc2hhcmVzIG5ldmVyIHJlZGVlbWVkLCBhbiB1bmNsYWltZWQgTFAgcmVtYWluZGVyLApwZXItaG9sZGVyIHJvdW5kaW5nIGR1c3QgYW5kIGFjY3J1ZWQgZmVlcyDigJQgdG8gdGhlIHRyZWFzdXJ5LCBhbmQKbWFya3MgdGhlIG1hcmtldCBjbG9zZWQgc28gbm90aGluZyBjYW4gZXZlciBwYXkgb3V0IGFnYWluLgoKVGhlIGRlYWRsaW5lIGlzIGByZWRlZW1fZGVhZGxpbmUobWFya2V0X2lkKWAgPSBgZmluYWxpemVkX2F0ICsKaG9yaXpvbl9zZWNzYCwgdGhlIGhvcml6b24gYmVpbmcgc25hcHNob3R0ZWQgYXQgY3JlYXRpb24gZnJvbSB0aGUKYWRtaW4gZGVmYXVsdC4gQW55b25lIHdobyByZWRlZW1zIGJlZm9yZSB0aGUgZGVhZGxpbmUgaXMgcGFpZCB0aGUKZXhhY3QgYHNldHRsZV92YWx1ZWAgdGhleSB3b3VsZCBoYXZlIHJlY2VpdmVkIHdpdGhvdXQgdGhpcyBjYWxsOwpvbmx5IHNoYXJlcyBzdGlsbCB1bnJlZGVlbWVkIGF0IHRoZSBkZWFkbGluZSBhcmUgZm9yZmVpdCwgYW5kIHRoZWlyCnZhbHVlIGdvZXMgdG8gdGhlIHRyZWFzdXJ5IHJhdGhlciB0aGFuIHRoZSBMUCBzbyB0aGUgTFAgY2Fubm90CnByb2ZpdCBmcm9tIHN1cHByZXNzaW5nIHJlZGVtcHRpb25zLgoKUmV0dXJucyB0aGUgYW1vdW50IHN3ZXB0LiBUaGUgc3dlcHQgdG90YWwgaXMgZXhhY3RseSB0aGUgbWFya2V0J3MKY29udHJhY3QgYmFsYW5jZTogc2V0cyBwbHVzIGZlZXMgbWludXMgZXZlcnl0aGluZyBhbHJlYWR5IHBhaWQgb3V0LgAAAAAAAA9maW5hbGl6ZV9tYXJrZXQAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAACwAAAAM=", + "AAAAAAAAAOBUaW1lc3RhbXAgYWZ0ZXIgd2hpY2ggdW5yZWRlZW1lZCBzaGFyZXMgYXJlIGZvcmZlaXQgKHNlZQpgZmluYWxpemVfbWFya2V0YCkuIGBOb25lYCB3aGlsZSB0aGUgbWFya2V0IGlzIHN0aWxsIE9wZW4g4oCUIGl0cwpkZWFkbGluZSBpcyBub3Qga25vd24geWV0IOKAlCBhbmQgZm9yIGxlZ2FjeSBtYXJrZXRzLCB3aGljaCBoYXZlIG5vCmRlYWRsaW5lIGFuZCBjYW5ub3QgYmUgZmluYWxpemVkLgAAAA9yZWRlZW1fZGVhZGxpbmUAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPoAAAABg==", + "AAAAAAAAAL1DdW11bGF0aXZlIGBwcmljZV9icHMgKiBzZWNvbmRzYCBmb3IgWUVTIHNpbmNlIGNyZWF0aW9uLCBleHRyYXBvbGF0ZWQKdG8gbm93OiBgb2JzZXJ2ZShtYXJrZXRfaWQsIDApYC4gUmVhZHktbWFkZSBmb3IgY29uc3VtZXJzIHRoYXQgc3RvcmUKdGhlaXIgb3duIGNoZWNrcG9pbnRzIGFuZCBjb21wdXRlIFRXQVBzIG9mZi1jaGFpbi4AAAAAAAAQcHJpY2VfY3VtdWxhdGl2ZQAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", "AAAAAAAAAEhBZmZlY3RzIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbDsgZXhpc3RpbmcgbWFya2V0cyBrZWVwIHRoZWlyIGZlZS4AAAATc2V0X2RlZmF1bHRfZmVlX2JwcwAAAAABAAAAAAAAAAdmZWVfYnBzAAAAAAQAAAABAAAD6QAAAAIAAAAD", - "AAAAAAAAAERDcmVhdG9yICh0aGUgTFApIHdpdGhkcmF3cyB3aGF0ZXZlciB0aGUgcG9vbCBzdGlsbCBob2xkcyBvbmNlIGZpbmFsLgAAABRjbGFpbV9wb29sX3JlbWFpbmRlcgAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD" ]), + "AAAAAAAAAERDcmVhdG9yICh0aGUgTFApIHdpdGhkcmF3cyB3aGF0ZXZlciB0aGUgcG9vbCBzdGlsbCBob2xkcyBvbmNlIGZpbmFsLgAAABRjbGFpbV9wb29sX3JlbWFpbmRlcgAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", + "AAAAAAAAAI5BZmZlY3RzIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbDsgZXhpc3RpbmcgbWFya2V0cyBrZWVwIHRoZQpob3Jpem9uIHNuYXBzaG90dGVkIGF0IHRoZWlyIGNyZWF0aW9uLiBNdXN0IGJlIHdpdGhpbgpbMTgwIGRheXMsIDE4MjUgZGF5c10uAAAAAAAac2V0X2RlZmF1bHRfcmVkZWVtX2hvcml6b24AAAAAAAEAAAAAAAAABHNlY3MAAAAGAAAAAQAAA+kAAAACAAAAAw==" ]), options ) } public readonly fromJSON = { buy: this.txFromJSON>, sell: this.txFromJSON>, + twap: this.txFromJSON>, redeem: this.txFromJSON>, + observe: this.txFromJSON>, resolve: this.txFromJSON>, get_price: this.txFromJSON>, quote_buy: this.txFromJSON>, @@ -316,8 +517,13 @@ export class Client extends ContractClient { market_count: this.txFromJSON, set_resolver: this.txFromJSON>, set_treasury: this.txFromJSON>, + settle_state: this.txFromJSON>, create_market: this.txFromJSON>, + finalize_market: this.txFromJSON>, + redeem_deadline: this.txFromJSON>, + price_cumulative: this.txFromJSON>, set_default_fee_bps: this.txFromJSON>, - claim_pool_remainder: this.txFromJSON> + claim_pool_remainder: this.txFromJSON>, + set_default_redeem_horizon: this.txFromJSON> } } \ No newline at end of file From 99da493b4f0238087bfd2b35cc044fa521dd30b6 Mon Sep 17 00:00:00 2001 From: Teescom Date: Fri, 16 Oct 2026 17:09:52 +0100 Subject: [PATCH 4/8] feat: batched redemption and LP claims with a measured per-transaction cap (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redeem_many / claim_pool_remainders settle up to MAX_BATCH (46) markets in one call, paying the total in a single token transfer. Validation rejects empty, over-cap and non-ascending id lists; markets that cannot pay out right now come back as per-item skips instead of aborting the batch, and the per-market Redeemed / PoolRemainderClaimed events let the frontend reconcile receipts without an indexer. The cap is backed by a committed measurement table (see the MAX_BATCH docstring; regenerate with `cargo test -p market-core measure_redeem_many_resources -- --ignored --nocapture`): the direct call measures 145 reads / 95 writes / 240 footprint at N=46, and the treasury wrappers add exactly two read entries (147 / 95 / 242). 46 keeps the worst-case disk-read interpretation at 147/200 = 73.5% of the mainnet limit, under the 75% headroom line; N=47 would sit at exactly 75%. Deviation from the issue sketch: `BatchItem.skipped: Option` does not compile as a `#[contracttype]` field — under the `testutils` feature the derive needs `From for ScVal`, which is unwritable from this crate (E0119 against the derive's own TryFrom via core's blanket impl; the orphan rule blocks impls on Option, E0117). The equivalent `BatchStatus { Settled, Skipped(SkipReason) }` union is used instead and documented on the type. Error codes continue at 23-27, reserving 19-22 for the parallel fees/breakers PR #153. Treasury: agent-gated redeem_many / claim_pool_remainders forwarders that act for the treasury's own positions, with cap-sized and wrong-caller tests. Bindings regenerated; market-core wasm re-vendored into resolver/treasury; frontend docs gain the chunking, simulate-before-sign and archived-entry-restore rules. --- .../contracts/market-core/src/lib.rs | 791 +++++++++++++++++- .../contracts/resolver/market_core.wasm | Bin 26137 -> 47533 bytes .../contracts/treasury/market_core.wasm | Bin 26137 -> 47533 bytes .../contracts/treasury/src/lib.rs | 225 ++++- .../docs/frontend-integration.md | 63 ++ .../bindings/market-core/src/index.ts | 104 ++- .../packages/bindings/treasury/src/index.ts | 84 +- 7 files changed, 1232 insertions(+), 35 deletions(-) diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 18081be..d80a566 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -6,7 +6,7 @@ pub mod ttl; use math::{MathError, Settlement, BPS, MAX_SETS}; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, panic_with_error, token, - Address, BytesN, Env, String, + Address, BytesN, Env, String, Vec, }; // --------------------------------------------------------------------------- @@ -33,6 +33,51 @@ const MAX_REDEEM_HORIZON_SECS: u64 = 1_825 * ttl::DAY_SECS; /// `ObservationTooOld` instead of a wrong answer. const ORACLE_RING: u32 = 16; +/// Markets one `redeem_many` / `claim_pool_remainders` call may settle. +/// +/// Measured with `measure_redeem_many_resources` (see the tests; run it +/// explicitly with `--ignored --nocapture` to regenerate) and checked +/// against the network per-transaction limits below, keeping every figure +/// under 75% of its limit for headroom. Each market adds 3 read entries +/// (Market, Position, Settle) and 2 write entries (Position removed, Settle +/// updated); the aggregated transfer and the instance entries are shared +/// across the batch. +/// +/// LIMITS (mainnet snapshot 2026-07-10, enforced by the test env — see +/// `InvocationResourceLimits::mainnet()` in the SDK): +/// instructions 400,000,000 | memory 41,943,040 B | disk read entries 200 +/// | write entries 200 | ledger entries 400 (footprint total) | read bytes +/// 200,000 | write bytes 132,096 | contract events 16,384 B +/// MEASURED, batch of N (direct market-core call, one holder, every +/// position redeemable; live entries, so `footprint` = reads + writes): +/// N | insns | mem B | reads | writes | footprint | write B | events B +/// 1 | 409,515 | 55,861 | 10 | 5 | 15 | 840 | 404 +/// 5 |1,258,619 | 163,347 | 22 | 13 | 35 | 2,120 | 1,076 +/// 10 |2,586,602 | 355,282 | 37 | 23 | 60 | 3,720 | 1,916 +/// 20 |6,133,201 | 931,077 | 67 | 43 | 110 | 6,920 | 3,596 +/// 30 |10,687,968|1,762,772 | 97 | 63 | 160 | 10,120 | 5,276 +/// 40 |16,308,578|2,850,367 | 127 | 83 | 210 | 13,320 | 6,956 +/// 45 |19,448,071|3,490,127 | 142 | 93 | 235 | 14,920 | 7,796 +/// 46 |20,093,044|3,625,756 | 145 | 95 | 240 | 15,240 | 7,964 +/// Rows are the direct market-core call; the treasury wrappers add a +/// constant two read entries (their own config and instance entries) and no +/// extra writes — measured at this cap by +/// `measure_treasury_redeem_many_resources` in `contracts/treasury`: +/// N=46 treasury path = 147 reads, 95 writes, 242 footprint, 33.1M insns, +/// 15,240 write B, 7,964 event B. +/// At N=46 the live-state binding axis (footprint, 5N+10) sits at 240/400 = +/// 60% of the ledger-entry limit; the other entry axes are write entries +/// 95/200 = 47.5% and events 7,964/16,384 = 49%, while instructions (5-8%), +/// memory (9-13%) and write bytes (12%) stay far below. +/// Worst case, if the touched entries had to be restored from disk (archived +/// state), the 3N+7 reads would hit the 200-entry disk-read limit at +/// 147/200 = 73.5% on the treasury path (145/200 = 72.5% direct) — under +/// the 75% headroom line with room left for the transaction overhead this +/// harness cannot see (auth entries, source account). N=47 would measure +/// exactly 150/200 = 75.0% with zero margin, so 46 is the largest +/// defensible cap. +const MAX_BATCH: u32 = 46; + // --------------------------------------------------------------------------- // Types shared with the frontend/backend // --------------------------------------------------------------------------- @@ -171,6 +216,60 @@ pub struct Observation { pub cumulative: i128, } +/// Why a market in a batch call did not settle. `Duplicate` is reserved: +/// ids are validated strictly ascending first, so a duplicate is rejected +/// as `InvalidAmount` before the batch runs and never reaches a skip. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SkipReason { + NotFound, + NotFinal, + NothingToRedeem, + AlreadyClaimed, + NotCreator, + Duplicate, +} + +/// Per-market result of a batch settlement. +/// +/// This union stands in for the `Option` sketched in the issue: +/// a `#[contracttype]` struct field of type `Option` does not +/// compile under the `testutils` feature (on for every `cargo test` build). +/// The derive emits an xdr conversion gated on `CustomType: Into`, +/// but `#[contracttype]` enums only derive `TryFrom`, and the missing impls +/// cannot be supplied from this crate: `From for ScVal` collides with +/// the derive's own `TryFrom` through core's blanket impl (E0119), and +/// `TryFrom<&Option> for ScVal` violates the orphan rule (E0117). +/// The union carries the same information and surfaces as a discriminated +/// union in the generated TS bindings. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BatchStatus { + /// The market settled and paid `BatchItem::payout`. + Settled, + /// The market was skipped; `BatchItem::payout` is 0. + Skipped(SkipReason), +} + +/// One market's outcome inside a `BatchResult`, in request order. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchItem { + pub market_id: u64, + pub payout: i128, + pub status: BatchStatus, +} + +/// Result of `redeem_many` / `claim_pool_remainders`: `total` is the single +/// aggregated transfer, `items` has one entry per requested market in the +/// same order. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchResult { + pub total: i128, + pub items: Vec, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Config { @@ -231,17 +330,21 @@ pub enum Error { NothingToRedeem = 16, AlreadyClaimed = 17, Overflow = 18, + // 19-22 are allocated on a sibling wave branch (fee split, breakers, + // trade bounds); this wave numbers from 23 so both merge cleanly. /// `observe` / `twap` asked for a time older than every surviving /// observation of the market (the ring keeps the most recent 16). - ObservationTooOld = 19, + ObservationTooOld = 23, /// `finalize_market` called before the redemption deadline. - RedemptionWindowOpen = 20, + RedemptionWindowOpen = 24, /// Market already closed by `finalize_market`; redemption is over. - RedemptionClosed = 21, + RedemptionClosed = 25, /// Legacy market created before `SettleState` existed: outstanding shares /// cannot be reconstructed on-chain, so it has no deadline and cannot be /// finalized (still fully redeemable). - NotSupported = 22, + NotSupported = 26, + /// A batch call carried no ids or more than `MAX_BATCH` of them. + BatchTooLarge = 27, } impl From for Error { @@ -302,6 +405,15 @@ pub struct Redeemed { pub payout: i128, } +#[contractevent] +pub struct PoolRemainderClaimed { + #[topic] + pub market_id: u64, + #[topic] + pub creator: Address, + pub payout: i128, +} + #[contractevent] pub struct FeesSwept { #[topic] @@ -813,9 +925,167 @@ impl MarketCore { if payout > 0 { Self::token(&env)?.transfer(&env.current_contract_address(), &creator, &payout); } + PoolRemainderClaimed { + market_id, + creator, + payout, + } + .publish(&env); Ok(payout) } + /// Redeems `holder`'s positions across up to `MAX_BATCH` final markets + /// with one collateral transfer. `market_ids` must be strictly ascending + /// (`InvalidAmount` otherwise; empty or over-cap is `BatchTooLarge`) so + /// the footprint is deterministic and duplicates are impossible. + /// + /// Markets that cannot pay do not abort the batch; they come back in + /// `items` with a `SkipReason`. In particular an archived `Market` entry + /// is NOT `NotFound` — the transaction fails at footprint time and the + /// market must be restored first (simulation shows this before + /// submission). `holder.require_auth()` covers the whole batch: the + /// signed payload is the `market_ids` vector itself. + pub fn redeem_many( + env: Env, + holder: Address, + market_ids: Vec, + ) -> Result { + holder.require_auth(); + Self::validate_batch(&market_ids)?; + let mut total: i128 = 0; + let mut items: Vec = Vec::new(&env); + for id in market_ids.iter() { + let key = DataKey::Market(id); + // Raw read: a market that ends up skipped must not pay a TTL + // bump, and a missing entry must skip rather than revert. + let m = match env.storage().persistent().get::<_, Market>(&key) { + Some(m) => m, + None => { + items.push_back(Self::batch_skip(id, SkipReason::NotFound)); + continue; + } + }; + let kind = match Self::settlement(&m) { + Ok(kind) => kind, + Err(_) => { + items.push_back(Self::batch_skip(id, SkipReason::NotFinal)); + continue; + } + }; + if Self::settle_closed(&env, id) { + // `finalize_market` swept this market: redemption is over. + items.push_back(Self::batch_skip(id, SkipReason::NothingToRedeem)); + continue; + } + let pos = Self::load_position(&env, id, &holder, m.close_time); + if pos.yes == 0 && pos.no == 0 { + items.push_back(Self::batch_skip(id, SkipReason::NothingToRedeem)); + continue; + } + ttl::bump_persistent(&env, &key, ttl::market_target(&env, m.close_time)); + let payout = math::settle_value(kind, pos.yes, pos.no); + Self::save_position(&env, id, &holder, &Position { yes: 0, no: 0 }, m.close_time); + // Both sides decrement even when only one pays (same as the + // single-market path). + if let Some(mut settle) = Self::load_settle_opt(&env, id) { + settle.outstanding_yes = settle + .outstanding_yes + .checked_sub(pos.yes) + .ok_or(Error::Overflow)?; + settle.outstanding_no = settle + .outstanding_no + .checked_sub(pos.no) + .ok_or(Error::Overflow)?; + settle.paid_out = settle.paid_out.checked_add(payout).ok_or(Error::Overflow)?; + Self::save_settle(&env, id, &settle); + } + total = total.checked_add(payout).ok_or(Error::Overflow)?; + Redeemed { + market_id: id, + holder: holder.clone(), + payout, + } + .publish(&env); + items.push_back(BatchItem { + market_id: id, + payout, + status: BatchStatus::Settled, + }); + } + if total > 0 { + Self::token(&env)?.transfer(&env.current_contract_address(), &holder, &total); + } + Ok(BatchResult { total, items }) + } + + /// Claims the LP pool remainder for up to `MAX_BATCH` markets the + /// `creator` owns, with one collateral transfer. Same ordering and skip + /// semantics as `redeem_many`. A market closed by `finalize_market` + /// reports `AlreadyClaimed` — the sweep already took the remainder. + pub fn claim_pool_remainders( + env: Env, + creator: Address, + market_ids: Vec, + ) -> Result { + creator.require_auth(); + Self::validate_batch(&market_ids)?; + let mut total: i128 = 0; + let mut items: Vec = Vec::new(&env); + for id in market_ids.iter() { + let mut m = match env + .storage() + .persistent() + .get::<_, Market>(&DataKey::Market(id)) + { + Some(m) => m, + None => { + items.push_back(Self::batch_skip(id, SkipReason::NotFound)); + continue; + } + }; + if m.creator != creator { + items.push_back(Self::batch_skip(id, SkipReason::NotCreator)); + continue; + } + let kind = match Self::settlement(&m) { + Ok(kind) => kind, + Err(_) => { + items.push_back(Self::batch_skip(id, SkipReason::NotFinal)); + continue; + } + }; + if m.lp_claimed || Self::settle_closed(&env, id) { + items.push_back(Self::batch_skip(id, SkipReason::AlreadyClaimed)); + continue; + } + let payout = math::settle_value(kind, m.reserve_yes, m.reserve_no); + m.reserve_yes = 0; + m.reserve_no = 0; + m.lp_claimed = true; + Self::save_market(&env, id, &m); + if let Some(mut settle) = Self::load_settle_opt(&env, id) { + settle.paid_out = settle.paid_out.checked_add(payout).ok_or(Error::Overflow)?; + Self::save_settle(&env, id, &settle); + } + total = total.checked_add(payout).ok_or(Error::Overflow)?; + PoolRemainderClaimed { + market_id: id, + creator: creator.clone(), + payout, + } + .publish(&env); + items.push_back(BatchItem { + market_id: id, + payout, + status: BatchStatus::Settled, + }); + } + if total > 0 { + Self::token(&env)?.transfer(&env.current_contract_address(), &creator, &total); + } + Ok(BatchResult { total, items }) + } + /// Permissionless close-out of a final market after its redemption /// deadline. Sweeps the market's entire remaining balance — value /// reserved for shares never redeemed, an unclaimed LP remainder, @@ -955,6 +1225,14 @@ impl MarketCore { } } + /// Largest accepted `market_ids` length for `redeem_many` / + /// `claim_pool_remainders`. Frontends should chunk work into strictly + /// ascending batches of at most this size (see + /// `docs/frontend-integration.md`). + pub fn max_batch(_env: Env) -> u32 { + MAX_BATCH + } + /// Spot price of `outcome` in bps (0..=10_000). YES + NO ~ 10_000. pub fn get_price(env: Env, market_id: u64, outcome: Outcome) -> Result { let m = Self::load_market(&env, market_id)?; @@ -1190,6 +1468,39 @@ impl MarketCore { ttl::bump_persistent(env, &key, ttl::long_lived_target(env)); } + /// `true` once `finalize_market` closed the market. Raw read on purpose: + /// batch loops must not pay a TTL bump for a market they only skip. + fn settle_closed(env: &Env, id: u64) -> bool { + env.storage() + .persistent() + .get::<_, SettleState>(&DataKey::Settle(id)) + .is_some_and(|s| s.closed) + } + + fn batch_skip(id: u64, why: SkipReason) -> BatchItem { + BatchItem { + market_id: id, + payout: 0, + status: BatchStatus::Skipped(why), + } + } + + /// Batch input validation: at least one id, at most `MAX_BATCH`, and + /// strictly ascending so the footprint is deterministic and duplicates + /// cannot appear. + fn validate_batch(market_ids: &Vec) -> Result<(), Error> { + let n = market_ids.len(); + if n == 0 || n > MAX_BATCH { + return Err(Error::BatchTooLarge); + } + for i in 1..n { + if market_ids.get(i).unwrap() <= market_ids.get(i - 1).unwrap() { + return Err(Error::InvalidAmount); + } + } + Ok(()) + } + /// `None` for markets created before the accumulator existed; unlike a /// missing `Settle` entry this cannot be reconstructed on-chain, so the /// oracle views refuse them (`NotSupported`) while trading keeps working @@ -1340,11 +1651,15 @@ impl MarketCore { #[cfg(test)] mod test { + extern crate std; + use super::*; use soroban_sdk::{ - testutils::{storage::Persistent as _, Address as _, Ledger, MockAuth, MockAuthInvoke}, - token::StellarAssetClient, - IntoVal, + testutils::{ + storage::Persistent as _, Address as _, Events as _, Ledger, MockAuth, MockAuthInvoke, + }, + token::{Client as TokenClient, StellarAssetClient}, + Event as _, IntoVal, }; fn setup_market() -> ( @@ -1962,7 +2277,7 @@ mod test { } #[test] - #[should_panic(expected = "Error(Contract, #20)")] + #[should_panic(expected = "Error(Contract, #24)")] fn finalize_before_deadline_rejected() { let (env, client, creator, _, _, _) = setup_market(); let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); @@ -2085,7 +2400,7 @@ mod test { } #[test] - #[should_panic(expected = "Error(Contract, #21)")] + #[should_panic(expected = "Error(Contract, #25)")] fn redeem_after_close_rejected() { let (env, client, creator, trader, _, _) = setup_market(); let market_id = create_market_with(&env, &client, &creator, 6800, 60_000); @@ -2106,7 +2421,7 @@ mod test { } #[test] - #[should_panic(expected = "Error(Contract, #21)")] + #[should_panic(expected = "Error(Contract, #25)")] fn claim_after_close_rejected() { let (env, client, creator, _, _, _) = setup_market(); let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); @@ -2120,7 +2435,7 @@ mod test { } #[test] - #[should_panic(expected = "Error(Contract, #21)")] + #[should_panic(expected = "Error(Contract, #25)")] fn finalize_twice_rejected() { let (env, client, creator, _, _, _) = setup_market(); let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); @@ -2134,7 +2449,7 @@ mod test { } #[test] - #[should_panic(expected = "Error(Contract, #22)")] + #[should_panic(expected = "Error(Contract, #26)")] fn legacy_market_cannot_finalize() { let (env, client, creator, _, _, _) = setup_market(); let market_id = create_market_with(&env, &client, &creator, 5000, 60_000); @@ -2586,4 +2901,454 @@ mod test { env.cost_estimate().resources() ); } + + // ----- issue #15: batch redemption across many markets ----------------- + + fn open_market_with_position( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + trader: &Address, + outcome: Outcome, + collateral_in: i128, + ) -> u64 { + let market_id = create_market_with(env, client, creator, 5_000, 60_000); + client.buy(trader, &market_id, &outcome, &collateral_in, &0_i128); + market_id + } + + /// Advance past every close used by the batch tests, then resolve `ids`. + fn advance_and_resolve(env: &Env, client: &MarketCoreClient, ids: &[u64], outcome: Outcome) { + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + for id in ids { + client.resolve(id, &outcome); + } + } + + fn ids_vec(env: &Env, ids: &[u64]) -> Vec { + let mut v = Vec::new(env); + for id in ids { + v.push_back(*id); + } + v + } + + /// XDR events published by the market-core contract itself, oldest first. + fn core_events( + env: &Env, + client: &MarketCoreClient, + ) -> std::vec::Vec { + let filtered = env.events().all().filter_by_contract(&client.address); + let mut out = std::vec::Vec::new(); + for event in filtered.events() { + out.push(event.clone()); + } + out + } + + #[test] + fn redeem_many_pays_single_aggregated_transfer() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let m0 = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + let m1 = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 7 * 10_000_000, + ); + let m2 = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 5 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[m0, m1, m2], Outcome::Yes); + + let token = TokenClient::new(&env, &collateral); + let core_before = token.balance(&client.address); + let trader_before = token.balance(&trader); + + let res = client.redeem_many(&trader, &ids_vec(&env, &[m0, m1, m2])); + + // `Events::all()` reports only the last contract invocation's events, + // so capture them before any balance/state reads (which are + // themselves invocations). + let emitted = core_events(&env, &client); + assert_eq!(emitted.len(), 3); + + assert_eq!(res.items.len(), 3); + let mut sum = 0i128; + for (i, item) in res.items.iter().enumerate() { + assert_eq!(item.market_id, [m0, m1, m2][i]); + assert_eq!(item.status, BatchStatus::Settled); + assert!(item.payout > 0); + sum += item.payout; + } + assert_eq!(sum, res.total); + // One aggregated transfer: the contract pays exactly `total`, the + // holder receives exactly `total`, and nothing else moved. + assert_eq!(token.balance(&client.address), core_before - res.total); + assert_eq!(token.balance(&trader), trader_before + res.total); + // Positions are cleared and `SettleState::paid_out` tracks each payout. + for (i, m) in [m0, m1, m2].iter().enumerate() { + let pos = client.get_position(m, &trader); + assert_eq!(pos.yes, 0); + assert_eq!(pos.no, 0); + let settle = client.settle_state(m); + assert_eq!(settle.paid_out, res.items.get(i as u32).unwrap().payout); + } + // One Redeemed event per market, in request order. + for (i, m) in [m0, m1, m2].iter().enumerate() { + let expected = Redeemed { + market_id: *m, + holder: trader.clone(), + payout: res.items.get(i as u32).unwrap().payout, + } + .to_xdr(&env, &client.address); + assert_eq!(emitted[i], expected); + } + } + + #[test] + fn redeem_many_skips_open_and_empty_markets() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let m_open = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + // No position for `trader` — the batch must skip, not revert. + let m_flat = create_market_with(&env, &client, &creator, 5_000, 60_000); + let m_paid = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 5 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[m_flat, m_paid], Outcome::Yes); + + let token = TokenClient::new(&env, &collateral); + let before = token.balance(&trader); + let res = client.redeem_many(&trader, &ids_vec(&env, &[m_open, m_flat, m_paid])); + + assert_eq!( + res.items.get(0).unwrap().status, + BatchStatus::Skipped(SkipReason::NotFinal) + ); + assert_eq!(res.items.get(0).unwrap().payout, 0); + assert_eq!( + res.items.get(1).unwrap().status, + BatchStatus::Skipped(SkipReason::NothingToRedeem) + ); + assert_eq!(res.items.get(2).unwrap().status, BatchStatus::Settled); + assert_eq!(res.total, res.items.get(2).unwrap().payout); + assert_eq!(token.balance(&trader), before + res.total); + // The open market's position is untouched. + assert!(client.get_position(&m_open, &trader).yes > 0); + } + + #[test] + fn redeem_many_rejects_unsorted_or_duplicate_ids() { + let (env, client, creator, trader, _, _) = setup_market(); + let m0 = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + let m1 = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[m0, m1], Outcome::Yes); + + assert_eq!( + client.try_redeem_many(&trader, &ids_vec(&env, &[m1, m0])), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!( + client.try_redeem_many(&trader, &ids_vec(&env, &[m0, m0])), + Err(Ok(Error::InvalidAmount)) + ); + // Validation runs before any state change: positions are intact. + assert!(client.get_position(&m0, &trader).yes > 0); + assert!(client.get_position(&m1, &trader).yes > 0); + } + + #[test] + fn redeem_many_rejects_over_cap() { + let (env, client, _, trader, _, _) = setup_market(); + let mut over = Vec::new(&env); + for i in 0..=(MAX_BATCH as u64) { + over.push_back(i); + } + assert_eq!( + client.try_redeem_many(&trader, &over), + Err(Ok(Error::BatchTooLarge)) + ); + assert_eq!( + client.try_redeem_many(&trader, &Vec::new(&env)), + Err(Ok(Error::BatchTooLarge)) + ); + // The LP batch validates identically. + assert_eq!( + client.try_claim_pool_remainders(&trader, &over), + Err(Ok(Error::BatchTooLarge)) + ); + assert_eq!( + client.try_claim_pool_remainders(&trader, &Vec::new(&env)), + Err(Ok(Error::BatchTooLarge)) + ); + } + + #[test] + fn redeem_many_mixed_yes_no_void() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let m_yes = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + let m_no = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::No, + 7 * 10_000_000, + ); + let m_void = create_market_with(&env, &client, &creator, 5_000, 60_000); + client.buy( + &trader, + &m_void, + &Outcome::Yes, + &(4 * 10_000_000_i128), + &0_i128, + ); + client.buy( + &trader, + &m_void, + &Outcome::No, + &(3 * 10_000_000_i128), + &0_i128, + ); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve(&m_yes, &Outcome::Yes); + client.resolve(&m_no, &Outcome::No); + client.void_market(&m_void); + + let pos_yes = client.get_position(&m_yes, &trader); + let pos_no = client.get_position(&m_no, &trader); + let pos_void = client.get_position(&m_void, &trader); + assert!(pos_yes.yes > 0 && pos_no.no > 0 && pos_void.no > 0); + + let token = TokenClient::new(&env, &collateral); + let before = token.balance(&trader); + let res = client.redeem_many(&trader, &ids_vec(&env, &[m_yes, m_no, m_void])); + + assert_eq!(res.items.get(0).unwrap().payout, pos_yes.yes); + assert_eq!(res.items.get(1).unwrap().payout, pos_no.no); + assert_eq!( + res.items.get(2).unwrap().payout, + (pos_void.yes + pos_void.no) / 2 + ); + assert_eq!( + res.total, + pos_yes.yes + pos_no.no + (pos_void.yes + pos_void.no) / 2 + ); + assert_eq!(token.balance(&trader), before + res.total); + } + + #[test] + fn claim_pool_remainders_skips_not_creator() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let m_mine = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + let stranger = Address::generate(&env); + StellarAssetClient::new(&env, &collateral).mint(&stranger, &(1_000 * 10_000_000_i128)); + let m_other = open_market_with_position( + &env, + &client, + &stranger, + &trader, + Outcome::Yes, + 5 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[m_mine, m_other], Outcome::Yes); + + let expected_payout = client.get_market(&m_mine).reserve_yes; + let token = TokenClient::new(&env, &collateral); + let before = token.balance(&creator); + let res = client.claim_pool_remainders(&creator, &ids_vec(&env, &[m_mine, m_other])); + + assert_eq!(res.items.get(0).unwrap().status, BatchStatus::Settled); + assert_eq!(res.items.get(0).unwrap().payout, expected_payout); + assert_eq!( + res.items.get(1).unwrap().status, + BatchStatus::Skipped(SkipReason::NotCreator) + ); + assert_eq!(res.items.get(1).unwrap().payout, 0); + assert_eq!(res.total, expected_payout); + assert_eq!(token.balance(&creator), before + res.total); + assert!(client.get_market(&m_mine).lp_claimed); + assert!(!client.get_market(&m_other).lp_claimed); + } + + #[test] + fn claim_pool_remainder_emits_event() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[market_id], Outcome::Yes); + + let payout = client.claim_pool_remainder(&market_id); + + // Exactly one market-core event: the claim itself (the token transfer + // event belongs to the collateral contract and is filtered out). + let emitted = core_events(&env, &client); + assert_eq!(emitted.len(), 1); + let expected = PoolRemainderClaimed { + market_id, + creator: creator.clone(), + payout, + } + .to_xdr(&env, &client.address); + assert_eq!(emitted[0], expected); + } + + #[test] + fn wrong_caller_cannot_redeem_many_for_other_holder() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + ); + advance_and_resolve(&env, &client, &[market_id], Outcome::Yes); + + let not_holder = Address::generate(&env); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: ¬_holder, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "redeem_many", + args: (trader.clone(), ids_vec(&env, &[market_id])).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_redeem_many(&trader, &ids_vec(&env, &[market_id])) + .is_err()); + // The position was not touched by the failed call. + assert!(client.get_position(&market_id, &trader).yes > 0); + + // The signed payload is exactly `(holder, market_ids)`: a mock signed + // by the holder over that invocation succeeds and pays. + let res = client + .mock_auths(&[MockAuth { + address: &trader, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "redeem_many", + args: (trader.clone(), ids_vec(&env, &[market_id])).into_val(&env), + sub_invokes: &[], + }, + }]) + .redeem_many(&trader, &ids_vec(&env, &[market_id])); + assert!(res.total > 0); + } + + /// Measurement harness behind the `MAX_BATCH` doc table (issue #15): + /// run explicitly with + /// `cargo test -p market-core measure_redeem_many_resources -- --ignored --nocapture`. + #[test] + #[ignore = "measurement harness; run explicitly with --ignored --nocapture"] + fn measure_redeem_many_resources() { + for n in [1u64, 5, 10, 20, 30, 40, 45, 46] { + let (env, client, creator, trader, _, collateral) = setup_market(); + // Each market needs a 100 USDC seed from the creator, and each + // position 10 USDC from the trader; top both up so the harness + // measures redemption, not the faucet. + let sac = StellarAssetClient::new(&env, &collateral); + sac.mint(&creator, &((n as i128 + 10) * 100 * 10_000_000)); + sac.mint(&trader, &((n as i128 + 10) * 10 * 10_000_000)); + let mut ids = Vec::new(&env); + for _ in 0..n { + ids.push_back(open_market_with_position( + &env, + &client, + &creator, + &trader, + Outcome::Yes, + 10 * 10_000_000, + )); + } + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + for id in ids.iter() { + client.resolve(&id, &Outcome::Yes); + } + let res = client.redeem_many(&trader, &ids); + let r = env.cost_estimate().resources(); + std::println!( + "redeem_many n={} total={} items={} | insns={} mem={} disk_read_entries={} mem_read_entries={} write_entries={} footprint={} disk_read_bytes={} write_bytes={} events_bytes={}", + n, + res.total, + res.items.len(), + r.instructions, + r.mem_bytes, + r.disk_read_entries, + r.memory_read_entries, + r.write_entries, + r.disk_read_entries + r.memory_read_entries + r.write_entries, + r.disk_read_bytes, + r.write_bytes, + r.contract_events_size_bytes + ); + } + } } diff --git a/oracledesk-stellar/contracts/resolver/market_core.wasm b/oracledesk-stellar/contracts/resolver/market_core.wasm index cdf8718156f61106501bbe3ff3adcfc1c18144be..1e863784d30861cf4732793e8f7351b4e0dfe5cf 100644 GIT binary patch literal 47533 zcmd_T3y@sbdFOW@-90_sGr;9bf&j05r$x&H3jhZY34kCa%^kkL6)6k>kf!X&(~X%1 zFu=_8pu2|vWIVe1CJK|P=@eP~{~lFxXRE9<7|RT(z?_TyzwZM|qX$_j{s&&+^!>iFh3covC3ik$K_)7Q*;ItIZ1tWYO2&&-) zt8au;R=>~MsO3_4kk1RWFYtMg&qZsqNZ){(1Ju02wVqB{ul@FUiqA#b2YepjbHV1i z5I#)H6IOpBJVTo$>$MoF*K+tM>sbyPtmj0y%=LxvksydKoG(YEd+&?mD7ycFxERI7 zVjM&tDtmf8)EWqu(={P83ILA5D@{ zX{=NlEtjG#TY~VxpumiULKuvUgj{U_ar74kTPu~yJ(cgh=VRYDS&YNrp3*(h)i7IM zzZF!k2H79~1m(!rp+yln(mK&; zwqI!m7m`&qo||7=UR!E(7pb~9dTy=7YP6FtRNQr^xwI7gNU7aiXf~IFOIzK=+(NUl zu(Y_+3@%rF&2nSqVsK@o-R!iM80%`mdaj4}by(&?^JHUfsXKSF$>^({;77ylgVlbB z)2;U67ud^>g%v$rZLD>g3&E@5nC|?*?ckcb?*h5bTKi(~n%#Gonsc2lunbXcT3pB>{kOt!E)K`S(aM{@6`tQ0hF79#{Y%4{=%w)>4ZoU& zXSYN_6jp<38l=&IxI7b1#JY|T#6k6UqbRyMa^<5z_HJ;t7Cjhb(L}8NNm?t+gvmr< zI#n4=6tZ9m|FdXA#Iv%El?AqVD6=bC} z%1TKs3$rL={@p=P3wNYp7CybB7H84Zw2ZQ;Gc}5xaXI;D8cjx1s@xeLoG7Grk!+|g zy6SDogK9Q46TaX&9G+mdo$>UL301nVR^I)(Qp#Q^C6km-5;Hk4U=r)>; z{26hz>Ay6Xi~=1SY#}ZbOC#mcN_C9m3juU3$o|zez*)gDOl5Y}aH-|<61%L_|6s3U>>jej{5$?e% z0c>%ssJ`i$T9o|*_4Uw5$4hE3xuLIB+XG<%_$@35HFyGcj6qTcwkZ3#p>=?(o2{cb zxQ>3`pC9V$7P?^_<#Fw)w}+^=hhJ3R*nub=k2l`~)tl_$k*F|}zCKg9#!jlBYI?R7 zJSgP0EyZq&+;Ro-iw3v~j10g@Cf%<^&QJsKILI&v6rj0Mo(Z0C2$f}bo4_q*pAw6L z`W>lMZN_99^j!V84FsbxwiUkRmY2r)MdUA%9owtVM$(9)E8=#45uW2XZw1DpvJ%A% z>~7}O8|&-q$Xix0Q@(C1NvhQej?dVjcFJz5%QjLPI1=rT_uu*Ps?Ur^YCH4kFFKCE zXj~<3zS#U2NSGiCqb$3cm4*b#>`p47zH$~D*IU%;ekl7!-!S+GBPGvw%C zW;a!qX431QivDXZv-g8DeBKPtBp*}%)fwx^)rDHo!8^=;HF$<|-15vh4VnF>@%`lc z7=y`*$f4+}7MK4^=?8l&8hhh>;rN<-lKGO!@lqCuFo!FPSvXeMSXsy-k==01IE!`6 z!<4P2T4pB7QWb_pwN`$HsdIT7#4zmK)4h8zvS@;

KHY`Nz(Ejr>umBf@SUshk_-FvYpmxuFz%b6sS8d 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 C1(ntS literal 26137 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 TR%_<5nZ2|7ADe9-JP`bEuaFv| diff --git a/oracledesk-stellar/contracts/treasury/market_core.wasm b/oracledesk-stellar/contracts/treasury/market_core.wasm index cdf8718156f61106501bbe3ff3adcfc1c18144be..1e863784d30861cf4732793e8f7351b4e0dfe5cf 100644 GIT binary patch literal 47533 zcmd_T3y@sbdFOW@-90_sGr;9bf&j05r$x&H3jhZY34kCa%^kkL6)6k>kf!X&(~X%1 zFu=_8pu2|vWIVe1CJK|P=@eP~{~lFxXRE9<7|RT(z?_TyzwZM|qX$_j{s&&+^!>iFh3covC3ik$K_)7Q*;ItIZ1tWYO2&&-) zt8au;R=>~MsO3_4kk1RWFYtMg&qZsqNZ){(1Ju02wVqB{ul@FUiqA#b2YepjbHV1i z5I#)H6IOpBJVTo$>$MoF*K+tM>sbyPtmj0y%=LxvksydKoG(YEd+&?mD7ycFxERI7 zVjM&tDtmf8)EWqu(={P83ILA5D@{ zX{=NlEtjG#TY~VxpumiULKuvUgj{U_ar74kTPu~yJ(cgh=VRYDS&YNrp3*(h)i7IM zzZF!k2H79~1m(!rp+yln(mK&; zwqI!m7m`&qo||7=UR!E(7pb~9dTy=7YP6FtRNQr^xwI7gNU7aiXf~IFOIzK=+(NUl zu(Y_+3@%rF&2nSqVsK@o-R!iM80%`mdaj4}by(&?^JHUfsXKSF$>^({;77ylgVlbB z)2;U67ud^>g%v$rZLD>g3&E@5nC|?*?ckcb?*h5bTKi(~n%#Gonsc2lunbXcT3pB>{kOt!E)K`S(aM{@6`tQ0hF79#{Y%4{=%w)>4ZoU& zXSYN_6jp<38l=&IxI7b1#JY|T#6k6UqbRyMa^<5z_HJ;t7Cjhb(L}8NNm?t+gvmr< zI#n4=6tZ9m|FdXA#Iv%El?AqVD6=bC} z%1TKs3$rL={@p=P3wNYp7CybB7H84Zw2ZQ;Gc}5xaXI;D8cjx1s@xeLoG7Grk!+|g zy6SDogK9Q46TaX&9G+mdo$>UL301nVR^I)(Qp#Q^C6km-5;Hk4U=r)>; z{26hz>Ay6Xi~=1SY#}ZbOC#mcN_C9m3juU3$o|zez*)gDOl5Y}aH-|<61%L_|6s3U>>jej{5$?e% z0c>%ssJ`i$T9o|*_4Uw5$4hE3xuLIB+XG<%_$@35HFyGcj6qTcwkZ3#p>=?(o2{cb zxQ>3`pC9V$7P?^_<#Fw)w}+^=hhJ3R*nub=k2l`~)tl_$k*F|}zCKg9#!jlBYI?R7 zJSgP0EyZq&+;Ro-iw3v~j10g@Cf%<^&QJsKILI&v6rj0Mo(Z0C2$f}bo4_q*pAw6L z`W>lMZN_99^j!V84FsbxwiUkRmY2r)MdUA%9owtVM$(9)E8=#45uW2XZw1DpvJ%A% z>~7}O8|&-q$Xix0Q@(C1NvhQej?dVjcFJz5%QjLPI1=rT_uu*Ps?Ur^YCH4kFFKCE zXj~<3zS#U2NSGiCqb$3cm4*b#>`p47zH$~D*IU%;ekl7!-!S+GBPGvw%C zW;a!qX431QivDXZv-g8DeBKPtBp*}%)fwx^)rDHo!8^=;HF$<|-15vh4VnF>@%`lc z7=y`*$f4+}7MK4^=?8l&8hhh>;rN<-lKGO!@lqCuFo!FPSvXeMSXsy-k==01IE!`6 z!<4P2T4pB7QWb_pwN`$HsdIT7#4zmK)4h8zvS@;

KHY`Nz(Ejr>umBf@SUshk_-FvYpmxuFz%b6sS8d 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 C1(ntS literal 26137 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 TR%_<5nZ2|7ADe9-JP`bEuaFv| diff --git a/oracledesk-stellar/contracts/treasury/src/lib.rs b/oracledesk-stellar/contracts/treasury/src/lib.rs index b598206..926bd9b 100644 --- a/oracledesk-stellar/contracts/treasury/src/lib.rs +++ b/oracledesk-stellar/contracts/treasury/src/lib.rs @@ -5,7 +5,7 @@ pub mod ttl; use soroban_sdk::{ auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}, contract, contracterror, contractevent, contractimpl, contracttype, token, vec, Address, - BytesN, Env, IntoVal, String, Symbol, + BytesN, Env, IntoVal, String, Symbol, Vec, }; // See the comment on the equivalent `mod market_core` in @@ -16,7 +16,7 @@ use soroban_sdk::{ mod market_core { soroban_sdk::contractimport!(file = "market_core.wasm"); } -use market_core::{Category, Client as MarketCoreClient, Outcome}; +use market_core::{BatchResult, Category, Client as MarketCoreClient, Outcome}; const DAY_SECONDS: u64 = 86_400; @@ -352,6 +352,34 @@ impl Treasury { Ok(MarketCoreClient::new(&env, &config.market_core).claim_pool_remainder(&market_id)) } + /// Redeems the treasury's positions in up to `MAX_BATCH` markets in one + /// call (market-core issue #15). `market_ids` must be strictly ascending; + /// markets that cannot be paid out right now are reported as skipped + /// rather than aborting the whole batch, so one bad id does not strand + /// the rest. The single aggregated token transfer at the end is why this + /// exists: batching amortizes fixed per-call costs (footprint entries, + /// event size), keeping the treasury inside the per-transaction resource + /// limits even when it holds positions in many markets. + pub fn redeem_many(env: Env, market_ids: Vec) -> Result { + 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) + .redeem_many(&treasury_addr, &market_ids)) + } + + /// Claims the LP pool remainders of up to `MAX_BATCH` treasury-created + /// markets in one call. Skips (never aborts on) markets the treasury did + /// not create, markets that have not settled, and markets already + /// claimed — see `redeem_many` for the batching rationale. + pub fn claim_pool_remainders(env: Env, market_ids: Vec) -> Result { + 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) + .claim_pool_remainders(&treasury_addr, &market_ids)) + } + /// Pulls accrued fees from a configured market-core contract. pub fn collect_market_fees(env: Env, market_id: u64) -> Result { Self::require_admin(&env)?; @@ -488,13 +516,15 @@ impl Treasury { #[cfg(test)] mod test { + extern crate std; + use super::*; use soroban_sdk::{ testutils::{ storage::{Instance as _, Persistent as _}, Address as _, Ledger, MockAuth, MockAuthInvoke, }, - token::StellarAssetClient, + token::{Client as TokenClient, StellarAssetClient}, BytesN, String, }; @@ -868,4 +898,193 @@ mod test { .agent_buy(&f.market_id, &Outcome::Yes, &(20 * 10_000_000_i128), &0); assert_eq!(instance_ttl(&f.env, &f.treasury_id), ttl::INSTANCE_TARGET); } + + // ----- batch redemption through the treasury (issue #15) -------------- + + struct BatchFixture { + env: Env, + treasury: TreasuryClient<'static>, + treasury_id: Address, + market_core: MarketCoreClient<'static>, + token: Address, + ids: Vec, + } + + /// `n` markets created *by the treasury*, each holding a 1 USDC YES + /// position bought through `agent_buy`, all closed and resolved YES at + /// the same timestamp. Sized so the treasury can pay for everything. + fn batch_fixture(n: u64) -> BatchFixture { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + let admin = Address::generate(&env); + let agent = Address::generate(&env); + let treasury_id = Address::generate(&env); + let collateral = env.register_stellar_asset_contract_v2(admin.clone()); + let sac = StellarAssetClient::new(&env, &collateral.address()); + // n x 100 USDC seeds + n x 1 USDC buys + slack. + sac.mint(&treasury_id, &((n as i128 + 10) * 102 * 10_000_000)); + + let market_core_id = env.register( + market_core::WASM, + ( + admin.clone(), + collateral.address(), + treasury_id.clone(), + Address::generate(&env), + 100u32, + ), + ); + let market_core = MarketCoreClient::new(&env, &market_core_id); + // Caps must satisfy max_trade <= max_market <= max_daily; buys are + // 1 USDC each, so the daily cap just needs to cover n of them. + let daily = (n.max(5) as i128 + 1) * 10_000_000; + let treasury = env.register_at( + &treasury_id, + Treasury, + TreasuryArgs::__constructor( + &admin, + &agent, + &collateral.address(), + &market_core_id, + &(5 * 10_000_000), + &(5 * 10_000_000), + &daily, + ), + ); + let treasury_client = TreasuryClient::new(&env, &treasury); + + let mut ids = Vec::new(&env); + for i in 0..n { + let id = market_core.create_market( + &treasury_id, + &BytesN::from_array(&env, &[(i % 250 + 1) as u8; 32]), + &BytesN::from_array(&env, &[211u8; 32]), + &String::from_str(&env, "ipfs://treasury-batch"), + &Category::Crypto, + &2_000u64, + &(100 * 10_000_000_i128), + &5000u32, + ); + ids.push_back(id); + } + for id in ids.iter() { + treasury_client.agent_buy(&id, &Outcome::Yes, &10_000_000_i128, &0); + } + env.ledger().set_timestamp(2_001); + for id in ids.iter() { + market_core.resolve(&id, &Outcome::Yes); + } + + BatchFixture { + env, + treasury: treasury_client, + treasury_id, + market_core, + token: collateral.address(), + ids, + } + } + + /// The acceptance test for issue #15's treasury half: a full-size batch + /// (sized from the contract's own `max_batch` view, so it cannot drift + /// from `MAX_BATCH`) redeems every position in one call, pays exactly + /// the reported total in one balance delta, and skips nothing. + #[test] + fn agent_redeems_many_through_treasury_at_cap() { + let probe = batch_fixture(0); + let cap = probe.market_core.max_batch(); + drop(probe); + let f = batch_fixture(cap as u64); + + let before = TokenClient::new(&f.env, &f.token).balance(&f.treasury_id); + let result = f.treasury.redeem_many(&f.ids); + let after = TokenClient::new(&f.env, &f.token).balance(&f.treasury_id); + + assert_eq!(result.items.len(), cap); + let mut sum: i128 = 0; + for (i, item) in result.items.iter().enumerate() { + assert_eq!(item.market_id, f.ids.get(i as u32).unwrap()); + assert_eq!(item.status, market_core::BatchStatus::Settled); + assert!(item.payout > 0); + sum += item.payout; + } + assert_eq!(result.total, sum); + assert_eq!(after - before, result.total); + } + + /// The batch forwarders are agent-gated exactly like their single-market + /// counterparts — and the gate holds at a full `max_batch` payload, so a + /// hostile caller cannot even get a validation error before the auth + /// check refuses them. + #[test] + fn wrong_caller_cannot_redeem_many() { + let probe = batch_fixture(0); + let cap = probe.market_core.max_batch(); + let f = probe; + let mut ids = Vec::new(&f.env); + for i in 0..cap { + ids.push_back(i as u64); + } + + let not_agent = Address::generate(&f.env); + f.env.set_auths(&[]); + assert!(f + .treasury + .mock_auths(&[MockAuth { + address: ¬_agent, + invoke: &MockAuthInvoke { + contract: &f.treasury_id, + fn_name: "redeem_many", + args: (ids.clone(),).into_val(&f.env), + sub_invokes: &[], + }, + }]) + .try_redeem_many(&ids) + .is_err()); + assert!(f + .treasury + .mock_auths(&[MockAuth { + address: ¬_agent, + invoke: &MockAuthInvoke { + contract: &f.treasury_id, + fn_name: "claim_pool_remainders", + args: (ids.clone(),).into_val(&f.env), + sub_invokes: &[], + }, + }]) + .try_claim_pool_remainders(&ids) + .is_err()); + } + + /// Measurement harness behind the treasury half of market-core's + /// `MAX_BATCH` doc table (issue #15): run explicitly with + /// `cargo test -p treasury measure_treasury_redeem_many_resources -- --ignored --nocapture`. + #[test] + #[ignore = "measurement harness; run explicitly with --ignored --nocapture"] + fn measure_treasury_redeem_many_resources() { + let probe = batch_fixture(0); + let cap = probe.market_core.max_batch() as u64; + drop(probe); + for n in [1u64, 10, cap] { + let f = batch_fixture(n); + let res = f.treasury.redeem_many(&f.ids); + let r = f.env.cost_estimate().resources(); + std::println!( + "treasury redeem_many n={} total={} items={} | insns={} mem={} disk_read_entries={} mem_read_entries={} write_entries={} footprint={} disk_read_bytes={} write_bytes={} events_bytes={}", + n, + res.total, + res.items.len(), + r.instructions, + r.mem_bytes, + r.disk_read_entries, + r.memory_read_entries, + r.write_entries, + r.disk_read_entries + r.memory_read_entries + r.write_entries, + r.disk_read_bytes, + r.write_bytes, + r.contract_events_size_bytes + ); + } + } } diff --git a/oracledesk-stellar/docs/frontend-integration.md b/oracledesk-stellar/docs/frontend-integration.md index 6da8c63..d39a5db 100644 --- a/oracledesk-stellar/docs/frontend-integration.md +++ b/oracledesk-stellar/docs/frontend-integration.md @@ -79,6 +79,69 @@ no on-chain sqrt (see the docstring on `sell` in `model/fpmm_model.py` — and the pool may have moved between your read and your transaction). +## Batch redemption (`redeem_many` / `claim_pool_remainders`) + +Settling markets one by one burns a fixed per-call cost (footprint entries, +event bytes) and quickly runs into the per-transaction resource limits. The +batch entry points settle many markets in a single call, paying the total in +one transfer: + +- `redeem_many(holder, market_ids)` — redeems one holder's positions across + markets that have resolved or voided. +- `claim_pool_remainders(creator, market_ids)` — claims the LP remainders of + markets the caller created. + +Rules to build callers around: + +1. **Ascending, deduplicated, non-empty ids.** `market_ids` must be strictly + ascending — that keeps the footprint deterministic and duplicates + impossible. Anything else reverts with `InvalidAmount`; an empty list or + one longer than `max_batch()` reverts with `BatchTooLarge`. +2. **Chunk to `max_batch()`, read from the contract.** Never hardcode the + cap: `const { result: cap } = await contract.max_batch();`. Sort ids + ascending, then slice into chunks of at most `cap`. The committed + measurement table behind the cap is in the `MAX_BATCH` docstring in + `contracts/market-core/src/lib.rs`. +3. **Skips are per-market, not per-call.** A market that cannot pay out + right now (not found, not final, nothing to redeem, already claimed or + claimed by someone else, not the creator) comes back as + `{ status: { tag: "Skipped", … }, payout: 0 }` instead of aborting the + batch — one bad id never strands the rest. Only input validation + (rule 1) aborts the whole call. +4. **Simulate before signing.** The footprint scales with the chunk size; + `AssembledTransaction`'s simulate step (run by `signAndSend`, or + explicitly) surfaces contract errors and resource problems before + anything is submitted. +5. **Archived entries must be restored first.** If a market, position or + settlement entry has expired out of the live state, its restore must + happen in a *separate* transaction (e.g. `stellar tx restore-footprint`): + restores included in the batch transaction consume the 200-entry + disk-read budget and can push a full-size chunk over the limit. Run the + batch chunk against live state. +6. **One transfer per call.** The chunk's payouts arrive in a single token + transfer, so the balance delta equals `result.total` exactly — reconcile + on that, and use `result.items[i]` for per-market receipts. + +```ts +const { result: cap } = await contract.max_batch(); // u32, from the contract +const sorted = [...ids].sort((a, b) => Number(a - b)); // strictly ascending + +for (let i = 0; i < sorted.length; i += cap) { + const chunk = sorted.slice(i, i + cap); + const tx = await contract.redeem_many({ holder, market_ids: chunk }); + const { result } = await tx.signAndSend({ signTransaction }); + + const retry = result.items + .filter((it) => it.status.tag === "Skipped") + .map((it) => [it.market_id, it.status.values]); // e.g. drop AlreadyClaimed +} +``` + +The treasury contract mirrors both entry points (`redeem_many` / +`claim_pool_remainders`, same `BatchResult` shape) for positions it holds +itself; agents call those instead of market-core directly, and the +`max_batch()` on market-core still applies. + ## Reading TWAP prices `get_price(market_id)` is the instantaneous spot price; it is trivially diff --git a/oracledesk-stellar/packages/bindings/market-core/src/index.ts b/oracledesk-stellar/packages/bindings/market-core/src/index.ts index 05d6422..f2b55b9 100644 --- a/oracledesk-stellar/packages/bindings/market-core/src/index.ts +++ b/oracledesk-stellar/packages/bindings/market-core/src/index.ts @@ -56,21 +56,25 @@ export const Errors = { * `observe` / `twap` asked for a time older than every surviving * observation of the market (the ring keeps the most recent 16). */ - 19: {message:"ObservationTooOld"}, + 23: {message:"ObservationTooOld"}, /** * `finalize_market` called before the redemption deadline. */ - 20: {message:"RedemptionWindowOpen"}, + 24: {message:"RedemptionWindowOpen"}, /** * Market already closed by `finalize_market`; redemption is over. */ - 21: {message:"RedemptionClosed"}, + 25: {message:"RedemptionClosed"}, /** * Legacy market created before `SettleState` existed: outstanding shares * cannot be reconstructed on-chain, so it has no deadline and cannot be * finalized (still fully redeemable). */ - 22: {message:"NotSupported"} + 26: {message:"NotSupported"}, + /** + * A batch call carried no ids or more than `MAX_BATCH` of them. + */ + 27: {message:"BatchTooLarge"} } @@ -144,6 +148,50 @@ export interface Position { +/** + * One market's outcome inside a `BatchResult`, in request order. + */ +export interface BatchItem { + market_id: u64; + payout: i128; + status: BatchStatus; +} + + +/** + * Why a market in a batch call did not settle. `Duplicate` is reserved: + * ids are validated strictly ascending first, so a duplicate is rejected + * as `InvalidAmount` before the batch runs and never reaches a skip. + */ +export type SkipReason = {tag: "NotFound", values: void} | {tag: "NotFinal", values: void} | {tag: "NothingToRedeem", values: void} | {tag: "AlreadyClaimed", values: void} | {tag: "NotCreator", values: void} | {tag: "Duplicate", values: void}; + + +/** + * Result of `redeem_many` / `claim_pool_remainders`: `total` is the single + * aggregated transfer, `items` has one entry per requested market in the + * same order. + */ +export interface BatchResult { + items: Array; + total: i128; +} + +/** + * Per-market result of a batch settlement. + * + * This union stands in for the `Option` sketched in the issue: + * a `#[contracttype]` struct field of type `Option` does not + * compile under the `testutils` feature (on for every `cargo test` build). + * The derive emits an xdr conversion gated on `CustomType: Into`, + * but `#[contracttype]` enums only derive `TryFrom`, and the missing impls + * cannot be supplied from this crate: `From for ScVal` collides with + * the derive's own `TryFrom` through core's blanket impl (E0119), and + * `TryFrom<&Option> for ScVal` violates the orphan rule (E0117). + * The union carries the same information and surfaces as a discriminated + * union in the generated TS bindings. + */ +export type BatchStatus = {tag: "Settled", values: void} | {tag: "Skipped", values: readonly [SkipReason]}; + /** * One point in the TWAP ring buffer. At most one per distinct timestamp. @@ -238,6 +286,7 @@ export type MarketStatus = {tag: "Open", values: void} | {tag: "Resolved", value + 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. @@ -299,6 +348,15 @@ export interface Client { */ get_price: ({market_id, outcome}: {market_id: u64, outcome: Outcome}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a max_batch 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. + * Largest accepted `market_ids` length for `redeem_many` / + * `claim_pool_remainders`. Frontends should chunk work into strictly + * ascending batches of at most this size (see + * `docs/frontend-integration.md`). + */ + max_batch: (options?: MethodOptions) => Promise> + /** * Construct and simulate a quote_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. * Shares a `buy` of `collateral_in` would return right now. @@ -332,6 +390,22 @@ export interface Client { */ sweep_fees: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a redeem_many 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. + * Redeems `holder`'s positions across up to `MAX_BATCH` final markets + * with one collateral transfer. `market_ids` must be strictly ascending + * (`InvalidAmount` otherwise; empty or over-cap is `BatchTooLarge`) so + * the footprint is deterministic and duplicates are impossible. + * + * Markets that cannot pay do not abort the batch; they come back in + * `items` with a `SkipReason`. In particular an archived `Market` entry + * is NOT `NotFound` — the transaction fails at footprint time and the + * market must be restored first (simulation shows this before + * submission). `holder.require_auth()` covers the whole batch: the + * signed payload is the `market_ids` vector itself. + */ + redeem_many: ({holder, market_ids}: {holder: string, market_ids: Array}, 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. @@ -423,6 +497,15 @@ export interface Client { */ claim_pool_remainder: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a claim_pool_remainders 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 LP pool remainder for up to `MAX_BATCH` markets the + * `creator` owns, with one collateral transfer. Same ordering and skip + * semantics as `redeem_many`. A market closed by `finalize_market` + * reports `AlreadyClaimed` — the sweep already took the remainder. + */ + claim_pool_remainders: ({creator, market_ids}: {creator: string, market_ids: Array}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a set_default_redeem_horizon 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 @@ -451,7 +534,7 @@ export class Client extends ContractClient { } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAFgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAABlBhdXNlZAAAAAAAAgAAAAAAAAAKSW52YWxpZEZlZQAAAAAAAwAAAAAAAAANSW52YWxpZEFtb3VudAAAAAAAAAQAAAAAAAAAEkludmFsaWRQcm9iYWJpbGl0eQAAAAAABQAAAAAAAAAQSW52YWxpZENsb3NlVGltZQAAAAYAAAAAAAAADk1hcmtldE5vdEZvdW5kAAAAAAAHAAAAAAAAAA1NYXJrZXROb3RPcGVuAAAAAAAACAAAAAAAAAAMTWFya2V0Q2xvc2VkAAAACQAAAAAAAAAMTm90Q2xvc2VkWWV0AAAACgAAAAAAAAAITm90RmluYWwAAAALAAAAAAAAABBTbGlwcGFnZUV4Y2VlZGVkAAAADAAAAAAAAAASSW5zdWZmaWNpZW50U2hhcmVzAAAAAAANAAAAAAAAABVJbnN1ZmZpY2llbnRMaXF1aWRpdHkAAAAAAAAOAAAAAAAAAA5NYXJrZXRUb29MYXJnZQAAAAAADwAAAAAAAAAPTm90aGluZ1RvUmVkZWVtAAAAABAAAAAAAAAADkFscmVhZHlDbGFpbWVkAAAAAAARAAAAAAAAAAhPdmVyZmxvdwAAABIAAAB9YG9ic2VydmVgIC8gYHR3YXBgIGFza2VkIGZvciBhIHRpbWUgb2xkZXIgdGhhbiBldmVyeSBzdXJ2aXZpbmcKb2JzZXJ2YXRpb24gb2YgdGhlIG1hcmtldCAodGhlIHJpbmcga2VlcHMgdGhlIG1vc3QgcmVjZW50IDE2KS4AAAAAAAART2JzZXJ2YXRpb25Ub29PbGQAAAAAAAATAAAAOGBmaW5hbGl6ZV9tYXJrZXRgIGNhbGxlZCBiZWZvcmUgdGhlIHJlZGVtcHRpb24gZGVhZGxpbmUuAAAAFFJlZGVtcHRpb25XaW5kb3dPcGVuAAAAFAAAAD9NYXJrZXQgYWxyZWFkeSBjbG9zZWQgYnkgYGZpbmFsaXplX21hcmtldGA7IHJlZGVtcHRpb24gaXMgb3Zlci4AAAAAEFJlZGVtcHRpb25DbG9zZWQAAAAVAAAAsExlZ2FjeSBtYXJrZXQgY3JlYXRlZCBiZWZvcmUgYFNldHRsZVN0YXRlYCBleGlzdGVkOiBvdXRzdGFuZGluZyBzaGFyZXMKY2Fubm90IGJlIHJlY29uc3RydWN0ZWQgb24tY2hhaW4sIHNvIGl0IGhhcyBubyBkZWFkbGluZSBhbmQgY2Fubm90IGJlCmZpbmFsaXplZCAoc3RpbGwgZnVsbHkgcmVkZWVtYWJsZSkuAAAADE5vdFN1cHBvcnRlZAAAABY=", + new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAFwAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAABlBhdXNlZAAAAAAAAgAAAAAAAAAKSW52YWxpZEZlZQAAAAAAAwAAAAAAAAANSW52YWxpZEFtb3VudAAAAAAAAAQAAAAAAAAAEkludmFsaWRQcm9iYWJpbGl0eQAAAAAABQAAAAAAAAAQSW52YWxpZENsb3NlVGltZQAAAAYAAAAAAAAADk1hcmtldE5vdEZvdW5kAAAAAAAHAAAAAAAAAA1NYXJrZXROb3RPcGVuAAAAAAAACAAAAAAAAAAMTWFya2V0Q2xvc2VkAAAACQAAAAAAAAAMTm90Q2xvc2VkWWV0AAAACgAAAAAAAAAITm90RmluYWwAAAALAAAAAAAAABBTbGlwcGFnZUV4Y2VlZGVkAAAADAAAAAAAAAASSW5zdWZmaWNpZW50U2hhcmVzAAAAAAANAAAAAAAAABVJbnN1ZmZpY2llbnRMaXF1aWRpdHkAAAAAAAAOAAAAAAAAAA5NYXJrZXRUb29MYXJnZQAAAAAADwAAAAAAAAAPTm90aGluZ1RvUmVkZWVtAAAAABAAAAAAAAAADkFscmVhZHlDbGFpbWVkAAAAAAARAAAAAAAAAAhPdmVyZmxvdwAAABIAAAB9YG9ic2VydmVgIC8gYHR3YXBgIGFza2VkIGZvciBhIHRpbWUgb2xkZXIgdGhhbiBldmVyeSBzdXJ2aXZpbmcKb2JzZXJ2YXRpb24gb2YgdGhlIG1hcmtldCAodGhlIHJpbmcga2VlcHMgdGhlIG1vc3QgcmVjZW50IDE2KS4AAAAAAAART2JzZXJ2YXRpb25Ub29PbGQAAAAAAAAXAAAAOGBmaW5hbGl6ZV9tYXJrZXRgIGNhbGxlZCBiZWZvcmUgdGhlIHJlZGVtcHRpb24gZGVhZGxpbmUuAAAAFFJlZGVtcHRpb25XaW5kb3dPcGVuAAAAGAAAAD9NYXJrZXQgYWxyZWFkeSBjbG9zZWQgYnkgYGZpbmFsaXplX21hcmtldGA7IHJlZGVtcHRpb24gaXMgb3Zlci4AAAAAEFJlZGVtcHRpb25DbG9zZWQAAAAZAAAAsExlZ2FjeSBtYXJrZXQgY3JlYXRlZCBiZWZvcmUgYFNldHRsZVN0YXRlYCBleGlzdGVkOiBvdXRzdGFuZGluZyBzaGFyZXMKY2Fubm90IGJlIHJlY29uc3RydWN0ZWQgb24tY2hhaW4sIHNvIGl0IGhhcyBubyBkZWFkbGluZSBhbmQgY2Fubm90IGJlCmZpbmFsaXplZCAoc3RpbGwgZnVsbHkgcmVkZWVtYWJsZSkuAAAADE5vdFN1cHBvcnRlZAAAABoAAAA9QSBiYXRjaCBjYWxsIGNhcnJpZWQgbm8gaWRzIG9yIG1vcmUgdGhhbiBgTUFYX0JBVENIYCBvZiB0aGVtLgAAAAAAAA1CYXRjaFRvb0xhcmdlAAAAAAAAGw==", "AAAABQAAAAAAAAAAAAAABVRyYWRlAAAAAAAAAQAAAAV0cmFkZQAAAAAAAAkAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAZ0cmFkZXIAAAAAABMAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAZpc19idXkAAAAAAAEAAAAAAAAAAAAAAApjb2xsYXRlcmFsAAAAAAALAAAAAAAAAAAAAAAGc2hhcmVzAAAAAAALAAAAAAAAAAAAAAADZmVlAAAAAAsAAAAAAAAAAAAAAAtyZXNlcnZlX3llcwAAAAALAAAAAAAAAAAAAAAKcmVzZXJ2ZV9ubwAAAAAACwAAAAAAAAAC", "AAAAAQAAAAAAAAAAAAAABkNvbmZpZwAAAAAABgAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAApjb2xsYXRlcmFsAAAAAAATAAAAAAAAAA9kZWZhdWx0X2ZlZV9icHMAAAAABAAAAAAAAAAGcGF1c2VkAAAAAAABAAAAAAAAAAhyZXNvbHZlcgAAABMAAAAAAAAACHRyZWFzdXJ5AAAAEw==", "AAAAAQAAAAAAAAAAAAAABk1hcmtldAAAAAAADQAAAAAAAAAIY2F0ZWdvcnkAAAfQAAAACENhdGVnb3J5AAAAPFVuaXggc2Vjb25kcyAobGVkZ2VyIHRpbWVzdGFtcCkuIFRyYWRpbmcgc3RvcHMgYXQgdGhpcyB0aW1lLgAAAApjbG9zZV90aW1lAAAAAAAGAAAAAAAAAAdjcmVhdG9yAAAAABMAAABGU25hcHNob3Qgb2YgdGhlIGZlZSBhdCBjcmVhdGlvbiBzbyBsYXRlciBjb25maWcgY2hhbmdlcyBuZXZlciBtb3ZlIGl0LgAAAAAAB2ZlZV9icHMAAAAABAAAACZUcmFkaW5nIGZlZXMgd2FpdGluZyBmb3IgYHN3ZWVwX2ZlZXNgLgAAAAAADGZlZXNfYWNjcnVlZAAAAAsAAAA4Q3JlYXRvciBoYXMgd2l0aGRyYXduIHRoZSBwb29sIHJlbWFpbmRlciBhZnRlciBmaW5hbGl0eS4AAAAKbHBfY2xhaW1lZAAAAAAAAQAAAChJUEZTIENJRCAvIFVSSSBvZiB0aGUgcXVlc3Rpb24gbWV0YWRhdGEuAAAACG1ldGFfdXJpAAAAEAAAAIZzaGEyNTYgb2YgdGhlIGNhbm9uaWNhbCBxdWVzdGlvbiBKU09OIHN0b3JlZCBvZmYtY2hhaW4gKElQRlMpLCBzbyBhCmNsaWVudCBjYW4gcHJvdmUgdGhlIHRleHQgaXQgc2hvd3MgaXMgdGhlIHRleHQgdGhhdCB3YXMgY29tbWl0dGVkLgAAAAAADXF1ZXN0aW9uX2hhc2gAAAAAAAPuAAAAIAAAAAAAAAAKcmVzZXJ2ZV9ubwAAAAAACwAAAAAAAAALcmVzZXJ2ZV95ZXMAAAAACwAAAS9Db21taXRtZW50IHRvIGhvdyB0aGlzIG1hcmtldCB3aWxsIGJlIHJlc29sdmVkLCBmaXhlZCBhdCBjcmVhdGlvbiBzbwp0aGUgY3JlYXRvciBjYW5ub3QgcGljayBmYXZvcmFibGUgcmVzb2x1dGlvbiBydWxlcyBhZnRlciBzZWVpbmcKdHJhZGluZyBhY3Rpdml0eS4gVGhlIHJlc29sdmVyIGNvbnRyYWN0IHZlcmlmaWVzIGEgcmV2ZWFsZWQgc3BlYwphZ2FpbnN0IHRoaXMgaGFzaCBiZWZvcmUgY29uZmlndXJpbmcgaXRzZWxmIGZvciB0aGUgbWFya2V0LiBTZWUKYGRvY3MvYWRyLzAwMDEtcmVzb2x1dGlvbi1jb21taXRtZW50Lm1kYC4AAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAEBDb21wbGV0ZSBzZXRzIGluIGV4aXN0ZW5jZSA9PSBjb2xsYXRlcmFsIGxvY2tlZCBmb3IgdGhpcyBtYXJrZXQuAAAAC3NldHNfbWludGVkAAAAAAsAAAAAAAAABnN0YXR1cwAAAAAH0AAAAAxNYXJrZXRTdGF0dXM=", @@ -460,7 +543,11 @@ export class Client extends ContractClient { "AAAAAgAAAAAAAAAAAAAACENhdGVnb3J5AAAABgAAAAAAAAAAAAAABkNyeXB0bwAAAAAAAAAAAAAAAAAFTWFjcm8AAAAAAAAAAAAAAAAAAAtHZW9wb2xpdGljcwAAAAAAAAAAAAAAAAZTcG9ydHMAAAAAAAAAAAAAAAAAB0N1bHR1cmUAAAAAAAAAAAAAAAAFT3RoZXIAAAA=", "AAAAAQAAAAAAAAAAAAAACFBvc2l0aW9uAAAAAgAAAAAAAAACbm8AAAAAAAsAAAAAAAAAA3llcwAAAAAL", "AAAABQAAAAAAAAAAAAAACFJlZGVlbWVkAAAAAQAAAAhyZWRlZW1lZAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAZob2xkZXIAAAAAABMAAAABAAAAAAAAAAZwYXlvdXQAAAAAAAsAAAAAAAAAAg==", + "AAAAAQAAAD5PbmUgbWFya2V0J3Mgb3V0Y29tZSBpbnNpZGUgYSBgQmF0Y2hSZXN1bHRgLCBpbiByZXF1ZXN0IG9yZGVyLgAAAAAAAAAAAAlCYXRjaEl0ZW0AAAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAZwYXlvdXQAAAAAAAsAAAAAAAAABnN0YXR1cwAAAAAH0AAAAAtCYXRjaFN0YXR1cwA=", "AAAABQAAAAAAAAAAAAAACUZlZXNTd2VwdAAAAAAAAAEAAAAKZmVlc19zd2VwdAAAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAC", + "AAAAAgAAAM9XaHkgYSBtYXJrZXQgaW4gYSBiYXRjaCBjYWxsIGRpZCBub3Qgc2V0dGxlLiBgRHVwbGljYXRlYCBpcyByZXNlcnZlZDoKaWRzIGFyZSB2YWxpZGF0ZWQgc3RyaWN0bHkgYXNjZW5kaW5nIGZpcnN0LCBzbyBhIGR1cGxpY2F0ZSBpcyByZWplY3RlZAphcyBgSW52YWxpZEFtb3VudGAgYmVmb3JlIHRoZSBiYXRjaCBydW5zIGFuZCBuZXZlciByZWFjaGVzIGEgc2tpcC4AAAAAAAAAAApTa2lwUmVhc29uAAAAAAAGAAAAAAAAAAAAAAAITm90Rm91bmQAAAAAAAAAAAAAAAhOb3RGaW5hbAAAAAAAAAAAAAAAD05vdGhpbmdUb1JlZGVlbQAAAAAAAAAAAAAAAA5BbHJlYWR5Q2xhaW1lZAAAAAAAAAAAAAAAAAAKTm90Q3JlYXRvcgAAAAAAAAAAAAAAAAAJRHVwbGljYXRlAAAA", + "AAAAAQAAAJtSZXN1bHQgb2YgYHJlZGVlbV9tYW55YCAvIGBjbGFpbV9wb29sX3JlbWFpbmRlcnNgOiBgdG90YWxgIGlzIHRoZSBzaW5nbGUKYWdncmVnYXRlZCB0cmFuc2ZlciwgYGl0ZW1zYCBoYXMgb25lIGVudHJ5IHBlciByZXF1ZXN0ZWQgbWFya2V0IGluIHRoZQpzYW1lIG9yZGVyLgAAAAAAAAAAC0JhdGNoUmVzdWx0AAAAAAIAAAAAAAAABWl0ZW1zAAAAAAAD6gAAB9AAAAAJQmF0Y2hJdGVtAAAAAAAAAAAAAAV0b3RhbAAAAAAAAAs=", + "AAAAAgAAAs9QZXItbWFya2V0IHJlc3VsdCBvZiBhIGJhdGNoIHNldHRsZW1lbnQuCgpUaGlzIHVuaW9uIHN0YW5kcyBpbiBmb3IgdGhlIGBPcHRpb248U2tpcFJlYXNvbj5gIHNrZXRjaGVkIGluIHRoZSBpc3N1ZToKYSBgI1tjb250cmFjdHR5cGVdYCBzdHJ1Y3QgZmllbGQgb2YgdHlwZSBgT3B0aW9uPEN1c3RvbVR5cGU+YCBkb2VzIG5vdApjb21waWxlIHVuZGVyIHRoZSBgdGVzdHV0aWxzYCBmZWF0dXJlIChvbiBmb3IgZXZlcnkgYGNhcmdvIHRlc3RgIGJ1aWxkKS4KVGhlIGRlcml2ZSBlbWl0cyBhbiB4ZHIgY29udmVyc2lvbiBnYXRlZCBvbiBgQ3VzdG9tVHlwZTogSW50bzxTY1ZhbD5gLApidXQgYCNbY29udHJhY3R0eXBlXWAgZW51bXMgb25seSBkZXJpdmUgYFRyeUZyb21gLCBhbmQgdGhlIG1pc3NpbmcgaW1wbHMKY2Fubm90IGJlIHN1cHBsaWVkIGZyb20gdGhpcyBjcmF0ZTogYEZyb208RW51bT4gZm9yIFNjVmFsYCBjb2xsaWRlcyB3aXRoCnRoZSBkZXJpdmUncyBvd24gYFRyeUZyb21gIHRocm91Z2ggY29yZSdzIGJsYW5rZXQgaW1wbCAoRTAxMTkpLCBhbmQKYFRyeUZyb208Jk9wdGlvbjxFbnVtPj4gZm9yIFNjVmFsYCB2aW9sYXRlcyB0aGUgb3JwaGFuIHJ1bGUgKEUwMTE3KS4KVGhlIHVuaW9uIGNhcnJpZXMgdGhlIHNhbWUgaW5mb3JtYXRpb24gYW5kIHN1cmZhY2VzIGFzIGEgZGlzY3JpbWluYXRlZAp1bmlvbiBpbiB0aGUgZ2VuZXJhdGVkIFRTIGJpbmRpbmdzLgAAAAAAAAAAC0JhdGNoU3RhdHVzAAAAAAIAAAAAAAAAMFRoZSBtYXJrZXQgc2V0dGxlZCBhbmQgcGFpZCBgQmF0Y2hJdGVtOjpwYXlvdXRgLgAAAAdTZXR0bGVkAAAAAAEAAAAxVGhlIG1hcmtldCB3YXMgc2tpcHBlZDsgYEJhdGNoSXRlbTo6cGF5b3V0YCBpcyAwLgAAAAAAAAdTa2lwcGVkAAAAAAEAAAfQAAAAClNraXBSZWFzb24AAA==", "AAAAAQAAAEZPbmUgcG9pbnQgaW4gdGhlIFRXQVAgcmluZyBidWZmZXIuIEF0IG1vc3Qgb25lIHBlciBkaXN0aW5jdCB0aW1lc3RhbXAuAAAAAAAAAAAAC09ic2VydmF0aW9uAAAAAAIAAAArYE9yYWNsZVN0YXRlOjpwcmljZV9jdW11bGF0aXZlYCBhcyBvZiBgdHNgLgAAAAAKY3VtdWxhdGl2ZQAAAAAACwAAAAAAAAACdHMAAAAAAAY=", "AAAAAQAAA8FQZXItbWFya2V0IFRXQVAgYWNjdW11bGF0b3IgKGlzc3VlICMxMSkuIExpdmVzIGluIGl0cyBvd24gZW50cnkgc28gdGhlCmBNYXJrZXRgIFhEUiBzdGF5cyB1bmNoYW5nZWQuCgpTZW1hbnRpY3M6IGF0IHRoZSBzdGFydCBvZiBldmVyeSB0cmFkZSB0aGUgcHJpY2UgdGhhdCBoZWxkIHNpbmNlIHRoZSBsYXN0CnRyYWRlIGlzIGZvbGRlZCBpbnRvIGBwcmljZV9jdW11bGF0aXZlYCBvdmVyIHRoZSBlbGFwc2VkIGludGVydmFsOyB0aGUKcG9zdC10cmFkZSBwcmljZSB0aGVuIGJlY29tZXMgYGxhc3RfcHJpY2VfYnBzYCB1bnRpbCB0aGUgbmV4dCB0cmFkZS4gVGhhdAppcyB0aGUgVW5pc3dhcC12MiBtb2RlbCwgYW5kIGl0IGlzIHdoYXQgbWFrZXMgbWFuaXB1bGF0aW9uIGV4cGVuc2l2ZSDigJQKbW92aW5nIHRoZSBhdmVyYWdlIHJlcXVpcmVzIGhvbGRpbmcgdGhlIHByaWNlIGF3YXkgZnJvbSBmYWlyIHZhbHVlIGFjcm9zcwpvbmUgb3IgbW9yZSBsZWRnZXIgYm91bmRhcmllcywgbm90IGp1c3Qgd2l0aGluIGEgc2luZ2xlIHRyYW5zYWN0aW9uLgoKT3ZlcmZsb3cgYW5hbHlzaXM6IGBsYXN0X3ByaWNlX2JwcyA8PSAxMF8wMDBgIGFuZCBlYWNoIGludGVydmFsIGFkZHMKYHByaWNlICogZHRgIHNlY29uZHMuIEEgMTAwLXllYXIgbWFya2V0IGxpZmV0aW1lIGFjY3VtdWxhdGVzIGF0IG1vc3QKYDEwXzAwMCAqIDMuMmU5IH4gMy4yZTEzYDsgZXZlbiBhIHBhdGhvbG9naWNhbCBgZHQgPSB1NjQ6Ok1BWGAgZ2l2ZXMKYDEuOGUxOSAqIDFlNCA9IDEuOGUyM2AuIGBpMTI4OjpNQVhgIGlzIH4xLjdlMzgsIHNvIHRoZXJlIGlzIG5vIGZlYXNpYmxlCmxpZmV0aW1lIHRoYXQgb3ZlcmZsb3dzIOKAlCBhbmQgZXZlcnkgc3RlcCBpcyBgY2hlY2tlZF8qYCBhbnl3YXksIHNvIGEKdmlvbGF0aW9uIHJlamVjdHMgdGhlIHRyYW5zYWN0aW9uIGluc3RlYWQgb2YgY29ycnVwdGluZyB0aGUgYWNjdW11bGF0b3IuAAAAAAAAAAAAAAtPcmFjbGVTdGF0ZQAAAAAFAAAALVlFUyBwcmljZSBpbiBicHMgYXMgb2YgdGhlIG1vc3QgcmVjZW50IHRyYWRlLgAAAAAAAA5sYXN0X3ByaWNlX2JwcwAAAAAABAAAAIZUaW1lc3RhbXAgdGhlIGFjY3VtdWxhdG9yIHdhcyBsYXN0IGFkdmFuY2VkIHRvICh0aGUgdGltZXN0YW1wIG9mIHRoZQptb3N0IHJlY2VudCB0cmFkZSkuIFRoZSBsYXN0IHRyYWRlZCBwcmljZSBob2xkcyBmcm9tIGhlcmUgb253YXJkLgAAAAAAB2xhc3RfdHMAAAAABgAAADVPYnNlcnZhdGlvbnMgd3JpdHRlbiBzbyBmYXIsIGNhcHBlZCBhdCBgT1JBQ0xFX1JJTkdgLgAAAAAAAAlvYnNfY291bnQAAAAAAAAEAAAAOVJpbmcgc2xvdCBvZiB0aGUgbmV3ZXN0IG9ic2VydmF0aW9uLCBpbiBgMC4uT1JBQ0xFX1JJTkdgLgAAAAAAAAlvYnNfaW5kZXgAAAAAAAAEAAAAQVJ1bm5pbmcgc3VtIG9mIGBsYXN0X3ByaWNlX2JwcyAqIHNlY29uZHNfZWxhcHNlZGAgc2luY2UgY3JlYXRpb24uAAAAAAAAEHByaWNlX2N1bXVsYXRpdmUAAAAL", "AAAAAQAAAGpQZXItbWFya2V0IHNldHRsZW1lbnQgYm9va2tlZXBpbmcgKGlzc3VlICMxNCkuIExpdmVzIGluIGl0cyBvd24gZW50cnkgc28KdGhlIGBNYXJrZXRgIFhEUiBzdGF5cyB1bmNoYW5nZWQuAAAAAAAAAAAAC1NldHRsZVN0YXRlAAAAAAYAAABqYGZpbmFsaXplX21hcmtldGAgaGFzIHN3ZXB0IHRoZSBtYXJrZXQncyByZW1haW5pbmcgYmFsYW5jZTsgbm8KcmVkZW1wdGlvbiBvciBjbGFpbSBjYW4gZXZlciBwYXkgb3V0IGFnYWluLgAAAAAABmNsb3NlZAAAAAAAAQAAAENUaW1lc3RhbXAgb2YgYHJlc29sdmVgIC8gYHZvaWRfbWFya2V0YDsgMCB3aGlsZSB0aGUgbWFya2V0IGlzIE9wZW4uAAAAAAxmaW5hbGl6ZWRfYXQAAAAGAAAAc1JlZGVtcHRpb24gd2luZG93LCBzbmFwc2hvdHRlZCBhdCBjcmVhdGlvbiBmcm9tIHRoZSBhZG1pbiBkZWZhdWx0LgpgcmVkZWVtX2RlYWRsaW5lID0gZmluYWxpemVkX2F0ICsgaG9yaXpvbl9zZWNzYC4AAAAADGhvcml6b25fc2VjcwAAAAYAAAAAAAAADm91dHN0YW5kaW5nX25vAAAAAAALAAAAw091dGNvbWUgc2hhcmVzIGhlbGQgb3V0c2lkZSB0aGUgcG9vbCAodXNlciBwb3NpdGlvbnMpLCBzdGlsbApyZWRlZW1hYmxlLiBgcmVkZWVtYCBkZWNyZW1lbnRzIHRoZSByZWRlZW1pbmcgaG9sZGVyJ3Mgc2hhcmVzIG9uIGJvdGgKc2lkZXMsIHNvIGEgbG9zaW5nLXNpZGUgcmVkZW1wdGlvbiAocGF5b3V0IDApIHN0aWxsIGNsZWFycyBoZXJlLgAAAAAPb3V0c3RhbmRpbmdfeWVzAAAAAAsAAAB7Q29sbGF0ZXJhbCBhbHJlYWR5IHBhaWQgdG8gaG9sZGVycyBhbmQgdGhlIExQIChgcmVkZWVtYCArCmBjbGFpbV9wb29sX3JlbWFpbmRlcmAgcGF5b3V0cyk7IHRyYWNrcyB3aGF0IGhhcyBsZWZ0IHRoZSBtYXJrZXQuAAAAAAhwYWlkX291dAAAAAs=", @@ -468,6 +555,7 @@ export class Client extends ContractClient { "AAAABQAAAAAAAAAAAAAADU1hcmtldENyZWF0ZWQAAAAAAAABAAAADm1hcmtldF9jcmVhdGVkAAAAAAAGAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAAAAAAAIY2F0ZWdvcnkAAAfQAAAACENhdGVnb3J5AAAAAAAAAAAAAAAKY2xvc2VfdGltZQAAAAAABgAAAAAAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAAAAAAA9pbml0aWFsX3llc19icHMAAAAABAAAAAAAAAAC", "AAAABQAAAAAAAAAAAAAADk1hcmtldFJlc29sdmVkAAAAAAABAAAAD21hcmtldF9yZXNvbHZlZAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAACFOb25lIG1lYW5zIHRoZSBtYXJrZXQgd2FzIHZvaWRlZC4AAAAAAAAHb3V0Y29tZQAAAAPoAAAH0AAAAAdPdXRjb21lAAAAAAAAAAAC", "AAAABQAAAAAAAAAAAAAAD01hcmtldEZpbmFsaXplZAAAAAABAAAAEG1hcmtldF9maW5hbGl6ZWQAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAHFFdmVyeXRoaW5nIHN3ZXB0IHRvIHRoZSB0cmVhc3VyeTogZXhwaXJlZCB1c2VyIGNsYWltcywgYW4gdW5jbGFpbWVkCkxQIHJlbWFpbmRlciwgcm91bmRpbmcgZHVzdCBhbmQgYWNjcnVlZCBmZWVzLgAAAAAAAAVzd2VwdAAAAAAAAAsAAAAAAAAAQFRoZSBwYXJ0IG9mIGBzd2VwdGAgdGhhdCB3YXMgcmVzZXJ2ZWQgZm9yIHNoYXJlcyBuZXZlciByZWRlZW1lZC4AAAAOdW5jbGFpbWVkX3VzZXIAAAAAAAsAAAAAAAAAQ1JvdW5kaW5nIGxlZnQgb3ZlciBhZnRlciBwZXItaG9sZGVyIHZvaWQgZmxvb3JzIHdlcmUgYWNjb3VudGVkIGZvci4AAAAABGR1c3QAAAALAAAAAAAAAAI=", + "AAAABQAAAAAAAAAAAAAAFFBvb2xSZW1haW5kZXJDbGFpbWVkAAAAAQAAABZwb29sX3JlbWFpbmRlcl9jbGFpbWVkAAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAQAAAAAAAAAGcGF5b3V0AAAAAAALAAAAAAAAAAI=", "AAAAAAAAAIFTcGVuZCBgY29sbGF0ZXJhbF9pbmAgdG8gYnV5IGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMgc2hhcmVzIHJlY2VpdmVkLgpSZXZlcnRzIGlmIGZld2VyIHRoYW4gYG1pbl9zaGFyZXNfb3V0YCB3b3VsZCBiZSByZWNlaXZlZC4AAAAAAAADYnV5AAAAAAUAAAAAAAAABnRyYWRlcgAAAAAAEwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA1jb2xsYXRlcmFsX2luAAAAAAAACwAAAAAAAAAObWluX3NoYXJlc19vdXQAAAAAAAsAAAABAAAD6QAAAAsAAAAD", "AAAAAAAAARBSZWNlaXZlIGV4YWN0bHkgYGNvbGxhdGVyYWxfb3V0YCBieSBzZWxsaW5nIGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMKc2hhcmVzIHNvbGQuIFJldmVydHMgaWYgbW9yZSB0aGFuIGBtYXhfc2hhcmVzX2luYCB3b3VsZCBiZSBuZWVkZWQuCkNsaWVudHMgdHVybiAic2VsbCBOIHNoYXJlcyIgaW50byBgY29sbGF0ZXJhbF9vdXRgIG9mZi1jaGFpbgooc2VlIGBtYXRoOjpjYWxjX3NlbGxgIGRvY3MpIGFuZCBhZGQgYSBzbGlwcGFnZSBtYXJnaW4gdG8gYG1heF9zaGFyZXNfaW5gLgAAAARzZWxsAAAABQAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADmNvbGxhdGVyYWxfb3V0AAAAAAALAAAAAAAAAA1tYXhfc2hhcmVzX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAUxUaW1lLXdlaWdodGVkIGF2ZXJhZ2UgWUVTIHByaWNlIGluIGJwcyBvdmVyIHRoZSB0cmFpbGluZwpgd2luZG93X3NlY3NgLCBmbG9vcmVkLiBSZWplY3RzIGEgemVybyB3aW5kb3cgKGBJbnZhbGlkQW1vdW50YCkuCkEgd2luZG93IHJlYWNoaW5nIGJlZm9yZSB0aGUgb2xkZXN0IG9ic2VydmF0aW9uIGZhaWxzIHdpdGgKYE9ic2VydmF0aW9uVG9vT2xkYDsgYSB3aW5kb3cgZXh0ZW5kaW5nIHBhc3QgYGNsb3NlX3RpbWVgIGtlZXBzCmF2ZXJhZ2luZyB0aGUgZmluYWwgcHJpY2UsIGJlY2F1c2UgdGhlIGFjY3VtdWxhdG9yIG9ubHkgbW92ZXMgd2hlbgp0cmFkZXMgYXJlIHBvc3NpYmxlLgAAAAR0d2FwAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAALd2luZG93X3NlY3MAAAAABgAAAAEAAAPpAAAABAAAAAM=", @@ -475,12 +563,14 @@ export class Client extends ContractClient { "AAAAAAAAAiRDdW11bGF0aXZlIFlFUy1wcmljZS1zZWNvbmRzIGFzIG9mIGBub3cgLSBzZWNvbmRzX2Fnb2AuCgpUaGUgYWNjdW11bGF0b3IgaXMgcGllY2V3aXNlIGxpbmVhciBpbiB0aW1lICh0aGUgcHJpY2Ugb25seSBtb3ZlcyB3aGVuCmEgdHJhZGUgbGFuZHMpLCBzbyBiZXR3ZWVuIG9ic2VydmF0aW9ucyB0aGUgdmFsdWUgaXMgaW50ZXJwb2xhdGVkCmFsb25nIHRoZSBjaG9yZCwgYW5kIHRpbWVzIGFmdGVyIHRoZSBsYXN0IHRyYWRlIGV4dHJhcG9sYXRlIGF0IHRoZQpsYXN0IHRyYWRlZCBwcmljZS4gRmFpbHMgd2l0aCBgT2JzZXJ2YXRpb25Ub29PbGRgIHdoZW4gYHNlY29uZHNfYWdvYApyZWFjaGVzIGJlZm9yZSB0aGUgb2xkZXN0IHN1cnZpdmluZyBvYnNlcnZhdGlvbiAodGhlIHJpbmcga2VlcHMgdGhlCm1vc3QgcmVjZW50IDE2KSwgYW5kIHdpdGggYE5vdFN1cHBvcnRlZGAgZm9yIG1hcmtldHMgY3JlYXRlZCBiZWZvcmUKdGhlIGFjY3VtdWxhdG9yIGV4aXN0ZWQuIEEgbWlzc2luZyAoYXJjaGl2ZWQpIHJpbmcgc2xvdCBpcyBza2lwcGVkLApuZXZlciBhIHBhbmljLgAAAAdvYnNlcnZlAAAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAAC3NlY29uZHNfYWdvAAAAAAYAAAABAAAD6QAAAAsAAAAD", "AAAAAAAAAAAAAAAHcmVzb2x2ZQAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAD9TcG90IHByaWNlIG9mIGBvdXRjb21lYCBpbiBicHMgKDAuLj0xMF8wMDApLiBZRVMgKyBOTyB+IDEwXzAwMC4AAAAACWdldF9wcmljZQAAAAAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAEAAAPpAAAABAAAAAM=", + "AAAAAAAAAMhMYXJnZXN0IGFjY2VwdGVkIGBtYXJrZXRfaWRzYCBsZW5ndGggZm9yIGByZWRlZW1fbWFueWAgLwpgY2xhaW1fcG9vbF9yZW1haW5kZXJzYC4gRnJvbnRlbmRzIHNob3VsZCBjaHVuayB3b3JrIGludG8gc3RyaWN0bHkKYXNjZW5kaW5nIGJhdGNoZXMgb2YgYXQgbW9zdCB0aGlzIHNpemUgKHNlZQpgZG9jcy9mcm9udGVuZC1pbnRlZ3JhdGlvbi5tZGApLgAAAAltYXhfYmF0Y2gAAAAAAAAAAAAAAQAAAAQ=", "AAAAAAAAADlTaGFyZXMgYSBgYnV5YCBvZiBgY29sbGF0ZXJhbF9pbmAgd291bGQgcmV0dXJuIHJpZ2h0IG5vdy4AAAAAAAAJcXVvdGVfYnV5AAAAAAAAAwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA1jb2xsYXRlcmFsX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAAAAAAAKZ2V0X2NvbmZpZwAAAAAAAAAAAAEAAAPpAAAH0AAAAAZDb25maWcAAAAAAAM=", "AAAAAAAAAAAAAAAKZ2V0X21hcmtldAAAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAH0AAAAAZNYXJrZXQAAAAAAAM=", "AAAAAAAAAEVTaGFyZXMgYSBgc2VsbGAgZm9yIGV4YWN0bHkgYGNvbGxhdGVyYWxfb3V0YCB3b3VsZCBjb25zdW1lIHJpZ2h0IG5vdy4AAAAAAAAKcXVvdGVfc2VsbAAAAAAAAwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA5jb2xsYXRlcmFsX291dAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAAAAAAAKc2V0X3BhdXNlZAAAAAAAAQAAAAAAAAAGcGF1c2VkAAAAAAABAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAC1BbnlvbmUgY2FuIG1vdmUgYWNjcnVlZCBmZWVzIHRvIHRoZSB0cmVhc3VyeS4AAAAAAAAKc3dlZXBfZmVlcwAAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAACwAAAAM=", + "AAAAAAAAAopSZWRlZW1zIGBob2xkZXJgJ3MgcG9zaXRpb25zIGFjcm9zcyB1cCB0byBgTUFYX0JBVENIYCBmaW5hbCBtYXJrZXRzCndpdGggb25lIGNvbGxhdGVyYWwgdHJhbnNmZXIuIGBtYXJrZXRfaWRzYCBtdXN0IGJlIHN0cmljdGx5IGFzY2VuZGluZwooYEludmFsaWRBbW91bnRgIG90aGVyd2lzZTsgZW1wdHkgb3Igb3Zlci1jYXAgaXMgYEJhdGNoVG9vTGFyZ2VgKSBzbwp0aGUgZm9vdHByaW50IGlzIGRldGVybWluaXN0aWMgYW5kIGR1cGxpY2F0ZXMgYXJlIGltcG9zc2libGUuCgpNYXJrZXRzIHRoYXQgY2Fubm90IHBheSBkbyBub3QgYWJvcnQgdGhlIGJhdGNoOyB0aGV5IGNvbWUgYmFjayBpbgpgaXRlbXNgIHdpdGggYSBgU2tpcFJlYXNvbmAuIEluIHBhcnRpY3VsYXIgYW4gYXJjaGl2ZWQgYE1hcmtldGAgZW50cnkKaXMgTk9UIGBOb3RGb3VuZGAg4oCUIHRoZSB0cmFuc2FjdGlvbiBmYWlscyBhdCBmb290cHJpbnQgdGltZSBhbmQgdGhlCm1hcmtldCBtdXN0IGJlIHJlc3RvcmVkIGZpcnN0IChzaW11bGF0aW9uIHNob3dzIHRoaXMgYmVmb3JlCnN1Ym1pc3Npb24pLiBgaG9sZGVyLnJlcXVpcmVfYXV0aCgpYCBjb3ZlcnMgdGhlIHdob2xlIGJhdGNoOiB0aGUKc2lnbmVkIHBheWxvYWQgaXMgdGhlIGBtYXJrZXRfaWRzYCB2ZWN0b3IgaXRzZWxmLgAAAAAAC3JlZGVlbV9tYW55AAAAAAIAAAAAAAAABmhvbGRlcgAAAAAAEwAAAAAAAAAKbWFya2V0X2lkcwAAAAAD6gAAAAYAAAABAAAD6QAAB9AAAAALQmF0Y2hSZXN1bHQAAAAAAw==", "AAAAAAAAAEFJbnZhbGlkIG9yIHVucmVzb2x2YWJsZSBtYXJrZXQuIEFsbG93ZWQgYW55IHRpbWUgYmVmb3JlIGZpbmFsaXR5LgAAAAAAAAt2b2lkX21hcmtldAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAAAAAAAMZ2V0X3Bvc2l0aW9uAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAGaG9sZGVyAAAAAAATAAAAAQAAB9AAAAAIUG9zaXRpb24=", "AAAAAAAAAAAAAAAMbWFya2V0X2NvdW50AAAAAAAAAAEAAAAG", @@ -494,6 +584,7 @@ export class Client extends ContractClient { "AAAAAAAAAL1DdW11bGF0aXZlIGBwcmljZV9icHMgKiBzZWNvbmRzYCBmb3IgWUVTIHNpbmNlIGNyZWF0aW9uLCBleHRyYXBvbGF0ZWQKdG8gbm93OiBgb2JzZXJ2ZShtYXJrZXRfaWQsIDApYC4gUmVhZHktbWFkZSBmb3IgY29uc3VtZXJzIHRoYXQgc3RvcmUKdGhlaXIgb3duIGNoZWNrcG9pbnRzIGFuZCBjb21wdXRlIFRXQVBzIG9mZi1jaGFpbi4AAAAAAAAQcHJpY2VfY3VtdWxhdGl2ZQAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", "AAAAAAAAAEhBZmZlY3RzIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbDsgZXhpc3RpbmcgbWFya2V0cyBrZWVwIHRoZWlyIGZlZS4AAAATc2V0X2RlZmF1bHRfZmVlX2JwcwAAAAABAAAAAAAAAAdmZWVfYnBzAAAAAAQAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAERDcmVhdG9yICh0aGUgTFApIHdpdGhkcmF3cyB3aGF0ZXZlciB0aGUgcG9vbCBzdGlsbCBob2xkcyBvbmNlIGZpbmFsLgAAABRjbGFpbV9wb29sX3JlbWFpbmRlcgAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", + "AAAAAAAAAQdDbGFpbXMgdGhlIExQIHBvb2wgcmVtYWluZGVyIGZvciB1cCB0byBgTUFYX0JBVENIYCBtYXJrZXRzIHRoZQpgY3JlYXRvcmAgb3ducywgd2l0aCBvbmUgY29sbGF0ZXJhbCB0cmFuc2Zlci4gU2FtZSBvcmRlcmluZyBhbmQgc2tpcApzZW1hbnRpY3MgYXMgYHJlZGVlbV9tYW55YC4gQSBtYXJrZXQgY2xvc2VkIGJ5IGBmaW5hbGl6ZV9tYXJrZXRgCnJlcG9ydHMgYEFscmVhZHlDbGFpbWVkYCDigJQgdGhlIHN3ZWVwIGFscmVhZHkgdG9vayB0aGUgcmVtYWluZGVyLgAAAAAVY2xhaW1fcG9vbF9yZW1haW5kZXJzAAAAAAAAAgAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAAptYXJrZXRfaWRzAAAAAAPqAAAABgAAAAEAAAPpAAAH0AAAAAtCYXRjaFJlc3VsdAAAAAAD", "AAAAAAAAAI5BZmZlY3RzIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbDsgZXhpc3RpbmcgbWFya2V0cyBrZWVwIHRoZQpob3Jpem9uIHNuYXBzaG90dGVkIGF0IHRoZWlyIGNyZWF0aW9uLiBNdXN0IGJlIHdpdGhpbgpbMTgwIGRheXMsIDE4MjUgZGF5c10uAAAAAAAac2V0X2RlZmF1bHRfcmVkZWVtX2hvcml6b24AAAAAAAEAAAAAAAAABHNlY3MAAAAGAAAAAQAAA+kAAAACAAAAAw==" ]), options ) @@ -506,12 +597,14 @@ export class Client extends ContractClient { observe: this.txFromJSON>, resolve: this.txFromJSON>, get_price: this.txFromJSON>, + max_batch: this.txFromJSON, quote_buy: this.txFromJSON>, get_config: this.txFromJSON>, get_market: this.txFromJSON>, quote_sell: this.txFromJSON>, set_paused: this.txFromJSON>, sweep_fees: this.txFromJSON>, + redeem_many: this.txFromJSON>, void_market: this.txFromJSON>, get_position: this.txFromJSON, market_count: this.txFromJSON, @@ -524,6 +617,7 @@ export class Client extends ContractClient { price_cumulative: this.txFromJSON>, set_default_fee_bps: this.txFromJSON>, claim_pool_remainder: this.txFromJSON>, + claim_pool_remainders: this.txFromJSON>, set_default_redeem_horizon: this.txFromJSON> } } \ No newline at end of file diff --git a/oracledesk-stellar/packages/bindings/treasury/src/index.ts b/oracledesk-stellar/packages/bindings/treasury/src/index.ts index d2e28e5..01eed46 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"}, @@ -69,10 +65,40 @@ 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 BatchItem { + market_id: u64; + payout: i128; + status: BatchStatus; +} + +export type SkipReason = {tag: "NotFound", values: void} | {tag: "NotFinal", values: void} | {tag: "NothingToRedeem", values: void} | {tag: "AlreadyClaimed", values: void} | {tag: "NotCreator", values: void} | {tag: "Duplicate", values: void}; + + +export interface BatchResult { + items: Array; + total: i128; +} + +export type BatchStatus = {tag: "Settled", values: void} | {tag: "Skipped", values: readonly [SkipReason]}; + 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. * Redeems the treasury's position in a resolved/voided market. + * + * Deadline (market-core issue #14): every market has a redemption + * window snapshotted at creation, and once `redeem_deadline(market_id)` + * passes anyone can `finalize_market`, which forfeits anything still + * unredeemed to the fee recipient. Nothing here tracks deadlines or + * auto-redeems, so the agent is responsible for calling this before the + * window expires. (This wrapper must exist: a contract address can only + * authorize itself from inside its own call stack, so nobody outside + * the treasury can redeem the treasury's position.) */ redeem: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> @@ -124,6 +150,19 @@ export interface Client { */ daily_usage: (options?: MethodOptions) => Promise>> + /** + * Construct and simulate a redeem_many 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. + * Redeems the treasury's positions in up to `MAX_BATCH` markets in one + * call (market-core issue #15). `market_ids` must be strictly ascending; + * markets that cannot be paid out right now are reported as skipped + * rather than aborting the whole batch, so one bad id does not strand + * the rest. The single aggregated token transfer at the end is why this + * exists: batching amortizes fixed per-call costs (footprint entries, + * event size), keeping the treasury inside the per-transaction resource + * limits even when it holds positions in many markets. + */ + redeem_many: ({market_ids}: {market_ids: Array}, 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. */ @@ -148,6 +187,15 @@ export interface Client { */ claim_pool_remainder: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a claim_pool_remainders 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 LP pool remainders of up to `MAX_BATCH` treasury-created + * markets in one call. Skips (never aborts on) markets the treasury did + * not create, markets that have not settled, and markets already + * claimed — see `redeem_many` for the batching rationale. + */ + claim_pool_remainders: ({market_ids}: {market_ids: Array}, options?: MethodOptions) => Promise>> + } export class Client extends ContractClient { static async deploy( @@ -168,13 +216,20 @@ export class Client extends ContractClient { } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec([ "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA", - "AAAAAgAAAAAAAAAAAAAACENhdGVnb3J5AAAABgAAAAAAAAAAAAAABkNyeXB0bwAAAAAAAAAAAAAAAAAFTWFjcm8AAAAAAAAAAAAAAAAAAAtHZW9wb2xpdGljcwAAAAAAAAAAAAAAAAZTcG9ydHMAAAAAAAAAAAAAAAAAB0N1bHR1cmUAAAAAAAAAAAAAAAAFT3RoZXIAAAA=", - "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAACgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAAA1JbnZhbGlkQW1vdW50AAAAAAAABAAAAAAAAAAQVHJhZGVDYXBFeGNlZWRlZAAAAAUAAAAAAAAAEU1hcmtldENhcEV4Y2VlZGVkAAAAAAAABgAAAAAAAAAQRGFpbHlDYXBFeGNlZWRlZAAAAAcAAAAAAAAADU1hcmtldE5vdE9wZW4AAAAAAAAIAAAAAAAAABNJbnN1ZmZpY2llbnRCYWxhbmNlAAAAAAkAAAAAAAAABlBhdXNlZAAAAAAACg==", - "AAAAAAAAADxSZWRlZW1zIHRoZSB0cmVhc3VyeSdzIHBvc2l0aW9uIGluIGEgcmVzb2x2ZWQvdm9pZGVkIG1hcmtldC4AAAAGcmVkZWVtAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", - "AAAAAAAAAAAAAAAHZGVwb3NpdAAAAAACAAAAAAAAAARmcm9tAAAAEwAAAAAAAAAGYW1vdW50AAAAAAALAAAAAQAAA+kAAAACAAAAAw==", + new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAACgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAAA1JbnZhbGlkQW1vdW50AAAAAAAABAAAAAAAAAAQVHJhZGVDYXBFeGNlZWRlZAAAAAUAAAAAAAAAEU1hcmtldENhcEV4Y2VlZGVkAAAAAAAABgAAAAAAAAAQRGFpbHlDYXBFeGNlZWRlZAAAAAcAAAAAAAAADU1hcmtldE5vdE9wZW4AAAAAAAAIAAAAAAAAABNJbnN1ZmZpY2llbnRCYWxhbmNlAAAAAAkAAAAAAAAABlBhdXNlZAAAAAAACg==", "AAAAAQAAAAAAAAAAAAAACkRhaWx5VXNhZ2UAAAAAAAIAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAJZGF5X3N0YXJ0AAAAAAAABg==", "AAAAAQAAAAAAAAAAAAAAClJpc2tDb25maWcAAAAAAAYAAAAAAAAABWFnZW50AAAAAAAAEwAAAAAAAAAKY29sbGF0ZXJhbAAAAAAAEwAAAAAAAAALbWFya2V0X2NvcmUAAAAAEwAAAAAAAAAJbWF4X2RhaWx5AAAAAAAACwAAAAAAAAAKbWF4X21hcmtldAAAAAAACwAAAAAAAAAJbWF4X3RyYWRlAAAAAAAACw==", + "AAAABQAAAAAAAAAAAAAADUZlZXNDb2xsZWN0ZWQAAAAAAAABAAAADmZlZXNfY29sbGVjdGVkAAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", + "AAAABQAAAAAAAAAAAAAADlBvc2l0aW9uQ2xvc2VkAAAAAAABAAAAD3Bvc2l0aW9uX2Nsb3NlZAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", + "AAAABQAAAAAAAAAAAAAAD1RyYWRlQXV0aG9yaXplZAAAAAABAAAAEHRyYWRlX2F1dGhvcml6ZWQAAAAFAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAFYWdlbnQAAAAAAAATAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAAAAAAMbWFya2V0X3RvdGFsAAAACwAAAAAAAAAAAAAAC2RhaWx5X3RvdGFsAAAAAAsAAAAAAAAAAg==", + "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA", + "AAAAAgAAAAAAAAAAAAAACENhdGVnb3J5AAAABgAAAAAAAAAAAAAABkNyeXB0bwAAAAAAAAAAAAAAAAAFTWFjcm8AAAAAAAAAAAAAAAAAAAtHZW9wb2xpdGljcwAAAAAAAAAAAAAAAAZTcG9ydHMAAAAAAAAAAAAAAAAAB0N1bHR1cmUAAAAAAAAAAAAAAAAFT3RoZXIAAAA=", + "AAAAAQAAAAAAAAAAAAAACUJhdGNoSXRlbQAAAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABnBheW91dAAAAAAACwAAAAAAAAAGc3RhdHVzAAAAAAfQAAAAC0JhdGNoU3RhdHVzAA==", + "AAAAAgAAAAAAAAAAAAAAClNraXBSZWFzb24AAAAAAAYAAAAAAAAAAAAAAAhOb3RGb3VuZAAAAAAAAAAAAAAACE5vdEZpbmFsAAAAAAAAAAAAAAAPTm90aGluZ1RvUmVkZWVtAAAAAAAAAAAAAAAADkFscmVhZHlDbGFpbWVkAAAAAAAAAAAAAAAAAApOb3RDcmVhdG9yAAAAAAAAAAAAAAAAAAlEdXBsaWNhdGUAAAA=", + "AAAAAQAAAAAAAAAAAAAAC0JhdGNoUmVzdWx0AAAAAAIAAAAAAAAABWl0ZW1zAAAAAAAD6gAAB9AAAAAJQmF0Y2hJdGVtAAAAAAAAAAAAAAV0b3RhbAAAAAAAAAs=", + "AAAAAgAAAAAAAAAAAAAAC0JhdGNoU3RhdHVzAAAAAAIAAAAAAAAAAAAAAAdTZXR0bGVkAAAAAAEAAAAAAAAAB1NraXBwZWQAAAAAAQAAB9AAAAAKU2tpcFJlYXNvbgAA", + "AAAAAAAAAklSZWRlZW1zIHRoZSB0cmVhc3VyeSdzIHBvc2l0aW9uIGluIGEgcmVzb2x2ZWQvdm9pZGVkIG1hcmtldC4KCkRlYWRsaW5lIChtYXJrZXQtY29yZSBpc3N1ZSAjMTQpOiBldmVyeSBtYXJrZXQgaGFzIGEgcmVkZW1wdGlvbgp3aW5kb3cgc25hcHNob3R0ZWQgYXQgY3JlYXRpb24sIGFuZCBvbmNlIGByZWRlZW1fZGVhZGxpbmUobWFya2V0X2lkKWAKcGFzc2VzIGFueW9uZSBjYW4gYGZpbmFsaXplX21hcmtldGAsIHdoaWNoIGZvcmZlaXRzIGFueXRoaW5nIHN0aWxsCnVucmVkZWVtZWQgdG8gdGhlIGZlZSByZWNpcGllbnQuIE5vdGhpbmcgaGVyZSB0cmFja3MgZGVhZGxpbmVzIG9yCmF1dG8tcmVkZWVtcywgc28gdGhlIGFnZW50IGlzIHJlc3BvbnNpYmxlIGZvciBjYWxsaW5nIHRoaXMgYmVmb3JlIHRoZQp3aW5kb3cgZXhwaXJlcy4gKFRoaXMgd3JhcHBlciBtdXN0IGV4aXN0OiBhIGNvbnRyYWN0IGFkZHJlc3MgY2FuIG9ubHkKYXV0aG9yaXplIGl0c2VsZiBmcm9tIGluc2lkZSBpdHMgb3duIGNhbGwgc3RhY2ssIHNvIG5vYm9keSBvdXRzaWRlCnRoZSB0cmVhc3VyeSBjYW4gcmVkZWVtIHRoZSB0cmVhc3VyeSdzIHBvc2l0aW9uLikAAAAAAAAGcmVkZWVtAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", + "AAAAAAAAAAAAAAAHZGVwb3NpdAAAAAACAAAAAAAAAARmcm9tAAAAEwAAAAAAAAAGYW1vdW50AAAAAAALAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAAAAAAAId2l0aGRyYXcAAAACAAAAAAAAAAJ0bwAAAAAAEwAAAAAAAAAGYW1vdW50AAAAAAALAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAENCdXlzIGBvdXRjb21lYCBzaGFyZXMgaW4gYW4gZXhpc3RpbmcgbWFya2V0IHVzaW5nIHRyZWFzdXJ5IGNhcGl0YWwuAAAAAAlhZ2VudF9idXkAAAAAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADWNvbGxhdGVyYWxfaW4AAAAAAAALAAAAAAAAAA5taW5fc2hhcmVzX291dAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAPVTZWxscyBgb3V0Y29tZWAgc2hhcmVzIGJhY2sgaW50byB0aGUgcG9vbCBmb3IgZXhhY3RseQpgY29sbGF0ZXJhbF9vdXRgLiBUaGlzIHJldHVybnMgY2FwaXRhbCB0byB0aGUgdHJlYXN1cnkgKG1hcmtldC1jb3JlCnBheXMgb3V0IG9mIGl0cyBvd24gYmFsYW5jZSksIHNvIGl0IGlzIG5vdCBjYXBwZWQgYW5kIGRvZXMgbm90IG5lZWQKdGhlIG5lc3RlZC10cmFuc2ZlciBwcmUtYXV0aG9yaXphdGlvbiB0aGF0IGJ1eWluZyBkb2VzLgAAAAAAAAphZ2VudF9zZWxsAAAAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADmNvbGxhdGVyYWxfb3V0AAAAAAALAAAAAAAAAA1tYXhfc2hhcmVzX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", @@ -182,14 +237,13 @@ export class Client extends ContractClient { "AAAAAAAAAAAAAAAKc2V0X3BhdXNlZAAAAAAAAQAAAAAAAAAGcGF1c2VkAAAAAAABAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAK1NYXJrcyBhIG1hcmtldCdzIGV4cG9zdXJlIGFzIGV4aXRlZC4gUHVyZSBib29ra2VlcGluZyAoc2VlCmBNYXJrZXRFeHBvc3VyZWApIGZvciB0aGUgZnV0dXJlIGV4cG9zdXJlIGRhc2hib2FyZDsgaXQgZG9lcyBub3QgbW92ZQpmdW5kcyBvciBmcmVlIHRoZSBjdW11bGF0aXZlIHBlci1tYXJrZXQgY2FwLgAAAAAAAAtjbG9zZV90cmFkZQAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAAAAAAALZGFpbHlfdXNhZ2UAAAAAAAAAAAEAAAPpAAAH0AAAAApEYWlseVVzYWdlAAAAAAAD", - "AAAABQAAAAAAAAAAAAAADUZlZXNDb2xsZWN0ZWQAAAAAAAABAAAADmZlZXNfY29sbGVjdGVkAAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", - "AAAABQAAAAAAAAAAAAAADlBvc2l0aW9uQ2xvc2VkAAAAAAABAAAAD3Bvc2l0aW9uX2Nsb3NlZAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", + "AAAAAAAAAhZSZWRlZW1zIHRoZSB0cmVhc3VyeSdzIHBvc2l0aW9ucyBpbiB1cCB0byBgTUFYX0JBVENIYCBtYXJrZXRzIGluIG9uZQpjYWxsIChtYXJrZXQtY29yZSBpc3N1ZSAjMTUpLiBgbWFya2V0X2lkc2AgbXVzdCBiZSBzdHJpY3RseSBhc2NlbmRpbmc7Cm1hcmtldHMgdGhhdCBjYW5ub3QgYmUgcGFpZCBvdXQgcmlnaHQgbm93IGFyZSByZXBvcnRlZCBhcyBza2lwcGVkCnJhdGhlciB0aGFuIGFib3J0aW5nIHRoZSB3aG9sZSBiYXRjaCwgc28gb25lIGJhZCBpZCBkb2VzIG5vdCBzdHJhbmQKdGhlIHJlc3QuIFRoZSBzaW5nbGUgYWdncmVnYXRlZCB0b2tlbiB0cmFuc2ZlciBhdCB0aGUgZW5kIGlzIHdoeSB0aGlzCmV4aXN0czogYmF0Y2hpbmcgYW1vcnRpemVzIGZpeGVkIHBlci1jYWxsIGNvc3RzIChmb290cHJpbnQgZW50cmllcywKZXZlbnQgc2l6ZSksIGtlZXBpbmcgdGhlIHRyZWFzdXJ5IGluc2lkZSB0aGUgcGVyLXRyYW5zYWN0aW9uIHJlc291cmNlCmxpbWl0cyBldmVuIHdoZW4gaXQgaG9sZHMgcG9zaXRpb25zIGluIG1hbnkgbWFya2V0cy4AAAAAAAtyZWRlZW1fbWFueQAAAAABAAAAAAAAAAptYXJrZXRfaWRzAAAAAAPqAAAABgAAAAEAAAPpAAAH0AAAAAtCYXRjaFJlc3VsdAAAAAAD", "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAcAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAFYWdlbnQAAAAAAAATAAAAAAAAAApjb2xsYXRlcmFsAAAAAAATAAAAAAAAAAttYXJrZXRfY29yZQAAAAATAAAAAAAAAAltYXhfdHJhZGUAAAAAAAALAAAAAAAAAAptYXhfbWFya2V0AAAAAAALAAAAAAAAAAltYXhfZGFpbHkAAAAAAAALAAAAAA==", - "AAAABQAAAAAAAAAAAAAAD1RyYWRlQXV0aG9yaXplZAAAAAABAAAAEHRyYWRlX2F1dGhvcml6ZWQAAAAFAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAFYWdlbnQAAAAAAAATAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAAAAAAMbWFya2V0X3RvdGFsAAAACwAAAAAAAAAAAAAAC2RhaWx5X3RvdGFsAAAAAAsAAAAAAAAAAg==", "AAAAAAAAAAAAAAARYXZhaWxhYmxlX2NhcGl0YWwAAAAAAAAAAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAGBDcmVhdGVzIGFuZCBzZWVkcyBhIG1hcmtldCB1c2luZyB0cmVhc3VyeSBjYXBpdGFsLiBUaGUgdHJlYXN1cnkKYmVjb21lcyB0aGUgbWFya2V0J3MgY3JlYXRvci9MUC4AAAATYWdlbnRfY3JlYXRlX21hcmtldAAAAAAHAAAAAAAAAA1xdWVzdGlvbl9oYXNoAAAAAAAD7gAAACAAAAAAAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAAAAAAAIbWV0YV91cmkAAAAQAAAAAAAAAAhjYXRlZ29yeQAAB9AAAAAIQ2F0ZWdvcnkAAAAAAAAACmNsb3NlX3RpbWUAAAAAAAYAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAD2luaXRpYWxfeWVzX2JwcwAAAAAEAAAAAQAAA+kAAAAGAAAAAw==", "AAAAAAAAADpQdWxscyBhY2NydWVkIGZlZXMgZnJvbSBhIGNvbmZpZ3VyZWQgbWFya2V0LWNvcmUgY29udHJhY3QuAAAAAAATY29sbGVjdF9tYXJrZXRfZmVlcwAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", - "AAAAAAAAAD9DbGFpbXMgdGhlIExQIHBvb2wgcmVtYWluZGVyIGZvciBhIG1hcmtldCB0aGUgdHJlYXN1cnkgY3JlYXRlZC4AAAAAFGNsYWltX3Bvb2xfcmVtYWluZGVyAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAACwAAAAM=" ]), + "AAAAAAAAAD9DbGFpbXMgdGhlIExQIHBvb2wgcmVtYWluZGVyIGZvciBhIG1hcmtldCB0aGUgdHJlYXN1cnkgY3JlYXRlZC4AAAAAFGNsYWltX3Bvb2xfcmVtYWluZGVyAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAACwAAAAM=", + "AAAAAAAAAQJDbGFpbXMgdGhlIExQIHBvb2wgcmVtYWluZGVycyBvZiB1cCB0byBgTUFYX0JBVENIYCB0cmVhc3VyeS1jcmVhdGVkCm1hcmtldHMgaW4gb25lIGNhbGwuIFNraXBzIChuZXZlciBhYm9ydHMgb24pIG1hcmtldHMgdGhlIHRyZWFzdXJ5IGRpZApub3QgY3JlYXRlLCBtYXJrZXRzIHRoYXQgaGF2ZSBub3Qgc2V0dGxlZCwgYW5kIG1hcmtldHMgYWxyZWFkeQpjbGFpbWVkIOKAlCBzZWUgYHJlZGVlbV9tYW55YCBmb3IgdGhlIGJhdGNoaW5nIHJhdGlvbmFsZS4AAAAAABVjbGFpbV9wb29sX3JlbWFpbmRlcnMAAAAAAAABAAAAAAAAAAptYXJrZXRfaWRzAAAAAAPqAAAABgAAAAEAAAPpAAAH0AAAAAtCYXRjaFJlc3VsdAAAAAAD" ]), options ) } @@ -203,9 +257,11 @@ export class Client extends ContractClient { set_paused: this.txFromJSON>, close_trade: this.txFromJSON>, daily_usage: this.txFromJSON>, + redeem_many: this.txFromJSON>, available_capital: this.txFromJSON>, agent_create_market: this.txFromJSON>, collect_market_fees: this.txFromJSON>, - claim_pool_remainder: this.txFromJSON> + claim_pool_remainder: this.txFromJSON>, + claim_pool_remainders: this.txFromJSON> } } \ No newline at end of file From 460c22a6fe1655abd7df9eb97040e50072795af1 Mon Sep 17 00:00:00 2001 From: Teescom Date: Fri, 16 Oct 2026 17:10:53 +0100 Subject: [PATCH 5/8] test: assert AlreadyClaimed is skipped when a claim batch is replayed (#15) --- .../contracts/market-core/src/lib.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index d80a566..a6dcfeb 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -3226,6 +3226,21 @@ mod test { assert_eq!(token.balance(&creator), before + res.total); assert!(client.get_market(&m_mine).lp_claimed); assert!(!client.get_market(&m_other).lp_claimed); + + // Re-running the same batch is safe: the claimed market reports + // AlreadyClaimed, the stranger's still NotCreator, and no further + // transfer happens. + let again = client.claim_pool_remainders(&creator, &ids_vec(&env, &[m_mine, m_other])); + assert_eq!( + again.items.get(0).unwrap().status, + BatchStatus::Skipped(SkipReason::AlreadyClaimed) + ); + assert_eq!( + again.items.get(1).unwrap().status, + BatchStatus::Skipped(SkipReason::NotCreator) + ); + assert_eq!(again.total, 0); + assert_eq!(token.balance(&creator), before + expected_payout); } #[test] From 56bbf2615ed854e22cfb665b1d60db6c959b5f96 Mon Sep 17 00:00:00 2001 From: Teescom Date: Fri, 16 Oct 2026 17:21:44 +0100 Subject: [PATCH 6/8] chore: keep packages/bindings/market-core/package-lock.json at develop's Regenerating the bindings npm-installs the package, which churned the lockfile's root name (@oracledesk/bindings-market-core vs the stale market-core) and bumped three transitive versions off registry drift. None of that is required by the interface change, so the lockfile goes back to develop's exact bytes; only src/index.ts carries the regen. --- .../bindings/market-core/package-lock.json | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/oracledesk-stellar/packages/bindings/market-core/package-lock.json b/oracledesk-stellar/packages/bindings/market-core/package-lock.json index 9f4f26e..098fe8a 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": "@oracledesk/bindings-market-core", + "name": "market-core", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@oracledesk/bindings-market-core", + "name": "market-core", "version": "0.0.0", "dependencies": { "@stellar/stellar-sdk": "^16.0.1", @@ -47,9 +47,9 @@ } }, "node_modules/@stellar/stellar-sdk": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.1.tgz", - "integrity": "sha512-PPM1JA51NChSPyMkh+T9sHxJtP/v9e9Gcyc2BMNvnGvbZwqoKJclRSdPgmalLDAmTnTewGs7VMM9nRxDeCzzgQ==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.0.tgz", + "integrity": "sha512-pnB1t4bdnkN8M7tRIcLXnNboq+Hof62NzPMnQrl5MWBNC/d1vUbdrzXgy7yw8I+0hbfz5YN64uVb84BqzIpYiQ==", "license": "Apache-2.0", "dependencies": { "@noble/ed25519": "^3.1.0", @@ -311,9 +311,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", - "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -534,9 +534,9 @@ } }, "node_modules/smol-toml": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", - "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -560,9 +560,9 @@ } }, "node_modules/uint8array-extras": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", - "integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", "license": "MIT", "engines": { "node": ">=18" From ff7724b3c9b9a65dcc94d40063ad2699168efa64 Mon Sep 17 00:00:00 2001 From: Teescom Date: Fri, 16 Oct 2026 17:23:27 +0100 Subject: [PATCH 7/8] chore: regenerate resolver and reasoning-registry bindings (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #16's TTL work touched doc comments on contractimpl methods, and those comments are part of the contract spec — so freshly generated bindings for these two packages carry new doc text (and the spec-entry order the pinned stellar CLI 27.1.0 emits). market-core and treasury were already regenerated with the same CLI earlier in this branch. Found by running scripts/gen-bindings.sh (the command the nested CI job runs) against the branch's built wasm. --- .../bindings/reasoning-registry/src/index.ts | 13 ++++++++--- .../packages/bindings/resolver/src/index.ts | 23 +++++++++++-------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/oracledesk-stellar/packages/bindings/reasoning-registry/src/index.ts b/oracledesk-stellar/packages/bindings/reasoning-registry/src/index.ts index 3c0f1d2..699a790 100644 --- a/oracledesk-stellar/packages/bindings/reasoning-registry/src/index.ts +++ b/oracledesk-stellar/packages/bindings/reasoning-registry/src/index.ts @@ -60,11 +60,16 @@ export type DataKey = {tag: "Admin", values: void} | {tag: "NextId", values: voi export interface Client { /** * Construct and simulate a get_trace 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. + * Extends `Trace` on read, as before (a write in a view: simulation + * footprints are read-write, kept for compatibility and noted in + * `docs/architecture.md`). */ get_trace: ({trace_id}: {trace_id: u64}, options?: MethodOptions) => Promise>> /** * Construct and simulate a trace_count 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. + * Reads the instance's `NextId`; the bump keeps the instance (and with + * it the whole contract) alive under view-only traffic. */ trace_count: (options?: MethodOptions) => Promise> @@ -75,6 +80,8 @@ export interface Client { /** * Construct and simulate a is_registered 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. + * Read-only view: no TTL writes, so an indexer's simulation stays cheap. + * The `Agent` entry is refreshed by `register_agent`/`publish_trace`. */ is_registered: ({agent}: {agent: string}, options?: MethodOptions) => Promise> @@ -130,11 +137,11 @@ export class Client extends ContractClient { "AAAAAgAAAAAAAAAAAAAAB0RhdGFLZXkAAAAABgAAAAAAAAAAAAAABUFkbWluAAAAAAAAAAAAAAAAAAAGTmV4dElkAAAAAAABAAAAAAAAAAVBZ2VudAAAAAAAAAEAAAATAAAAAQAAAAAAAAAFVHJhY2UAAAAAAAABAAAABgAAAAEAAAC+TnVtYmVyIG9mIHRyYWNlcyBwdWJsaXNoZWQgZm9yIGEgbWFya2V0LCBzbyBhIGNsaWVudCBjYW4gcGFnZSB0aHJvdWdoCmBNYXJrZXRUcmFjZShtYXJrZXRfaWQsIDAuLmNvdW50KWAgd2l0aG91dCB0aGUgY29udHJhY3QgZXZlciBob2xkaW5nCmFuIHVuYm91bmRlZCBWZWMgb2YgdHJhY2UgaWRzIGluIG9uZSBzdG9yYWdlIGVudHJ5LgAAAAAAEE1hcmtldFRyYWNlQ291bnQAAAABAAAABgAAAAEAAAAAAAAAC01hcmtldFRyYWNlAAAAAAIAAAAGAAAABg==", "AAAABQAAAAAAAAAAAAAADlRyYWNlUHVibGlzaGVkAAAAAAABAAAAD3RyYWNlX3B1Ymxpc2hlZAAAAAAHAAAAAAAAAAh0cmFjZV9pZAAAAAYAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAFYWdlbnQAAAAAAAATAAAAAQAAAAAAAAAGYWN0aW9uAAAAAAAQAAAAAAAAAAAAAAAKdHJhY2VfaGFzaAAAAAAD7gAAACAAAAAAAAAAAAAAAAhpcGZzX2NpZAAAABAAAAAAAAAAAAAAAAxwdWJsaXNoZWRfYXQAAAAGAAAAAAAAAAI=", "AAAABQAAAAAAAAAAAAAAD0FnZW50UmVnaXN0ZXJlZAAAAAABAAAAEGFnZW50X3JlZ2lzdGVyZWQAAAABAAAAAAAAAAVhZ2VudAAAAAAAABMAAAABAAAAAg==", - "AAAAAAAAAAAAAAAJZ2V0X3RyYWNlAAAAAAAAAQAAAAAAAAAIdHJhY2VfaWQAAAAGAAAAAQAAA+kAAAfQAAAABVRyYWNlAAAAAAAAAw==", - "AAAAAAAAAAAAAAALdHJhY2VfY291bnQAAAAAAAAAAAEAAAAG", + "AAAAAAAAAJlFeHRlbmRzIGBUcmFjZWAgb24gcmVhZCwgYXMgYmVmb3JlIChhIHdyaXRlIGluIGEgdmlldzogc2ltdWxhdGlvbgpmb290cHJpbnRzIGFyZSByZWFkLXdyaXRlLCBrZXB0IGZvciBjb21wYXRpYmlsaXR5IGFuZCBub3RlZCBpbgpgZG9jcy9hcmNoaXRlY3R1cmUubWRgKS4AAAAAAAAJZ2V0X3RyYWNlAAAAAAAAAQAAAAAAAAAIdHJhY2VfaWQAAAAGAAAAAQAAA+kAAAfQAAAABVRyYWNlAAAAAAAAAw==", + "AAAAAAAAAHpSZWFkcyB0aGUgaW5zdGFuY2UncyBgTmV4dElkYDsgdGhlIGJ1bXAga2VlcHMgdGhlIGluc3RhbmNlIChhbmQgd2l0aAppdCB0aGUgd2hvbGUgY29udHJhY3QpIGFsaXZlIHVuZGVyIHZpZXctb25seSB0cmFmZmljLgAAAAAAC3RyYWNlX2NvdW50AAAAAAAAAAABAAAABg==", "AAAAAAAAAAAAAAAMdmVyaWZ5X3RyYWNlAAAAAgAAAAAAAAAIdHJhY2VfaWQAAAAGAAAAAAAAAA1yZWNlaXZlZF9oYXNoAAAAAAAD7gAAACAAAAABAAAD6QAAAAEAAAAD", "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAEAAAAAAAAABWFkbWluAAAAAAAAEwAAAAA=", - "AAAAAAAAAAAAAAANaXNfcmVnaXN0ZXJlZAAAAAAAAAEAAAAAAAAABWFnZW50AAAAAAAAEwAAAAEAAAAB", + "AAAAAAAAAIpSZWFkLW9ubHkgdmlldzogbm8gVFRMIHdyaXRlcywgc28gYW4gaW5kZXhlcidzIHNpbXVsYXRpb24gc3RheXMgY2hlYXAuClRoZSBgQWdlbnRgIGVudHJ5IGlzIHJlZnJlc2hlZCBieSBgcmVnaXN0ZXJfYWdlbnRgL2BwdWJsaXNoX3RyYWNlYC4AAAAAAA1pc19yZWdpc3RlcmVkAAAAAAAAAQAAAAAAAAAFYWdlbnQAAAAAAAATAAAAAQAAAAE=", "AAAAAAAAAAAAAAANcHVibGlzaF90cmFjZQAAAAAAAAUAAAAAAAAABWFnZW50AAAAAAAAEwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAGYWN0aW9uAAAAAAAQAAAAAAAAAAp0cmFjZV9oYXNoAAAAAAPuAAAAIAAAAAAAAAAIaXBmc19jaWQAAAAQAAAAAQAAA+kAAAAGAAAAAw==", "AAAAAAAAAAAAAAAOcmVnaXN0ZXJfYWdlbnQAAAAAAAEAAAAAAAAABWFnZW50AAAAAAAAEwAAAAEAAAPpAAAAAgAAAAM=", "AAAAAAAAAEFUaGUgYGluZGV4YC10aCAoMC1iYXNlZCwgcHVibGlzaCBvcmRlcikgdHJhY2UgaWQgZm9yIGBtYXJrZXRfaWRgLgAAAAAAAA9tYXJrZXRfdHJhY2VfYXQAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAFaW5kZXgAAAAAAAAGAAAAAQAAA+kAAAAGAAAAAw==", diff --git a/oracledesk-stellar/packages/bindings/resolver/src/index.ts b/oracledesk-stellar/packages/bindings/resolver/src/index.ts index f62f4b4..595cdb5 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,9 +79,14 @@ 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. + * Pure view: no TTL extensions. The instance and the market's entries + * are kept alive by the mutating entrypoints above, so a view-only + * simulation stays read-only. */ state: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise> @@ -154,22 +157,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", + "AAAAAAAAAKBQdXJlIHZpZXc6IG5vIFRUTCBleHRlbnNpb25zLiBUaGUgaW5zdGFuY2UgYW5kIHRoZSBtYXJrZXQncyBlbnRyaWVzCmFyZSBrZXB0IGFsaXZlIGJ5IHRoZSBtdXRhdGluZyBlbnRyeXBvaW50cyBhYm92ZSwgc28gYSB2aWV3LW9ubHkKc2ltdWxhdGlvbiBzdGF5cyByZWFkLW9ubHkuAAAABXN0YXRlAAAAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAfQAAAAD1Jlc29sdXRpb25TdGF0ZQA=", + "AAAAAAAAAAAAAAAGYXR0ZXN0AAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAZzaWduZXIAAAAAABMAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAEAAAPpAAAAAgAAAAM=", + "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAIAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAALbWFya2V0X2NvcmUAAAAAEwAAAAA=", + "AAAAAAAAAAAAAAANcmVzb2x2ZV9wcmljZQAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD", + "AAAAAAAAAMVHdWFyZGlhbiBhY3Rpb246IHdpdGhkcmF3cyBhbiB1bmZpbmFsaXplZCBzaWduZXIgcHJvcG9zYWwsIGUuZy4gYmVjYXVzZQpldmlkZW5jZSBzdXJmYWNlZCBkdXJpbmcgdGhlIGRpc3B1dGUgd2luZG93IHRoYXQgaXQgaXMgd3JvbmcuIFZvdGluZwpjYW4gcmVzdGFydCBvbmNlIHRoZSBjb3VudHMgYXJlIGNsZWFyZWQgYnkgcmUtYXR0ZXN0aW5nLgAAAAAAAA9jYW5jZWxfcHJvcG9zYWwAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAAAgAAAAM=", + "AAAAAAAAAAAAAAAQZmluYWxpemVfc2lnbmVycwAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD", "AAAAAAAAATpSZXZlYWxzIHRoZSByZXNvbHV0aW9uIHJ1bGUgY29tbWl0dGVkIHRvIGF0IG1hcmtldCBjcmVhdGlvbi4gQW55b25lCm1heSBjYWxsIHRoaXMgKHRoZXJlIGlzIG5vIGFkbWluIGdhdGUpIGJlY2F1c2UgdGhlIG9ubHkgdGhpbmcgdGhhdAptYWtlcyBhIHNwZWMgYWNjZXB0YWJsZSBpcyB0aGF0IGl0IGhhc2hlcyB0byBgbWFya2V0LnJlc29sdXRpb25faGFzaGAK4oCUIHRoZSBjb21taXRtZW50LCBub3QgdGhlIGNhbGxlciwgaXMgdGhlIGF1dGhvcml0eS4gRmFpbHMgaWYgYSBzcGVjCndhcyBhbHJlYWR5IHJlZ2lzdGVyZWQgZm9yIHRoaXMgbWFya2V0LgAAAAAAE3JlZ2lzdGVyX3ByaWNlX3NwZWMAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAEc3BlYwAAB9AAAAALUHJpY2VDb25maWcAAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAMJHdWFyZGlhbiBhY3Rpb246IHZvaWRzIGFuIHVucmVzb2x2YWJsZSBtYXJrZXQgZGlyZWN0bHkgdGhyb3VnaAptYXJrZXQtY29yZS4gVGhlIHJlc29sdmVyIGlzIHRoZSBkaXJlY3QgY2FsbGVyLCBzbyBtYXJrZXQtY29yZSdzCmByZXF1aXJlX3Jlc29sdmVyYCBjaGVjayBwYXNzZXMgdGhlIHNhbWUgd2F5IGByZXNvbHZlX3ByaWNlYCBkb2VzLgAAAAAAFGd1YXJkaWFuX3ZvaWRfbWFya2V0AAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAAAgAAAAM=", "AAAAAAAAAENTZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgc2lnbmVyIG1vZGUuAAAAABRyZWdpc3Rlcl9zaWduZXJfc3BlYwAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABHNwZWMAAAfQAAAADFNpZ25lckNvbmZpZwAAAAEAAAPpAAAAAgAAAAM=" ]), From 04f9845e5a83b6c67085f44c793f519c16b79477 Mon Sep 17 00:00:00 2001 From: Teescom Date: Wed, 21 Oct 2026 20:18:54 +0100 Subject: [PATCH 8/8] style: cargo fmt the contract files this branch touches Six of the twenty-one rustfmt hunks are this branch's own code (two in market-core, four in resolver); the remaining fifteen are drift already on develop in resolver/treasury, which rustfmt normalizes because the gate is per-file. reasoning-registry is excluded: it does not parse on develop. --- .../contracts/market-core/src/lib.rs | 4 +- .../contracts/resolver/src/lib.rs | 94 +++++++++++-------- .../contracts/treasury/src/lib.rs | 27 +++--- 3 files changed, 72 insertions(+), 53 deletions(-) diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 2cc458c..af6a1c0 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -1,9 +1,9 @@ #![no_std] mod math; -pub mod ttl; #[cfg(kani)] mod math_proofs; +pub mod ttl; use math::{MathError, Settlement, BPS, MAX_SETS}; use soroban_sdk::{ @@ -3809,7 +3809,7 @@ mod test { client.transfer_lp(&market_id, &Address::generate(&env)); } -// ----- TTL policy ------------------------------------------------------ + // ----- TTL policy ------------------------------------------------------ /// Advances both clocks together, as they move on a real network /// (one ledger per `ttl::LEDGER_SECS`). diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index fe2668a..14ac670 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -935,9 +935,8 @@ impl Resolver { return Err(Error::AlreadyFinalized); } let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(&env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(&env, &market_core).try_get_market(&market_id))?; let now = env.ledger().timestamp(); let fallback_deadline = market.close_time.saturating_add(config.fallback_after); if now < fallback_deadline { @@ -1236,9 +1235,8 @@ impl Resolver { return Err(Error::WrongMode); }; let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(&env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(&env, &market_core).try_get_market(&market_id))?; if env.ledger().timestamp() < market.close_time { return Err(Error::MarketNotClosed); } @@ -1287,9 +1285,8 @@ impl Resolver { caller.require_auth(); let config = Self::price_config(&env, market_id)?; let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(&env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(&env, &market_core).try_get_market(&market_id))?; let now = env.ledger().timestamp(); if now < market.close_time { return Err(Error::MarketNotClosed); @@ -1333,9 +1330,8 @@ impl Resolver { /// is known before (and without) a spec reveal. pub fn resolution_deadline(env: Env, market_id: u64) -> Result { let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(&env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(&env, &market_core).try_get_market(&market_id))?; Ok(Self::deadline(&market)) } @@ -1464,9 +1460,8 @@ impl Resolver { return Err(Error::StaleRound); } let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(&env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(&env, &market_core).try_get_market(&market_id))?; if env.ledger().timestamp() < market.close_time { return Err(Error::MarketNotClosed); } @@ -1591,7 +1586,8 @@ impl Resolver { } let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let token = core_call(MarketCoreClient::new(&env, &market_core).try_get_config())?.collateral; + let token = + core_call(MarketCoreClient::new(&env, &market_core).try_get_config())?.collateral; token::Client::new(&env, &token).transfer( &disputer, env.current_contract_address(), @@ -2410,9 +2406,8 @@ impl Resolver { .unwrap_or(EvidencePolicy::Disabled); let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(env, &market_core).try_get_market(&market_id))?; // Before any write: this is what keeps an `Invalid` proposal (and so // `void_market`, which has no close-time guard) from existing before // close. @@ -2430,15 +2425,14 @@ impl Resolver { if storage.has(&DataKey::EvidenceUsed(trace_id)) { return Err(Error::EvidenceAlreadyUsed); } - let trace = match registry_call( - RegistryClient::new(env, ®istry).try_get_trace(&trace_id), - ) { - Ok(trace) => trace, - // A missing trace (or a registry that cannot be read) is the - // same caller-facing condition: the cited evidence is not - // available. The registry's own numeric code is never leaked. - Err(_) => return Err(Error::EvidenceNotFound), - }; + let trace = + match registry_call(RegistryClient::new(env, ®istry).try_get_trace(&trace_id)) { + Ok(trace) => trace, + // A missing trace (or a registry that cannot be read) is the + // same caller-facing condition: the cited evidence is not + // available. The registry's own numeric code is never leaked. + Err(_) => return Err(Error::EvidenceNotFound), + }; if trace.agent != signer { return Err(Error::EvidenceAuthorMismatch); } @@ -2532,9 +2526,8 @@ impl Resolver { commitment: &BytesN<32>, ) -> Result { let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; - let market = core_call( - MarketCoreClient::new(env, &market_core).try_get_market(&market_id), - )?; + let market = + core_call(MarketCoreClient::new(env, &market_core).try_get_market(&market_id))?; if *commitment != market.resolution_hash { return Err(Error::SpecHashMismatch); } @@ -4272,7 +4265,8 @@ mod test { }; f.resolver.register_signer_spec(&f.market_id, &spec); f.env.ledger().set_timestamp(2_000); - f.resolver.attest(&f.market_id, &f.signer_a, &AttestedOutcome::No); + f.resolver + .attest(&f.market_id, &f.signer_a, &AttestedOutcome::No); // 61 days of silence — past the old 60-day cap and past any default // persistent TTL. Without the write-time extension the tally would be @@ -4284,7 +4278,8 @@ mod test { "vote tally archived during the dispute period" ); - f.resolver.attest(&f.market_id, &f.signer_b, &AttestedOutcome::No); + f.resolver + .attest(&f.market_id, &f.signer_b, &AttestedOutcome::No); let count: Option = f.env.as_contract(&f.resolver_id, || { f.env.storage().persistent().get(&count_key) }); @@ -4293,7 +4288,10 @@ mod test { f.env .ledger() .set_timestamp(f.env.ledger().timestamp() + 200); - assert_eq!(f.resolver.finalize_signers(&f.market_id), AttestedOutcome::No); + assert_eq!( + f.resolver.finalize_signers(&f.market_id), + AttestedOutcome::No + ); assert_eq!( f.market_core.get_market(&f.market_id).status, MarketStatus::Resolved(Outcome::No) @@ -4316,7 +4314,8 @@ mod test { assert!(instance_ttl(&f.env, &f.resolver_id) < ttl::INSTANCE_THRESHOLD); // ...and the next entrypoint call re-extends it. - f.resolver.attest(&f.market_id, &f.signer_a, &AttestedOutcome::No); + f.resolver + .attest(&f.market_id, &f.signer_a, &AttestedOutcome::No); assert_eq!(instance_ttl(&f.env, &f.resolver_id), ttl::INSTANCE_TARGET); } @@ -5477,18 +5476,30 @@ mod shared_types { assert_eq!(shared.to_xdr(&env), imported.to_xdr(&env)); } for (shared, imported) in [ - (oracledesk_types::Category::Crypto, market_core::Category::Crypto), - (oracledesk_types::Category::Macro, market_core::Category::Macro), + ( + oracledesk_types::Category::Crypto, + market_core::Category::Crypto, + ), + ( + oracledesk_types::Category::Macro, + market_core::Category::Macro, + ), ( oracledesk_types::Category::Geopolitics, market_core::Category::Geopolitics, ), - (oracledesk_types::Category::Sports, market_core::Category::Sports), + ( + oracledesk_types::Category::Sports, + market_core::Category::Sports, + ), ( oracledesk_types::Category::Culture, market_core::Category::Culture, ), - (oracledesk_types::Category::Other, market_core::Category::Other), + ( + oracledesk_types::Category::Other, + market_core::Category::Other, + ), ] { assert_eq!(shared.to_xdr(&env), imported.to_xdr(&env)); } @@ -5580,7 +5591,10 @@ mod golden { let env = Env::default(); let declared = hex_of(&env, &signers(&env, &[G1, G2, G3], 2, 3_600)); let canonical = hex_of(&env, &signers(&env, &[G3, G1, G2], 2, 3_600)); - assert_eq!(declared, "4373448f97b0dd998a70efd9a1bfd3b3b8639bebaf682d9fc083c11f248a99ee"); + assert_eq!( + declared, + "4373448f97b0dd998a70efd9a1bfd3b3b8639bebaf682d9fc083c11f248a99ee" + ); assert_ne!(declared, canonical); } diff --git a/oracledesk-stellar/contracts/treasury/src/lib.rs b/oracledesk-stellar/contracts/treasury/src/lib.rs index 052c393..5fcb763 100644 --- a/oracledesk-stellar/contracts/treasury/src/lib.rs +++ b/oracledesk-stellar/contracts/treasury/src/lib.rs @@ -117,7 +117,10 @@ fn map_market_core_code(error: soroban_sdk::Error) -> Error { /// name-dispatched trade calls); the panicking generated methods are never /// used. fn core_call( - result: Result, Result>, + result: Result< + Result, + Result, + >, ) -> Result { match result { Ok(Ok(value)) => Ok(value), @@ -1770,16 +1773,18 @@ impl Treasury { let treasury_addr = env.current_contract_address(); Self::authorize_token_pull(&env, &global, &treasury_addr, seed_amount); - let market_id = core_call(MarketCoreClient::new(&env, &global.market_core).try_create_market( - &treasury_addr, - &question_hash, - &resolution_hash, - &meta_uri, - &category, - &close_time, - &seed_amount, - &initial_yes_bps, - ))?; + let market_id = core_call( + MarketCoreClient::new(&env, &global.market_core).try_create_market( + &treasury_addr, + &question_hash, + &resolution_hash, + &meta_uri, + &category, + &close_time, + &seed_amount, + &initial_yes_bps, + ), + )?; Self::reserve_capacity(&env, &global, &agent, &policy, market_id, seed_amount)?; Self::reserve_net(&env, &global, market_id, seed_amount)?; Self::record_cost(&env, market_id, seed_amount)?;