Skip to content

feat: categorical (#6), scalar (#7) and dynamic-fee (#9) markets - #161

Open
MFrancis-dev wants to merge 6 commits into
OracleDesk:developfrom
MFrancis-dev:feat/categorical-scalar-dynamic-fees
Open

MFrancis-dev wants to merge 6 commits into
OracleDesk:developfrom
MFrancis-dev:feat/categorical-scalar-dynamic-fees

Conversation

@MFrancis-dev

Copy link
Copy Markdown

What this does

Implements #6, #7 and #9 together: categorical (n-outcome) markets, scalar (range) markets resolved from a Reflector feed, and per-market dynamic fee schedules — plus the resolver and treasury plumbing each needs, the Python/TypeScript mirrors the issues call for, regenerated bindings and refreshed vendored wasm.

Closes #6
Closes #7
Closes #9

The branch also carries one small independent fix: ac7b283 regenerates packages/bindings/resolver, which was stale since #159 (the committed client was missing ResolutionSpecV2, Verdict, Escalation, expiry_mode, expire_dispute/settle_dispute/escalation_attest/current_round/register_spec_v2).

#6 — categorical N-outcome FPMM

  • create_categorical_market(…, probs_bps) — 2..=8 outcomes (MAX_MARKET_OUTCOMES = 8), each probability within MIN_PROB_BPS..=MAX_PROB_BPS and summing to exactly BPS; the creator/LP receives the leftover shares of the likelier outcomes.
  • New math_categorical module: the binary k = r_yes·r_no invariant generalized to k = Π(r_i), with the same iterative floor/ceil scheme as the binary path — a trade never profits from rounding and k never decreases (property-tested in all three mirrors).
  • Entrypoints: buy_cat / sell_cat (share-count bounds), quote_buy_cat, get_prices_cat (implied probabilities; sum within n of 10000 bps), redeem_cat, claim_pool_remainder_cat, get_cat_market / get_cat_position, market_kind.
  • Events CatMarketCreated / CatTrade / CatMarketResolved; void_market and the pause switch cover categorical ids; the FeeTerms snapshot is shared.
  • Categorical markets charge a flat snapshotted fee_bps (no dynamic schedule) and have no price-based breakers (those are binary-price-only) — both deliberate, documented in docs/architecture.md.
  • Measured worst case (cat_eight_outcomes_resource_budget, printed on every run): buy_cat at n = 8 = 444,604 instructions / 64,092 B memory / 1,640 write bytes / 5 write entries — asserted to stay under 60% of the network limits (400M instructions / 40 MiB / 132,096 write bytes).

#7 — scalar (range) markets

  • create_scalar_market(…, lower, upper) — a binary market (LONG = YES, SHORT = NO) plus a ScalarMeta side entry; trading, fees (schedules included), breakers and the solvency invariant are inherited unchanged.
  • resolve_scalar(market_id, payout_long_bps) writes MarketStatus::ResolvedScalar(u32) (appended variant; 0 = full SHORT, 10000 = full LONG); redemption pays floor(yes·p/10000) + floor(no·(10000−p)/10000) — both legs floor, so payouts stay within sets_minted.
  • Resolver: register_scalar_spec + resolve_scalar_price — reads the committed Reflector feed at/after close (same max-staleness window as resolve_price), rejects a non-positive reading as InvalidPrice, clamps into [lower, upper], floors the payout toward the pool.
  • get_scalar_meta view (display only).

#9 — dynamic fees from time-to-close and pool skew

  • FeeSchedule { base_bps, max_bps, ramp_secs, skew_mult_bps } → math::effective_fee_bps(schedule, now, close_time, r_yes, r_no) = min(max_bps, base + ramp + skew): ramp rises linearly from base_bps to max_bps over the last ramp_secs before close (flat at max_bps afterwards), skew adds skew_mult_bps per 1000 bps of pre-trade YES-price distance from 50/50. All floors round toward the trader.
  • Snapshot at creation: admin set_default_fee_schedule (validated; new markets only) or an explicit create_market_with_fees, validated against the default's max_bps cap before a market id is allocated. Legacy markets have no schedule → flat fee_bps; no migration needed.
  • Every trade path uses it (buy/sell, _v2, quotes and the fee split); views: current_fee_bps, fee_schedule, and quote_buy_at(market_id, outcome, collateral_in, at_timestamp) so clients can price expected inclusion latency under a ramping fee.
  • TradeV2 carries the realised fee_bps alongside the existing Trade event (the indexer-facing XDR of Trade is unchanged).

Resolver / treasury / agents

  • register_cat_signer_spec / attest_cat / finalize_cat — categorical counterpart of Attested mode, same dispute window; the guardian's cancel_proposal clears a categorical proposal too.
  • Treasury: agent_buy_cat forwards to market-core under the existing per-trade/per-market/daily caps (with a test).
  • Agents: live mode reads get_market + current_fee_bps so the strategy quotes with the effective (scheduled) fee and clamps size to the impact breaker; a trader test asserts the effective fee — not the flat one — reaches the decision trace.

Mirrors (Python + TS, pinned to one fixture file)

model/fpmm_model.py and agents/core/fpmm.ts (tests in agents/core/fpmm.test.ts) gain categorical buy/sell/prices/seed/settle, scalar payout and effective_fee_bps. model/fee_fixtures.json holds golden vectors read by Python, Rust and TypeScript — all three implementations are pinned to the same numbers, so they cannot drift silently.

Compatibility notes

  • Market's XDR is unchanged: the new state is side storage under the same market_id. MarketStatus::ResolvedScalar and ResolutionSpec::{CategoricalSigners (tag 4), Scalar (tag 5)} are appended after the existing variants — old values keep decoding, but bindings generated before this change cannot decode values that use the new variants (release note in docs/STATUS.md; committed bindings are regenerated here). Rationale and rejected alternatives: docs/adr/0005-market-kind-storage-split.md.
  • get_market returns MarketNotFound for categorical ids — readers dispatch on market_kind (documented in docs/frontend-integration.md).
  • market-core error codes: 1–18, 19–22, 28 (InvalidOutcomeIndex); 23–27 were left free for PR Storage TTL policy, outstanding-share finalization, TWAP accumulator, and batched redemption (#16, #14, #11, #15) #156.

Checklist

  • cargo fmt --check and cargo clippy --workspace --all-targets -- -D warnings pass
  • cargo test --workspace passes — 208 tests (market-core 97, resolver 82, treasury 23, reasoning-registry 6), plus scripts/spec-hash 11
  • Bindings regenerated (make bindings) and included; regeneration verified idempotent
  • market-core's interface changed → resolver/treasury vendored market_core.wasm refreshed (rebuild byte-identical)
  • New behavior has tests; changed behavior's tests updated, none deleted
  • docs/STATUS.md updated (dated addendum)

How this was tested

Final local run (the CI workflow in this repo is nested under oracledesk-stellar/.github/ and does not run on GitHub; these commands mirror it exactly and then some):

  • cargo fmt --check → clean
  • cargo clippy --workspace --all-targets -- -D warnings → clean
  • cargo test --workspace → market-core 97, reasoning-registry 6, resolver 82, treasury 23 — 0 failed
  • cargo test --manifest-path scripts/spec-hash/Cargo.toml → 11 passed, 0 failed
  • python3 model/fpmm_model.py → fpmm_model: all property checks passed
  • x402: npm ci && npm run build && npm test → 5 pass / 0 fail
  • agents: npm install && npm run build && npm test → 52 pass / 0 fail
  • scripts/gen-bindings.sh re-run → byte-identical output; wasm rebuild → byte-identical (md5 stable); all four packages/bindings/* packages npm install && npm run build → clean

Not done (called out honestly): no testnet redeploy — the new entrypoints are covered by contract-level and off-chain tests, not yet by a real-ledger transaction.

packages/bindings/resolver/src/index.ts was missing the OracleDesk#159 interface
additions (ResolutionSpecV2, Verdict, Escalation, ExpiryMode,
DisputeSettled, expire_dispute/settle_dispute/escalation_attest/
current_round/register_spec_v2, Error variants 19-25). Regenerated with
scripts/gen-bindings.sh at stellar-cli 27.1.0; no other package drifted.
…fee markets (OracleDesk#6 OracleDesk#7 OracleDesk#9)

- market-core: n-outcome categorical FPMM (2..=8) — create_categorical_market,
  buy_cat/sell_cat, resolve_cat, redeem_cat, claim_pool_remainder_cat,
  get_cat_market/get_cat_position/get_prices_cat/quote_buy_cat, new
  math_categorical module, CatTrade/CatMarketResolved events, MarketKind dispatch
- market-core: scalar range markets — create_scalar_market, resolve_scalar
  (MarketStatus::ResolvedScalar), get_scalar_meta; binary pool reused (LONG=YES)
- market-core: dynamic fee schedules — FeeSchedule + effective_fee_bps,
  create_market_with_fees, set_default_fee_schedule, current_fee_bps,
  quote_buy_at, TradeV2 carrying the realised fee_bps
- resolver: register_cat_signer_spec/attest_cat/finalize_cat and
  register_scalar_spec/resolve_scalar_price (Reflector, clamped, floored)
- treasury: agent_buy_cat under the existing caps
- mirrors: model/fpmm_model.py + agents/core/fpmm.ts pinned to shared
  model/fee_fixtures.json; trader reads get_market + current_fee_bps in live mode
- bindings regenerated (market-core/resolver/treasury); vendored
  market_core.wasm rebuilt via make build
- docs: ADR 0005 (market-kind storage split), ADR 0001 appended-variants note,
  architecture/frontend-integration/STATUS updates
@drips-wave

drips-wave Bot commented Oct 16, 2026

Copy link
Copy Markdown

@MFrancis-dev 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

…-scalar-dynamic-fees

# Conflicts:
#	oracledesk-stellar/docs/STATUS.md
Conflicts resolved by hand in ten paths. The semantic collisions, not the
textual ones:

- resolver `Error`: develop filled codes 26-41, so `InvalidOutcomeIndex`
  moves from 26 to 42 (a duplicate tag would silently alias two errors).
- resolver `ResolutionSpec`: categorical/scalar keep tags 4/5 (they are
  pinned by the golden commitment-hash vectors in this branch's
  `golden_legacy_scalar_spec_hashes`); committee/hybrid append at 6/7.
  Variants stay append-only either way.
- resolver `DataKey`: develop's V2/migration keys and the new categorical
  keys both append, develop's first, so neither side's stored tags move.
- resolver `cancel_proposal`: develop's constrained guardian (typed reason,
  `MAX_CANCELS_PER_MARKET`, `GuardianAction` event, dispute-window bound)
  wins, extended to clear the separate categorical proposal slot.
  `cat_proposal_opens_with_quorum_and_guards_expiry` is re-sequenced for
  the window bound: the quorum now lands after the grace deadline, so the
  proposal is still inside its window when the guardian cancels it.
- treasury: develop's price-checked `do_agent_buy` replaces this branch's
  copy, and `agent_buy_cat` is rewritten on the agent-registry helpers
  (`require_agent`/`check_trade_size`/`authorize_token_pull`/
  `reserve_capacity`/`reserve_net`); both categorical tests port to the
  `agent`-first signature. `settle_value` gains the `ResolvedScalar` arm
  mirroring market-core's `math::scalar_payout`, which the merged
  exhaustive match requires.
- spec-hash: both parser sets kept (cat-signers/scalar and
  committee/hybrid), 11 tests including all golden vectors.
- ADR renumbered 0005 -> 0006: develop consumed 0003 and 0005.

Regenerated artifacts, which is what the two `git diff --exit-code` gates
demand (both were red on untouched develop at 6f186e6):
`stellar contract build` -> vendored market_core/reasoning_registry wasm,
`scripts/gen-bindings.sh` -> packages/bindings. Verified idempotent:
a second generation leaves `packages/bindings` diff-clean.
The addendum's figures were measured pre-merge (208 workspace tests, 52
agents). On the merged branch: 305 (market-core 97, resolver 107, treasury
95, reasoning-registry 6) and 54 agents tests.
@MFrancis-dev

Copy link
Copy Markdown
Author

Conflicts resolved and every nested oracledesk-stellar/.github/workflows/ci.yml gate run locally. Note this repo's workflow is nested, so GitHub never starts it for this PR: "green" here means the gates pass locally on the pushed head.

Head a0ca3ee (merge 06bf9d8 + docs count fix) — mergeable_state: clean.

Gates, in ci.yml order

Gate Result
cargo fmt --check pass
cargo clippy --workspace --all-targets -- -D warnings pass (0 warnings)
cargo test --workspace 305 passed, 0 failed — market-core 97, resolver 107, treasury 95, reasoning-registry 6
stellar contract build → vendor wasm → rebuild pass, 2 builds
git diff --exit-code -- contracts/{resolver,treasury}/market_core.wasm contracts/resolver/reasoning_registry.wasm pass (exit 0)
python3 model/fpmm_model.py pass (3.12, as pinned)
./scripts/gen-bindings.sh + git diff --exit-code -- packages/bindings pass (exit 0), and re-running generation a second time is also diff-clean
npm install && npm run build × market-core / resolver / treasury / reasoning-registry pass ×4
x402: npm ci && npm run build && npm test pass — 5/5
agents: npm install && npm run build && npm test pass — 54/54
scripts/spec-hash crate tests pass — 11/11 (all golden commitment-hash vectors)

Resolutions worth flagging

Four of these were semantic collisions, not textual ones:

  1. Error code tag. develop filled 26-41 (CommitteeNotFound … SnapshotExists). This branch's InvalidOutcomeIndex was also 26, which would have silently aliased two errors — it moves to 42.
  2. ResolutionSpec tag order. Categorical/scalar keep tags 4/5 because this branch's golden_legacy_scalar_spec_hashes pins tag 5; develop's committee/hybrid append at 6/7. Variants remain append-only, and all golden vectors pass.
  3. DataKey order. Both sides append: develop's V2/migration keys first, then this branch's categorical keys, so no stored tag moves on either side.
  4. cancel_proposal. develop's constrained guardian wins (typed GuardianReason, MAX_CANCELS_PER_MARKET, dispute-window bound, GuardianAction event) and now also clears the separate categorical proposal slot. cat_proposal_opens_with_quorum_and_guards_expiry is re-sequenced accordingly: the quorum now lands after the grace deadline so the proposal is still inside its own window when the guardian cancels it (previously the cancel happened past the window, which the new bound correctly rejects with DisputeWindowClosed).

Also in treasury: develop's price-checked do_agent_buy replaces this branch's copy, agent_buy_cat is rewritten on the agent-registry helpers (require_agent / check_trade_size / authorize_token_pull / reserve_capacity / reserve_net), both categorical tests port to the agent-first signature, and the private settle_value mirror gains the ResolvedScalar arm (matching market-core's math::scalar_payout) because the merged match is exhaustive. ADR renumbered 0005 → 0006 (develop consumed 0003 and 0005).

Why committed wasm/bindings are part of this PR

Both git diff --exit-code gates are already red on an untouched develop checkout at 6f186e6 with the CI-pinned toolchain, i.e. the vendored market_core.wasm / reasoning_registry.wasm and packages/bindings/{resolver,treasury}/src/index.ts were stale before this branch existed. Regenerating and committing them is what satisfies those two steps; nothing else in packages/bindings changed.

Environment disclosure

Measured on macOS arm64 — stellar 27.1.0 (same as CI's pinned STELLAR_CLI_VERSION), rustc 1.98.1, python 3.12, node v22.23.3 (CI pins Node 20). The CI dtolnay/rust-toolchain@stable is unpinned, so if CI's stable resolves to a different rustc the wasm bytes can differ; that is the pre-existing reason these two gates were red before this PR, not a regression introduced here.

…al-scalar-dynamic-fees

Resolves the textual conflicts against develop, which has advanced since this
branch last merged (PR OracleDesk#182).

Source conflicts resolved:
- .env.example, agents/bin/trader.ts, agents/core/fpmm.test.ts,
  agents/stellar/adapter.ts, docs/architecture.md, model/fpmm_model.py,
  contracts/market-core/src/lib.rs, contracts/resolver/src/lib.rs.
  trader.ts/adapter: our inline trader + getMarketView/currentFeeBps kept
  alongside develop's register/reveal-spec entrypoints (our getMarket renamed to
  getMarketView to avoid a name clash with develop's getMarket).

Safe, independent fixes applied on top of the merge:
- rust-toolchain.toml: 1.90.0 -> 1.93.0 (soroban-sdk 27.0.6 needs >=1.91; CI's
  stellar-cli 27.1.0 needs >=1.93; 1.90 failed before any repo command).
- contracts/oracledesk-types/src/lib.rs: add the missing `contracttype` import
  (develop ships the attribute without importing it; the crate cannot compile).
  Also carries this branch's CatSignerConfig/ScalarPriceConfig/ResolvedScalar
  and ResolutionSpec tags 6/7.
- scripts/verify-wasm-hash.rs: convert the `//!` header to `//` (rustc 1.93
  rejects inner doc-comments inside the include!d non-crate-root fragment).

Generated artifacts are NOT regenerated — they mirror develop's committed
bytes verbatim (packages/bindings/{market-core,resolver,treasury}/src/index.ts,
contracts/resolver/{market_core,reasoning_registry}.wasm,
contracts/treasury/market_core.wasm, and their .sha256 pins). Reason: the
workspace wasm build cannot run, because contracts/reasoning-registry/src/lib.rs
on develop is corrupt at tip d75e2fa (unrecoverable without authoring contract
logic we will not fabricate).

This branch is therefore NOT CI-green. See the PR comment for the enumerated,
re-measured develop-caused breakages and the follow-up owed on this branch's
market-core test module.
@MFrancis-dev

Copy link
Copy Markdown
Author

Status: merged with develop, conflicts resolved — but this PR cannot go green while develop is uncompilable

Head is now 68c0e55 (merge of upstream/develop @ d75e2fa, which includes PR #182). All textual conflicts are resolved. This is a fast-forward merge commit, no force-push.

I want to be straight about CI: I can fix the merge conflicts, but I cannot make this PR green, because develop itself does not compile. Four of the blockers are on develop and pre-date this PR; one is owed on my branch and I'll fix it in a follow-up. Details with reproduction commands below.

What landed in 68c0e55

Conflicts resolved (source): .env.example, agents/bin/trader.ts, agents/core/fpmm.test.ts, agents/stellar/adapter.ts, contracts/market-core/src/lib.rs, contracts/resolver/src/lib.rs, docs/architecture.md, model/fpmm_model.py. For the traders, our inline main + getMarketView/currentFeeBps are kept alongside develop's register/reveal-spec entrypoints; our old getMarket was renamed getMarketView to avoid a name clash with develop's getMarket.

Generated artifacts are NOT regenerated — they are committed byte-for-byte from develop: packages/bindings/{market-core,resolver,treasury}/src/index.ts, the vendored contracts/resolver/{market_core,reasoning_registry}.wasm + contracts/treasury/market_core.wasm, and their .sha256 pins. Reason in (4) below: regenerating them needs a full workspace stellar contract build, which is blocked by develop's corrupt reasoning-registry. I will not hand-author generated files or ship a half-rebuilt wasm that CI cannot reproduce.

Three independent compile fixes applied on top of the merge — these fix develop's broken build, not my feature:

  1. rust-toolchain.toml: 1.90.0 → 1.93.0.
  2. contracts/oracledesk-types/src/lib.rs: add the missing contracttype import.
  3. scripts/verify-wasm-hash.rs: //! header → // (rustc 1.93 rejects inner doc-comments in the include!d fragment).

CI on GitHub right now

The 5 workflows (CI, Coverage, Local E2E, Kani proofs, Formal model) are all in action_required — this is a fork PR, so a maintainer must approve the run before it executes. That's why mergeable_state reads unstable.

Re-measured locally on this head (Rust 1.93, cargo check)

Passes: oracledesk-types (lib, after fix 2), market-core (lib, 3 warnings), router/timelock libs, python3 model/fpmm_model.py.

Fails — pre-existing on develop, not caused by #161:

(1) contracts/reasoning-registry/src/lib.rs:948 — hard parse error, blocks everything

$ cargo check -p reasoning-registry --lib
error: unexpected closing delimiter: `}`
  --> contracts/reasoning-registry/src/lib.rs:948:5
error: could not compile `reasoning-registry` (lib) due to 1 previous error

publish_trace_batch is a bad splice: the impl block closes early at line 867; the function then validates action/ipfs_cid (which are not its parameters — its signature is env, agent, root, leaf_count, manifest_cid) and return Err(Error::ActionTooLong)/EmptyCid, ending in Ok(()) against a Result<u64, Error> signature; and lines 895–948 are an orphan second block referencing undefined id/market_id/action. This is consensus-critical on-chain logic. I deliberately did not invent a replacement — guessing what the correct anchoring semantics are and shipping it would be worse than the break. Because a parse error stops the whole workspace, this alone fails cargo test --workspace, cargo clippy --workspace --all-targets, and stellar contract build.

(2) develop's committed oracledesk-types won't compile
Line 27 imports {Address, BytesN, Symbol, Vec} but the file uses #[contracttype] 20× — the contracttype attribute macro is never imported. (Fix 2 above resolves it on this branch; it still needs the same fix on develop.)

(3) the toolchain pin can't build the lockfile or the CLI
Cargo.lock pins soroban-sdk 27.0.6 (needs rustc ≥ 1.91) and CI installs stellar-cli 27.1.0 (needs ≥ 1.93). With 1.90.0, every Rust job fails before reaching a repo command. (Fix 1 above resolves it.)

(4) vendored wasm drifts from its own committed pin

$ shasum -a 256 contracts/resolver/market_core.wasm   # (develop tip)
ec19a91eb4cdf6a1bd9aee21442d77d330c4e2f994556ad75813cd030eb90dfa
$ cat contracts/resolver/market_core.wasm.sha256       # (develop tip)
a3c539eb6de113f0988a8d208f92d92296bcbf91ad519fa1f1a7882b83a028a3

resolver's and treasury's build.rs verify_wasm_hash() therefore panics → a compile error in both crates, independent of anything in #161. This is why the vendored copies + pins are checked in verbatim: the drift is develop's own and I'm reproducing it, not masking it.

Fails — owed on THIS branch (I will fix in a follow-up commit, not fabricate it now):

(5) market-core test module placement. cargo check -p market-core --tests reports 78 errors. The lib compiles, but my #6/#7/#9 feature tests (cat_redeem_after_resolution, the scalar tests, the dynamic-fee tests) landed outside mod test, so setup_market/new_market/MockAuth/lp_fees_stored are out of scope. Purely a test-stitching fix on my side; the feature logic is intact.

Recommendation

A small maintainer PR into develop is the unblocker, doing (1) repair reasoning-registry::publish_trace_batch, (2) add the contracttype import to oracledesk-types, (3) raise the toolchain to 1.93, (4) re-sync the vendored wasm + .sha256 via scripts/sync-vendored-wasm.sh. Once develop is buildable, I'll regenerate this branch's bindings/wasm and land (5), and #161 can go green.

Happy to open (2)/(3)/(4) as a separate PR from our side if you'd prefer the fixes to come from a contributor — but (1) needs someone who owns the reasoning-registry contract to confirm the intended batch-anchoring semantics, which is why I've stopped short of patching it here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant