Skip to content

fix(resolver): SEP-40-correct price thresholds (#26) and TWAP resolution variant (#27) - #171

Open
achiever2110 wants to merge 9 commits into
OracleDesk:developfrom
achiever2110:fix/sep40-price-resolution-26
Open

achiever2110 wants to merge 9 commits into
OracleDesk:developfrom
achiever2110:fix/sep40-price-resolution-26

Conversation

@achiever2110

@achiever2110 achiever2110 commented Oct 19, 2026 •

Copy link
Copy Markdown

Implements #26 (SEP-40-correct price resolution) and #27 (TWAP resolution variant) in contracts/resolver, rebased onto current develop and re-resolved against PR #176's shared-types layout.

Closes #26
Closes #27

What landed

#26 — SEP-40 price resolution (original scope of this PR):

  • Grid-aligned feed lookups: price(asset, ts) is queried at close_time - close_time % resolution() and walked back along the grid, bounded by MAX_LOOKBACK_RECORDS = 12 (fixes every off-boundary close missing its record).
  • InvalidPrice for non-positive prices and resolution() == 0 feeds; PriceUnavailable for trapping feeds — never a host error.
  • Decimal-explicit scaled thresholds: ScaledPriceConfig (+ MAX_THRESHOLD_DECIMALS = 38) rescales threshold and feed price to max(feed_decimals, threshold_decimals) with checked i128 arithmetic; register_scaled_price_spec; PriceResolutionScaled event; faithful SEP-40 exact-grid test mock.
  • scripts/spec-hash price-scaled mode (incl. --decimal 0.20, exact no-float conversion).

#27 — TWAP resolution variant (folded on top as separate commits):

  • TwapConfig { reflector, asset, threshold, direction, window, min_samples, max_gap } with register_twap_spec / resolve_twap (resolve_price dispatches TWAP markets too, mirroring the scaled dispatch).
  • Sampling at resolution()-aligned timestamps in (close_time − window, close_time]; missing records skipped; duplicate timestamps (nearest-earlier feeds) counted once; < min_samples or any gap (start→first, consecutive, last→close) > max_gap → StalePrice; price ≤ 0 or overflow → InvalidPrice.
  • Integer-only time-weighted average: i128 checked_mul/checked_add/checked_div, weight = time to next sample, newest sample weighted close_time − last_ts + 1 (closing second included; both sides of the division use one convention), floor division before the committed comparison.
  • MAX_TWAP_SAMPLES = 32 hard cap as a cap on reads inspected, not samples found (the loop in the issue sketch is unbounded when records are missing — this closes that).
  • DataKey::PriceTwap(u64) appended last; assert_unconfigured extended to the third key (no double-registration across the three price shapes); snapshot_price/preview_price reply WrongMode; TwapResolution { market_id, twap, samples, first_ts, last_ts, outcome } emitted alongside the unchanged PriceResolution/V2 pair; expire_market prefers a resolvable TWAP settle and voids on a dead feed like the other price shapes.
  • scripts/spec-hash price-twap subcommand + tests.

Conflict resolution against develop (why the commits differ from the pre-rebase head)

The previous head was based on 5cba67b2; develop@655974c4 moved PriceConfig/SignerConfig/ResolutionSpec/ResolutionSpecV2 into contracts/oracledesk-types (PR #176), which the resolver re-exports. Router and treasury flow resolver::ResolutionSpec straight into ResolutionSpecV2.rule, so the enum cannot be shadowed locally and the types crate is not ours to edit here. Resolution, append-only and hash-safe:

  • The scaled mode (Make PriceThreshold resolution SEP-40 correct: decimals, resolution periods, bad prices #26) and the TWAP mode (Add TWAP-based price resolution sampled from Reflector history #27) each commit through their own domain-tagged preimage — resolver::scaled_spec_hash = sha256("oracledesk:scaled-price-spec" || XDR(cfg)), resolver::twap_spec_hash = sha256("oracledesk:price-twap-spec" || XDR(cfg)) — the same disjointness construction ADR 0004 uses for V2 (legacy preimages are ScVal XDR starting 00 00 00 10; the four tags differ at fixed offsets). Every already-committed Price / Signers / V2 resolution_hash is byte-for-byte unchanged, and the ADR-0001 golden vectors still pin that.
  • DataKey::PriceScaled / PriceTwap are appended last in the resolver's own key enum (positional discriminants; earlier keys keep their encoding).
  • scripts/spec-hash delegates to the contract's own hash functions, so tool and contract cannot drift. The literal price-scaled golden hex pre-dates Feat/multi 60 59 58 57 #176 and is not re-pinnable by any runnable test at this tip (see Gates); its test now pins delegation equality + hash-space disjointness + --v2 rejection, with the re-pin listed as maintainer follow-up. Same discipline for the new price-twap vector.
  • Canonical end state (append PriceScaled/PriceTwap variants into oracledesk-types::ResolutionSpec, then simplify the CLI) is documented in ADR 0001 as maintainer follow-up.

Issue-text traps, handled honestly

Gates — actual numbers at the pushed head

develop itself does not compile, so no cargo test suite can execute at this tip. Two independent, upstream-owned death vectors (neither file appears in git diff --name-only 655974c4 HEAD — verified count 0):

  1. contracts/oracledesk-types/src/lib.rs:27 never imports contracttype (used ~16×) → 21 errors on +1.91.0 for every dependent crate; the pinned 1.90.0 is refused by soroban-sdk@27.0.6's MSRV. (Recorded in the lane log runs/L139__abercrombie04__OracleDesk__184-rebase-develop-17-19/gate_attempt_rebase_tip_191.log.)
  2. The resolver's build.rs chain compiles scripts/verify-wasm-hash.rs, which fails with 14× E0753 (inner doc comments in a non-module file).

The gate this PR was expected to clear, run twice around Phase B (cargo +1.91.0 test --manifest-path scripts/spec-hash/Cargo.toml, via the repo sandbox, warm dep cache):

  • Before Phase B: exit 101, 0 tests executed (test result lines: 0), 14× E0753.
  • After Phase B: exit 101, 0 tests executed (test result lines: 0), 14× E0753 — byte-identical failure mode. Verbatim (path elided):
    error[E0753]: expected outer doc comment
      --> .../contracts/resolver/../../scripts/verify-wasm-hash.rs:1:1
    ...
    error: could not compile `resolver` (build script) due to 14 previous errors
    
    My diff adds no new errors (identical counts and error set pre/post), but the golden-hash suite genuinely executes 0 tests at this tip — I am not claiming a pass. Last green for these suites was at the old pre-rebase base: resolver 117 passed / 0 failed, spec-hash 12 passed (recorded in the rescue lane for the previous head). Restoring the build gate (oracledesk-types import fix, verify-wasm-hash.rs comment fix) is repo-owned and out of this PR's scope; once restored, the suites here are expected to run unchanged.
  • contracts/treasury is red at develop independently (E0081, contracts/treasury/src/lib.rs:473-498); nothing in this PR touches it, and no --workspace run is used to average the two together.

Gates that do execute here, and did:

  • Standalone rustfmt --edition 2021 --check: scripts/spec-hash/src/lib.rs clean; contracts/resolver/src/lib.rs complaint set identical to pristine develop (12 pre-existing sites, content-compared via normalized multiset diff — 0 new complaints introduced, upstream sites deliberately not reformatted).
  • git diff 655974c4 HEAD -- '*.wasm' | wc -c → 0 (and *.sha256 → 0): no deployed blob was silently mutated; bindings/wasm rebuild is listed below.

Tests added (source-level; execution blocked as above)

#26: grid alignment incl. off-boundary close, walkback cap & staleness, resolution()==0, zero/negative price, direction×price matrix, foreign-asset isolation, preview/snapshot mode guards, --decimal equivalence, scaled comparison/equality/overflow/mutual-exclusion, commitment cross-checks against scripts/spec-hash vectors. #27 (16 new tests): twap_resolves_yes_when_average_above_threshold_despite_last_print_below, twap_resolves_no_when_single_spike_at_close, twap_rejects_insufficient_samples, twap_rejects_gap_larger_than_max_gap (all three gap kinds), twap_weighting_exact_known_value (hand-computed 4-sample case), twap_overflow_returns_invalid_price, twap_rejects_nonpositive_sample, register_twap_spec_infeasible_window_rejected (incl. unreachable-reflector PriceUnavailable), cannot_register_twap_when_price_spec_exists (both directions + scaled + WrongMode guards), resolve_twap_lifecycle, resolve_price_dispatches_twap_markets, twap_emits_resolution_events_with_avg_and_window, twap_read_cap_bounds_window_of_missing_records, twap_ignores_duplicate_records_from_nearest_earlier_feed (mock gains a nearest-earlier mode), plus expire-resolve-preferred and expire-void-on-dead-feed; spec-hash gains golden_price_twap_vector (delegation, disjointness vs price/signers/scaled, verify round-trip, --v2 rejection) and arity/type rejection cases.

Maintainer follow-ups (deliberately not done here)

  1. Rebuild wasm + regenerate bindings once the build gate compiles (7 .wasm blobs are unverifiable here — this PR proves a zero-diff instead of a fake rebuild).
  2. Re-pin the literal price-scaled/price-twap golden hexes by an actual host run.
  3. Append PriceScaled/PriceTwap variants into oracledesk-types::ResolutionSpec (append-only) once that crate's toolchain wall is fixed.
  4. Measure MAX_TWAP_SAMPLES CPU per the issue text and reconcile with 32.

No approving review was invalidated by the force-pushes: the review count on this PR was 0 before and is 0 after (three --force-with-lease pushes: rescue rebase f3d71e30 → 255f760, then Phase B in two steps 255f760 → 84310df → 05c508c).

@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@achiever2110 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

@achiever2110
achiever2110 force-pushed the fix/sep40-price-resolution-26 branch from 35d61e8 to f3d71e3 Compare October 20, 2026 17:01
…ck (OracleDesk#26)

Extend the ReflectorPrice client trait with SEP-40 decimals()/resolution()/
lastprice(); align resolve-time lookups to the feed's resolution() grid with a
bounded walkback (MAX_LOOKBACK_RECORDS=12) within max_staleness; reject
price <= 0 with InvalidPrice and resolution()==0 as a broken feed; add the
read-only preview_price view; replace MockReflector with Sep40Mock (per-asset
exact-timestamp histories, configurable decimals/resolution, set_panic) and
cover grid alignment, staleness boundaries, lookback cap, bad prices, asset
isolation and a 4x3 direction matrix. Legacy price spec hashes are unchanged
(golden vectors still pass).
…racleDesk#26)

Add ResolutionSpec::PriceScaled(ScaledPriceConfig) as the LAST enum variant
so every earlier variant's positional XDR discriminant (and thus every
committed resolution_hash) is unchanged — the golden vectors in mod golden
and scripts/spec-hash still pass verbatim. Document the legacy Price
threshold as feed-native units. register_scaled_price_spec reveals it;
resolve_price settles it by reading the grid-aligned record, querying
decimals(), and rescaling both sides to max(feed_decimals,
threshold_decimals) with checked i128 math (InvalidConfig past
MAX_THRESHOLD_DECIMALS at reveal, InvalidPrice on overflow at resolve).
Storage uses a new DataKey::PriceScaled appended last; assert_unconfigured,
expiry_mode and the guardian OracleUnavailable paths recognize it, and
InvalidPrice now counts as feed-unusable for void/expiry. Emits the new
PriceResolutionScaled event alongside the untouched PriceResolution/V2.
…ts (OracleDesk#26)

spec-hash gains a `price-scaled` command (positional threshold_decimals or
an exact `--decimal 0.20` string, no floats) and verify support, delegating
all hashing to the resolver's own legacy_spec_hash/spec_v2_hash so it cannot
drift. A golden vector pins the new variant's hash; the legacy golden vectors
still pass unchanged.

ADR 0001 gains a "Price threshold units" section (legacy Price is
feed-native and frozen; PriceScaled carries threshold_decimals rescaled to
max(feed, threshold) with checked i128 math; append-last keeps every
committed hash byte-identical), and its liveness "Unusable feed" bullet now
names StalePrice/InvalidPrice alongside PriceUnavailable. architecture.md's
PriceThreshold bullet is corrected from "at (or after)" close_time to the
actual grid-aligned at-or-before lookup with the MAX_LOOKBACK_RECORDS
walkback, documents PriceScaled and preview_price, and the snapshot
paragraph matches validate_price's non-positive-price rejection.
…acleDesk#26)

Formats the two files the OracleDesk#26 work touches (`cargo fmt` baseline for the
resolver was clean; scripts/spec-hash carried two pre-existing rustfmt
diffs in parse_committee/parse_hybrid that a whole-file format resolves),
collapses the guardian-reasons nested if flagged by clippy, and drops the
three needless borrows in parse_price_scaled.
…e under OracleDesk#176 shared types (OracleDesk#26)

PR OracleDesk#176 moved ResolutionSpec/PriceConfig/SignerConfig to contracts/oracledesk-types
and resolver re-exports them, so issue OracleDesk#26's decimal-scaled threshold can no longer
be a seventh ResolutionSpec variant (router and treasury flow resolver::ResolutionSpec
into ResolutionSpecV2.rule; a local shadow enum would break both crates, which this
PR must not touch). The scaled mode now commits through
resolver::scaled_spec_hash = sha256("oracledesk:scaled-price-spec" || XDR(ScaledPriceConfig)),
a third domain-tagged hash space exactly in the ADR-0004 style; every existing
Price/Signers/V2 preimage is byte-untouched. scripts/spec-hash delegates to the new
function (ParsedRule::Scaled), the stale literal pin for the old enum-variant preimage
is replaced by delegation/disjointness/V2-rejection assertions, and ADR 0001 +
architecture.md are retargeted. Canonical follow-up (documented): append PriceScaled
into oracledesk-types once that crate's missing contracttype import is fixed upstream.
@achiever2110
achiever2110 force-pushed the fix/sep40-price-resolution-26 branch from f3d71e3 to 255f760 Compare October 20, 2026 20:35
OracleDesk#27)

Adds the time-weighted-average price rule of issue OracleDesk#27 on top of the OracleDesk#26
machinery it reuses (SEP-40 grid alignment from read_feed_on_grid):

- TwapConfig + register_twap_spec / resolve_twap, committing through a
  third domain-tagged hash space (twap_spec_hash) so every legacy
  Price/Signers/V2 and scaled commitment stays byte-for-byte valid
  (same ADR-0001 argument as the scaled mode; the ResolutionSpec enum
  stays frozen in oracledesk-types as PR OracleDesk#176 made it).
- DataKey::PriceTwap appended last (positional discriminants),
  assert_unconfigured extended to the third key, closing the
  double-registration hole; WrongMode guards on snapshot/preview.
- Sampling at resolution()-aligned timestamps in (close_time-window,
  close_time], capped at MAX_TWAP_SAMPLES reads (not found samples),
  duplicate timestamps counted once; min_samples/gap failures are
  StalePrice, non-positive or overflowing prices InvalidPrice;
  i128 checked_mul/checked_add, floor division, last weight is
  close_time - last_ts + 1.
- MAX_TWAP_SAMPLES = 32 is reasoned from the crate's own accepted
  ceilings (MAX_WEIGHTED_SIGNERS/MAX_COMMITTEE 32-signature loops),
  not measured: the CPU measurement the issue asks for cannot run at
  this tip; documented in the ADR text and code.
- Registration validates statically AND against the live feed
  (window/resolution <= MAX_TWAP_SAMPLES, min_samples <= slots),
  failing unresolvable markets early.
- TwapResolution event alongside the unchanged PriceResolution/V2
  pair; expire_market prefers a resolvable TWAP settle and voids on
  a dead feed like the other price shapes.

Also folds the rebase integration fix: settle_scaled must propagate
settle_price's Result introduced by OracleDesk#176.
Adds the `price-twap` subcommand (shared <asset>/<direction> parsers were
already factored out of parse_price by OracleDesk#26's work, so issue step 7 needed
no further refactor) hashing through the contract's own twap_spec_hash, a
ParsedRule::Twap arm, --v2-envelope rejection, and verify-kind plumbing.

golden_price_twap_vector pins delegation equality, hash-space disjointness
against price/signers/price-scaled preimages, verify round-trip and --v2
rejection; the pinned legacy vectors keep proving every existing
Price/Signers/V2 hash is unchanged. A literal twap hex golden is a listed
maintainer follow-up (no host run is possible while the in-tree build gate
fails), same disclosure as the scaled pin.
)

Documents the fourth commitment hash space ("oracledesk:price-twap-spec"),
the sampling/gap/weighting rules (last weight close_time - last_ts + 1,
floor division, feed-native threshold), and records explicitly that
MAX_TWAP_SAMPLES = 32 is REASONED from the crate's own 32-signature
weighted-keys ceiling, not measured: the CPU measurement issue OracleDesk#27 asks
for cannot execute while the in-tree build gate fails, so no benchmark
number is claimed. Canonical variant-append into oracledesk-types and the
literal golden re-pin remain listed maintainer follow-ups.
@achiever2110 achiever2110 changed the title fix(resolver): make PriceThreshold resolution SEP-40 correct (#26) fix(resolver): SEP-40-correct price thresholds (#26) and TWAP resolution variant (#27) Oct 20, 2026
anitahhhh added a commit to anitahhhh/OracleDesk-SmartContract that referenced this pull request Oct 21, 2026
…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.
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