Repository navigation
Conversation
…acleDesk#21) A voided market currently pays every holder `settle_value = (yes + no) / 2`, so a position entered at 0.90 loses ~44% of its cost when the market is voided for an invalid or unresolvable question. OracleDesk#21 asks for an *optional* per-holder cost-basis refund instead. Design rule followed throughout: no frozen interface moves. `Market`, `Position`, and the signatures of `create_market`, `buy`, `sell`, `redeem`, `resolve`, `void_market`, `claim_pool_remainder` are decoded by resolver, treasury and router from vendored copies of this contract's wasm that the repo pins by sha256, so all new state is sidecar storage and all new choice is a new entrypoint. - `VoidPolicy { HalfPerShare, CostBasis }` + `DataKey::VoidPolicy(u64)`, `CostBasis(u64, Address)`, `BasisTotal(u64)`, `VoidSnapshot(u64)` appended at the tail of `DataKey` (positional XDR discriminants are unchanged). Absent means `HalfPerShare`, so every existing market reads byte-identically. - `create_market_with_policy(...)` is a new entrypoint; `create_market` delegates to it with `HalfPerShare` and still writes no sidecar. - Basis tracks collateral, not shares: `+q.net` on buy, `-q.gross` on sell, `+seed_amount` for the creator, a proportional slice on `transfer_position`, and the entry leaves with an emptied or redeemed position. That keeps `Σ basis_h + basis_LP == sets_minted` exact at every step (`basis_LP` is the implicit residual and follows `m.creator`), and is why the refund pro-rata denominator cannot drift. - `void_market` freezes `(pot = sets_minted, basis_total)` into a `VoidSnapshot` and `redeem` / `claim_pool_remainder` pay only from it, so payouts are independent of redemption order and the total payout is bounded by the pot: `Σ floor(basis_i * pot / total) + (pot − min(total, pot)) == pot`. - Fees are excluded from basis by design: the protocol fee never enters `sets_minted`, so including it would over-fund the pot. Tests (authored; see PR body for why no cargo test run is possible on this host): legacy half-per-share parity, refund at a 90% opening, basis reduced by gross on a partial sell, negative basis pays zero, order-independent redemptions, LP residual after a full-exit-at-a-loss, and a 5-holder / 500-trade randomized solvency property asserting `Σ payouts <= sets_minted`. `cargo +stable fmt -p market-core -- --check` exits 0.
…esk#22) "Will X happen by D?" markets stay open until D even when X has already happened, so anyone faster than the repricing crowd buys the known outcome at a stale price and the LP eats the difference. `resolve` could not help either: it requires `now >= close_time`. `halt_trading(market_id, effective_at)` brings the end of trading forward. It is resolver-only (same `require_resolver` as `resolve`/`void_market`), needs the market still `Open`, allows exactly one halt per market, and only accepts an `effective_at` with `close_time >= effective_at >= now - MAX_BACKDATE_SECS`: a halt records a fact, it can never schedule one or push the close later. `MAX_BACKDATE_SECS` is 7 days, the same window the resolver already grants signers (`SIGNER_GRACE_SECS`), so a halt cannot reach back further than the period in which late trading is already tolerated. Everything reads `effective_close = min(close_time, early_close)`: `assert_tradable` (both `buy`/`sell` and `buy_v2`/`sell_v2`, which share `do_buy`/`do_sell`), `resolve`, and the resolver's attestation gate. A market with no `EarlyClose` entry compares against `close_time` exactly as before, at the cost of one storage read that misses. Late-trade policy: **accept and disclose**. Trades in `[effective_at, halted_at)` stand; `TradingHalted` publishes `effective_at`, `halted_at`, `close_time` and `last_trade_at` in one event so the backend can flag and price the window without re-reading a market whose state has moved on. `DataKey:: LastTrade(u64)` is written by `do_buy`/`do_sell` after every check that can fail — one fixed-size entry per market, one write per trade, never growing — and is what anchors the disclosure to a real on-chain trade. Rationale and the rejected void-if-late alternative go in ADR 0001. Sidecar state again: `DataKey::EarlyClose(u64)` / `LastTrade(u64)` appended at the tail, `Error::AlreadyHalted = 27` / `HaltTooOld = 28` at the `Error` tail, `TradingHalted` appended after the OracleDesk#21 events. `Market` and its frozen XDR are untouched, so `close_time` stays the *nominal* close and consumers that want the real one use `get_effective_close` / `get_early_close` / `get_last_trade_at`. Tests (authored; see PR body for why no cargo test run is possible on this host): buys and sells blocked after the effective time, rejection of future / post-close / over-backdated halts plus `AlreadyHalted`, `resolve` unlocking exactly at the halt, void still reachable after a halt, targeted `mock_auths` proving only the resolver key can halt, `last_trade_at` moving only with accepted trades, and an un-halted market never touching the new keys. `cargo +stable fmt -p market-core -- --check` exits 0.
…cleDesk#22) market-core can now move a market's end of trading forward (`halt_trading`), but the authority to do that belongs with the market's resolution signers, not with whoever notices the event first. This is the resolver half of OracleDesk#22: the commit that grants the power, the quorum that exercises it, the guardian's veto, and what every other resolver entrypoint does once a halt is on the record. Commitment first, because a halt must not be able to spring on traders. The obvious home for the config — a new `ResolutionSpec` variant in `oracledesk-types` — is closed to us: that crate is the XDR-consensus surface and the one that fails `cargo +stable check` with 21 pre-existing errors at this tip. So, exactly the pattern PR OracleDesk#171 proved, the struct is **local to resolver** with its **own domain tag**: `signer_with_early_close_hash = sha256("oracledesk:early-close-spec" || XDR(SignerConfig) || XDR(EarlyCloseSignerSpec))`, a third hash space disjoint from ADR 0001's legacy and ADR 0004's V2 preimages (legacy preimages are `ScVal` XDR starting `00 00 00 10`, every tag here starts with ASCII `o`, and the two tags differ inside the first 13 bytes). `register_signer_spec` and `legacy_spec_hash` are untouched, so every existing spec hash and every existing golden vector holds **by construction** — asserted directly, not argued. Flow: `register_signer_early_close_spec` (the market must have committed to that preimage; plain-`Signers` commitments get `SpecHashMismatch` and vice versa, so the two authorities are never interchangeable after the fact) -> `propose_early_close(market_id, signer, event_time)`, signer-only, one vote per signer per round, votes required to agree **exactly** on `event_time` (the issue offers "match or take the maximum"; a maximum would let one late voter silently move refunds, and a signer believing a different instant is the true one should attest `Invalid` instead), `event_time` in `[now - MAX_BACKDATE_SECS, now]`, and refused at or after the effective close or on a market that is no longer Open -> at `early_close_threshold` an `EarlyCloseProposed` dispute window opens -> `finalize_early_close` (permissionless, like `finalize_signers`) calls `halt_trading` -> `cancel_early_close` lets the guardian block it inside the window, clearing the whole round so a rejected event time cannot count towards the next one. Anti-grief: `early_close_threshold >= SignerConfig.threshold` and `<= signers.len()`, `window != 0` — freezing a market is never easier than resolving one. `MAX_CANCELS_PER_MARKET` is deliberately not consumed by a cancel: that budget bounds churn of an *outcome*, whereas refusing a halt simply lets trading run to `close_time`. `halt_trading` is dispatched **by name** via `env.try_invoke_contract`, not through `MarketCoreClient`: the `market_core.wasm` vendored here is pinned by sha256 and predates the method, so it does not exist in the generated spec. That makes the adoption limit explicit instead of silent — against today's blob `finalize_early_close` returns `HaltRejected` and fails *before* any write, leaving proposal, votes and event time intact for the guardian to cancel or for a retry against a refreshed core; against a deployed core that has the export the same code needs no change. `attest`'s timing gate is now the market's **effective** close, read from this contract's own `DataKey::EarlyCloseApplied` record — never back from market-core, because the pinned blob has no `early_close` field to decode and no `get_effective_close` to call. With no halt applied the comparison is the pre-OracleDesk#22 `now < market.close_time`, boundary included; the evidence check further down still compares `trace.published_at` to the *nominal* close on purpose (`EvidenceTooEarly` guards against reasoning written before the market was due, and a trace is still good evidence for a halt). `get_effective_close`, `get_early_close_spec`/`_proposal`/`_votes`, `get_applied_early_close` are new readers; `LateTradePolicy::VoidIfLate` is registerable (so a commitment can be honest about a policy that follows) and deliberately not yet acted on. Append-only: six `DataKey` variants at the tail, five `Error` variants at the tail (`EventTimeMismatch = 44`..`CoreUnavailable = 48`), four events after the existing ones. Nothing renumbered, nothing reordered, no field added to a decodable struct. Tests: the three hash spaces are disjoint and every disclosed field moves the commitment; the two commitments are not interchangeable; registration records + publishes what it granted and rejects a threshold that would make freezing easier than resolving; quorum, exact-agreement, double-vote, non-signer, future-event-time, past-close and no-commitment paths; the window before finalisation and the `HaltRejected`-and-survive behaviour against the pinned blob; an applied halt moving the effective close and unlocking `attest` at a timestamp that was `MarketNotClosed` a moment earlier; the halt being final; guardian cancel clearing the whole round, its window boundary, and its admin auth checked through `mock_auths` with an intruder root; plus the pure `effective_close_of` clamping and `event_time_is_usable` boundaries. `cargo +stable fmt -p resolver -- --check` reports the same 12 hunks as it does at `5ede00a` (byte-identical multiset, verified) — zero new complaints; the file is not clean at the base and this PR does not pretend otherwise.
… policy and refund design (OracleDesk#21, OracleDesk#22)
|
@anitahhhh Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Open
13 of 15 tasks
anitahhhh
added a commit
to anitahhhh/OracleDesk-SmartContract
that referenced
this pull request
Oct 22, 2026
…ils precisely (OracleDesk#24) ADR 0010 listed the Python mirror as follow-up work it could not do. That was wrong about the constraint: model/fpmm_model.py runs here, so its checks are executed rather than merely authored. Only the TypeScript mirror stays out, because Node on this host cannot strip types. Also drops the claim that resolver's Error 44 collides with OracleDesk#239/OracleDesk#240/OracleDesk#241: it is OracleDesk#241 (QuorumNotReached) and OracleDesk#242 (EventTimeMismatch) that reach 44, verified from each PR's own patch. Co-Authored-By: Claude <noreply@anthropic.com>
anitahhhh
added a commit
to anitahhhh/OracleDesk-SmartContract
that referenced
this pull request
Oct 22, 2026
…riant rule (OracleDesk#23, OracleDesk#24) `develop`'s resolver enum ends at 43, so `MarketCoreCallFailed = 44` reaches for the same number as OracleDesk#241's `QuorumNotReached` and OracleDesk#242's `EventTimeMismatch`. The renumber rule is worth writing down where the next contributor will hit it: asserting the named variant survives the renumber, asserting the numeric literal does not. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
One PR for two issues, both assigned to
@anitahhhhand both landing in the same two crates:Built standalone against
develop@5ede00a8. It does not depend on, rebase onto, or import from any other open PR.#21 — cost-basis void refunds
VoidPolicy { HalfPerShare, CostBasis }is chosen at creation through a new additive entrypoint,create_market_with_policy(...).create_marketis untouched and its markets getHalfPerShare, which is today's rule (math::settle_value), so existing behaviour is unchanged by construction rather than by re-derivation.The policy and the money both live in sidecar keys, so no existing stored entry changes shape:
DataKey::VoidPolicy(u64)HalfPerShareDataKey::CostBasis(u64, Address)DataKey::BasisTotal(u64)DataKey::VoidSnapshot(u64){ sets_minted, basis_total }frozen byvoid_marketvoid_marketfreezes the snapshot once and everyredeemsettles against that snapshot, never live storage — that is what makes payouts independent of redeem order. The split ismath::cost_basis_refund(basis, pot, total): a holder whose basis is negative (took out more than they paid in) is refunded nothing even though they still hold shares, and a pot smaller than the positive bases scales everyone down pro-rata. Σ holder payouts + LP payout ≤sets_mintedat void time, which the randomized test asserts.HalfPerSharevoids skip the whole block: noVoidPolicy, noCostBasis, noVoidSnapshotwrite, so the common path pays no extra rent.#22 — event-driven early close
market-core side.
halt_trading(market_id, effective_at)is resolver-only (the caller must be the market's registered resolver), cannot postdate beyondclose_timeor into the future, is backdated at mostMAX_BACKDATE_SECS, is one halt per market, final and non-extending, and emitsTradingHalted. It writesDataKey::EarlyClose(u64), andeffective_close()is a clampedmin(close_time, halt)so a corrupt value can never lengthen a market's life.assert_tradable(contracts/market-core/src/lib.rs:1761) andresolvenow read the effective close, andLastTrade(u64)recordslast_trade_atso the late-trade window is computable on-chain. New errors append at the tail:AlreadyHalted = 27,HaltTooOld = 28.resolver side. The authority to halt is committed, and it is a third hash domain, not a new
ResolutionSpecvariant:EarlyCloseSignerSpec { early_close_threshold, early_close_window, late_trade_policy }lives incontracts/resolver/src/lib.rs. The reason it is resolver-local and not inoracledesk-typesis stated in the ADR: that crate is the XDR-consensus surface every consumer shares, and it is also the crate that does not compile at this tip. Because no existing preimage changes shape — every legacy preimage is the XDR of anScValand therefore starts00 00 00 10, while both domain tags start with ASCIIo, and the two tags differ within their first 13 bytes —register_signer_specandlegacy_spec_hashare untouched, so every existing spec hash and golden vector holds by construction. The added golden tests inmod goldenre-assert the legacyPrice/Signershashes and assert the new tag is disjoint from both.A market reveals through
register_signer_early_close_specorregister_signer_spec, never both: each entrypoint checks its own commitment, so the two authorities are not interchangeable after the fact.propose_early_closerequires signer auth, membership in the market's existing signer group, the market stillOpen,nowbefore the effective close, one vote per signer per round, and exact agreement onevent_time. Flow:propose_early_close→ dispute window (early_close_window > 0) →finalize_early_closecallshalt_tradingon market-core →cancel_early_closeis the guardian's veto while the proposal is open and also clears the votes (unlikecancel_proposal, which leaves outcome counters in place; votes for a rejected event time must not count towards the next round). It deliberately does not consumeMAX_CANCELS_PER_MARKET, because that budget bounds churn of an outcome and a refused halt strands nothing — trading simply continues toclose_time.Registration bounds that cap the damage:
SignerConfig.threshold ≤ early_close_threshold ≤ signers.len()(freezing is never easier than resolving), and the worst a captured or mistaken signer majority can do is freeze a market early — it cannot choose the outcome, move collateral, or resolve anything, becausefinalize_signers,resolveandvoid_marketare untouched.Late trades use the chosen
LateTradePolicy: accept-and-disclose is whatTradingHaltedsupports, publishingeffective_at,halted_at,close_timeandlast_trade_atso the window[effective_at, halted_at)can be flagged and repriced off-chain.Verification — exactly what ran and what did not
This host cannot compile the workspace, so no test in this PR was executed.
cargo +stable fmt -p market-core -- --check→ exit 0, before and after (market-core is fmt-clean at tip and stays clean with these additions).cargo +stable fmt -p resolver -- --check→ 12 flagged hunks before, 12 after, none of them inside a range this PR adds (added ranges: 7-8, 281-314, 323-376, 426-443, 491-516, 757-808, 1730-2004, 2375-2511, 2997-3009, 5696-6267; flagged lines: 1110, 1408, 1459, 1505, 1635, 2036, 2988, 3018, 3112, 5469, 5478, 5483). The 12 findings are pre-existing. This proves the new code is rustfmt-clean and that rustfmt parses it; it is not a type check.rust-toolchain.toml(1.90.0, pinned deliberately by issue Replace vendored market_core.wasm with a deterministic build and hash-pinned import #58 for bit-identical wasm) is belowsoroban-sdk@27.0.6's MSRV 1.91, socargo checkfails at dependency resolution;cargo +stablegets past resolution and dies on 21 pre-existing errors inoracledesk-types(cannot find attribute contracttype×~16 plusE0277/E0369onWeightedKey). Neither is caused by this PR.sets_mintedrandomized, halt auth/backdate/postdate/second-halt, effective-close gating ofassert_tradable/resolve/attest, vote agreement, dispute window, guardian cancel clearing votes, registration-bound rejections, spec-domain disjointness golden vectors). They are authored, not run.bash -non the touched shell where applicable. No shellcheck on this host.Acceptance criteria
#21 —
VoidPolicy+ additive creation entrypoint withHalfPerSharedefault: met. Cost basis maintained on create/buy/sell and deleted with empty positions: met.math::cost_basis_refundpure with unit + property tests in Rust: met. Python property tests: not met — there is no numpy on this host, so the Python side was not written or run. Σ payouts ≤sets_minted, randomized: authored, not executed. Redeem-order independence: met by the snapshot design. HalfPerShare unchanged: met. Treasury forwarding the new creation path, vendored wasm and bindings regenerated: not met — see deviations.docs/architecture.mdpolicy/formula/dust bound: met.make test/make lint: cannot run here.#22 —
halt_tradingresolver-only, no postdate, emitsTradingHalted: met.assert_tradable/resolve/attest on effective close: met. Committed spec enabling early close + golden tests that existing hashes are unchanged: met by a different mechanism (third domain tag rather than a newResolutionSpecvariant). Proposal flow with dispute window and guardian cancellation that also clears votes: met. Late-trade policy chosen, enforced-or-disclosed on-chain, documented in ADR 0001: met.scripts/spec-hashsupporting the new variant: not met by design — there is no newResolutionSpecvariant for it to support, and delegating the new tag intoscripts/spec-hashwould change a tool that today only ever produces legacy hashes. Vendoredmarket_core.wasm+ bindings regenerated: refused, see below.docs/architecture.mdlifecycle text: met.make test/make lint: cannot run here.Deviations, refusals and known gaps — stated plainly
.wasmand no*.wasm.sha256bytes move in this PR, andmake bindingswas not run. The vendored blobs cannot be rebuilt on this host, andscripts/verify-wasm-hash.rs(hooked intoresolver/build.rsandtreasury/build.rs) turns a stale vendored blob into a compile error. Regenerating them is a required follow-up by someone with stellar-cli ≥ 27.1.0, because the new market-core entrypoints and the two new#[contractevent]structs do change the contract spec.create_market_with_policyis not reachable through the canonical creation paths:contracts/router/src/lib.rs:296still callscreate_market(...)andtreasury'sagent_create_market(:1772) still callstry_create_market(...). Forwarding those was left out on purpose to keep this PR additive and out of the crates other open PRs are editing, which means a Add optional cost-basis refunds for voided markets #21-policy market cannot yet be created through router/treasury. That is a real functional gap, not a cosmetic one, and it needs a small follow-up PR.oracledesk-types,packages/bindings/**,rust-toolchain.toml, everybuild.rsand.github/**are untouched.Market,Positionand every existing exported signature keep their current field order and count. New state is sidecar-only with absent-means-default, new capabilities are new entrypoints, and theDataKey/Errortails are append-only (they are XDR discriminants).Enum-tail collisions with other open PRs
These append at the current
developtail, which several of our own open PRs also append to. Whichever merges last has to re-append. Disclosed rather than silently avoided:Error: this PR takes27,28. PR feat: graduated emergency modes in market-core + treasury pre-check (#20) #239 (Add graduated emergency modes to market-core: reduce-only and settle-only #20) also takes27(ModeRestricted); PR feat(market-core): counter-indexed open/category/holder market directories (#25) #240 (Add on-chain market directories indexed by status, category and holder #25) also takes27(DirectoryEntryArchived).DataKey: this PR's six keys append afterBand(u64), the same anchor as feat(market-core): counter-indexed open/category/holder market directories (#25) #240's eight directory keys and feat: graduated emergency modes in market-core + treasury pre-check (#20) #239'sMode.Error: this PR takes44–48. PR feat(resolver): committed multi-oracle quorum resolution with median aggregation (#28) #241 (Add multi-oracle quorum resolution with median aggregation and per-feed fallback #28) also takes44and45.DataKey: this PR's six keys append afterSnapshot(u64), the same anchor as feat(resolver): committed multi-oracle quorum resolution with median aggregation (#28) #241'sQuorum(u64).