Repository navigation
fix(resolver): SEP-40-correct price thresholds (#26) and TWAP resolution variant (#27) - #171
Open
achiever2110 wants to merge 9 commits into
Open
achiever2110 wants to merge 9 commits into
achiever2110 wants to merge 9 commits into
Conversation
|
@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! 🚀 |
achiever2110
force-pushed
the
fix/sep40-price-resolution-26
branch
from
October 20, 2026 17:01
35d61e8 to
f3d71e3
Compare
…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
force-pushed
the
fix/sep40-price-resolution-26
branch
from
October 20, 2026 20:35
f3d71e3 to
255f760
Compare
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.
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.
5 of 7 tasks
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.
Implements #26 (SEP-40-correct price resolution) and #27 (TWAP resolution variant) in
contracts/resolver, rebased onto currentdevelopand 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):
price(asset, ts)is queried atclose_time - close_time % resolution()and walked back along the grid, bounded byMAX_LOOKBACK_RECORDS = 12(fixes every off-boundary close missing its record).InvalidPricefor non-positive prices andresolution() == 0feeds;PriceUnavailablefor trapping feeds — never a host error.ScaledPriceConfig(+MAX_THRESHOLD_DECIMALS = 38) rescales threshold and feed price tomax(feed_decimals, threshold_decimals)with checkedi128arithmetic;register_scaled_price_spec;PriceResolutionScaledevent; faithful SEP-40 exact-grid test mock.scripts/spec-hashprice-scaledmode (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 }withregister_twap_spec/resolve_twap(resolve_pricedispatches TWAP markets too, mirroring the scaled dispatch).resolution()-aligned timestamps in(close_time − window, close_time]; missing records skipped; duplicate timestamps (nearest-earlier feeds) counted once;< min_samplesor any gap (start→first, consecutive,last→close)> max_gap→StalePrice;price ≤ 0or overflow →InvalidPrice.i128checked_mul/checked_add/checked_div, weight = time to next sample, newest sample weightedclose_time − last_ts + 1(closing second included; both sides of the division use one convention), floor division before the committed comparison.MAX_TWAP_SAMPLES = 32hard 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_unconfiguredextended to the third key (no double-registration across the three price shapes);snapshot_price/preview_pricereplyWrongMode;TwapResolution { market_id, twap, samples, first_ts, last_ts, outcome }emitted alongside the unchangedPriceResolution/V2pair;expire_marketprefers a resolvable TWAP settle and voids on a dead feed like the other price shapes.scripts/spec-hashprice-twapsubcommand + tests.Conflict resolution against develop (why the commits differ from the pre-rebase head)
The previous head was based on
5cba67b2;develop@655974c4movedPriceConfig/SignerConfig/ResolutionSpec/ResolutionSpecV2intocontracts/oracledesk-types(PR #176), which the resolver re-exports. Router and treasury flowresolver::ResolutionSpecstraight intoResolutionSpecV2.rule, so the enum cannot be shadowed locally and the types crate is not ours to edit here. Resolution, append-only and hash-safe: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 starting00 00 00 10; the four tags differ at fixed offsets). Every already-committedPrice/Signers/ V2resolution_hashis byte-for-byte unchanged, and the ADR-0001 golden vectors still pin that.DataKey::PriceScaled/PriceTwapare appended last in the resolver's own key enum (positional discriminants; earlier keys keep their encoding).scripts/spec-hashdelegates to the contract's own hash functions, so tool and contract cannot drift. The literalprice-scaledgolden 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 +--v2rejection, with the re-pin listed as maintainer follow-up. Same discipline for the newprice-twapvector.PriceScaled/PriceTwapvariants intooracledesk-types::ResolutionSpec, then simplify the CLI) is documented in ADR 0001 as maintainer follow-up.Issue-text traps, handled honestly
read_feed_on_gridand its grid math), and the TWAP sampler reuses exactly that pattern. Nothing from Add on-chain market directories indexed by status, category and holder #25 was implemented here.env.cost_estimate()against the registered wasm) is not executable at this tip — no cargo test runs at all (verbatim below).MAX_TWAP_SAMPLES = 32is therefore reasoned, not measured: it matches the crate's own accepted ceilingsMAX_WEIGHTED_SIGNERS = 32/MAX_COMMITTEE = 32, and the weighted-keys path already verifies up to 32 ed25519 signatures in one call (documented at 23.1M instructions in ADR 0001), a per-item cost strictly above one bounded price read. No benchmark number is claimed; a measured re-derivation is a maintainer follow-up. Stated in the code, ADR 0001, and here.Gates — actual numbers at the pushed head
developitself does not compile, so no cargo test suite can execute at this tip. Two independent, upstream-owned death vectors (neither file appears ingit diff --name-only 655974c4 HEAD— verified count 0):contracts/oracledesk-types/src/lib.rs:27never importscontracttype(used ~16×) → 21 errors on+1.91.0for every dependent crate; the pinned1.90.0is refused bysoroban-sdk@27.0.6's MSRV. (Recorded in the lane logruns/L139__abercrombie04__OracleDesk__184-rebase-develop-17-19/gate_attempt_rebase_tip_191.log.)build.rschain compilesscripts/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):test resultlines: 0), 14×E0753.test resultlines: 0), 14×E0753— byte-identical failure mode. Verbatim (path elided):resolver117 passed / 0 failed,spec-hash12 passed (recorded in the rescue lane for the previous head). Restoring the build gate (oracledesk-typesimport fix,verify-wasm-hash.rscomment fix) is repo-owned and out of this PR's scope; once restored, the suites here are expected to run unchanged.contracts/treasuryis red atdevelopindependently (E0081,contracts/treasury/src/lib.rs:473-498); nothing in this PR touches it, and no--workspacerun is used to average the two together.Gates that do execute here, and did:
rustfmt --edition 2021 --check:scripts/spec-hash/src/lib.rsclean;contracts/resolver/src/lib.rscomplaint set identical to pristinedevelop(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,--decimalequivalence, scaled comparison/equality/overflow/mutual-exclusion, commitment cross-checks againstscripts/spec-hashvectors. #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-reflectorPriceUnavailable),cannot_register_twap_when_price_spec_exists(both directions + scaled +WrongModeguards),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 gainsgolden_price_twap_vector(delegation, disjointness vs price/signers/scaled, verify round-trip,--v2rejection) and arity/type rejection cases.Maintainer follow-ups (deliberately not done here)
.wasmblobs are unverifiable here — this PR proves a zero-diff instead of a fake rebuild).price-scaled/price-twapgolden hexes by an actual host run.PriceScaled/PriceTwapvariants intooracledesk-types::ResolutionSpec(append-only) once that crate's toolchain wall is fixed.MAX_TWAP_SAMPLESCPU 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-leasepushes: rescue rebasef3d71e30 → 255f760, then Phase B in two steps255f760 → 84310df → 05c508c).