Skip to content

feat(market-core,resolver): cost-basis void refunds and event-driven early close (#21, #22) - #242

Open
anitahhhh wants to merge 4 commits into
OracleDesk:developfrom
anitahhhh:feat/refunds-and-early-close-21-22
Open

anitahhhh wants to merge 4 commits into
OracleDesk:developfrom
anitahhhh:feat/refunds-and-early-close-21-22

Conversation

@anitahhhh

Copy link
Copy Markdown

What this is

One PR for two issues, both assigned to @anitahhhh and 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_market is untouched and its markets get HalfPerShare, 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:

key contents
DataKey::VoidPolicy(u64) the market's policy — absent = HalfPerShare
DataKey::CostBasis(u64, Address) each holder's signed net collateral, maintained on create/buy/sell, deleted with an empty position
DataKey::BasisTotal(u64) sum of positive bases
DataKey::VoidSnapshot(u64) { sets_minted, basis_total } frozen by void_market

void_market freezes the snapshot once and every redeem settles against that snapshot, never live storage — that is what makes payouts independent of redeem order. The split is math::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_minted at void time, which the randomized test asserts.

HalfPerShare voids skip the whole block: no VoidPolicy, no CostBasis, no VoidSnapshot write, 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 beyond close_time or into the future, is backdated at most MAX_BACKDATE_SECS, is one halt per market, final and non-extending, and emits TradingHalted. It writes DataKey::EarlyClose(u64), and effective_close() is a clamped min(close_time, halt) so a corrupt value can never lengthen a market's life. assert_tradable (contracts/market-core/src/lib.rs:1761) and resolve now read the effective close, and LastTrade(u64) records last_trade_at so 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 ResolutionSpec variant:

signer_with_early_close_hash = sha256("oracledesk:early-close-spec" || XDR(SignerConfig) || XDR(EarlyCloseSignerSpec))

EarlyCloseSignerSpec { early_close_threshold, early_close_window, late_trade_policy } lives in contracts/resolver/src/lib.rs. The reason it is resolver-local and not in oracledesk-types is 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 an ScVal and therefore starts 00 00 00 10, while both domain tags start with ASCII o, and the two tags differ within their first 13 bytes — register_signer_spec and legacy_spec_hash are untouched, so every existing spec hash and golden vector holds by construction. The added golden tests in mod golden re-assert the legacy Price/Signers hashes and assert the new tag is disjoint from both.

A market reveals through register_signer_early_close_spec or register_signer_spec, never both: each entrypoint checks its own commitment, so the two authorities are not interchangeable after the fact.

propose_early_close requires signer auth, membership in the market's existing signer group, the market still Open, now before the effective close, one vote per signer per round, and exact agreement on event_time. Flow: propose_early_close → dispute window (early_close_window > 0) → finalize_early_close calls halt_trading on market-core → cancel_early_close is the guardian's veto while the proposal is open and also clears the votes (unlike cancel_proposal, which leaves outcome counters in place; votes for a rejected event time must not count towards the next round). It deliberately does not consume MAX_CANCELS_PER_MARKET, because that budget bounds churn of an outcome and a refused halt strands nothing — trading simply continues to close_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, because finalize_signers, resolve and void_market are untouched.

Late trades use the chosen LateTradePolicy: accept-and-disclose is what TradingHalted supports, publishing effective_at, halted_at, close_time and last_trade_at so 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.
  • The compile wall, for the record: the pinned 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 below soroban-sdk@27.0.6's MSRV 1.91, so cargo check fails at dependency resolution; cargo +stable gets past resolution and dies on 21 pre-existing errors in oracledesk-types (cannot find attribute contracttype ×~16 plus E0277/E0369 on WeightedKey). Neither is caused by this PR.
  • 31 test functions authored across the two crates (void-policy defaults, cost-basis maintenance on create/buy/sell, empty-position deletion, snapshot-frozen payouts, redeem-order independence, Σ ≤ sets_minted randomized, halt auth/backdate/postdate/second-halt, effective-close gating of assert_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 -n on the touched shell where applicable. No shellcheck on this host.

Acceptance criteria

#21 — VoidPolicy + additive creation entrypoint with HalfPerShare default: met. Cost basis maintained on create/buy/sell and deleted with empty positions: met. math::cost_basis_refund pure 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.md policy/formula/dust bound: met. make test/make lint: cannot run here.

#22 — halt_trading resolver-only, no postdate, emits TradingHalted: 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 new ResolutionSpec variant). 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-hash supporting the new variant: not met by design — there is no new ResolutionSpec variant for it to support, and delegating the new tag into scripts/spec-hash would change a tool that today only ever produces legacy hashes. Vendored market_core.wasm + bindings regenerated: refused, see below. docs/architecture.md lifecycle text: met. make test/make lint: cannot run here.

Deviations, refusals and known gaps — stated plainly

  1. No .wasm and no *.wasm.sha256 bytes move in this PR, and make bindings was not run. The vendored blobs cannot be rebuilt on this host, and scripts/verify-wasm-hash.rs (hooked into resolver/build.rs and treasury/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.
  2. create_market_with_policy is not reachable through the canonical creation paths: contracts/router/src/lib.rs:296 still calls create_market(...) and treasury's agent_create_market (:1772) still calls try_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.
  3. oracledesk-types, packages/bindings/**, rust-toolchain.toml, every build.rs and .github/** are untouched.
  4. Storage shapes are frozen: Market, Position and 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 the DataKey/Error tails are append-only (they are XDR discriminants).

Enum-tail collisions with other open PRs

These append at the current develop tail, which several of our own open PRs also append to. Whichever merges last has to re-append. Disclosed rather than silently avoided:

…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.
@drips-wave

drips-wave Bot commented Oct 21, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add event-driven early close so trading halts when the underlying event occurs Add optional cost-basis refunds for voided markets

1 participant