Skip to content

Commit 0885827

Browse files
Merge branch 'master' into task/resolution-rustdoc
2 parents c53aca2 + 489fb5a commit 0885827

158 files changed

Lines changed: 27813 additions & 15946 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/wasm-size.yml

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
name: WASM Size Budget
22

3+
# Enforces per-contract WASM size budgets so accidental bloat is caught in CI
4+
# before it reaches mainnet deployment. Budgets are intentionally conservative
5+
# and should be updated via a deliberate PR if a contract genuinely needs more
6+
# space (e.g. a new feature module).
7+
#
8+
# Budget table (release build, wasm32v1-none, opt-level=z + LTO):
9+
# hello-world : 20 KB – toy example, must stay tiny
10+
# governance-events : 24 KB – pure event types + emitters, no logic
11+
# predictify-hybrid : 900 KB – full prediction-market contract
12+
#
13+
# The governance-events crate was added in FWC26 (Stellar Wave).
14+
315
on:
416
push:
517
branches: [ "master", "main" ]
@@ -24,23 +36,80 @@ jobs:
2436
brew update
2537
brew install stellar-cli
2638
27-
- name: Build Contracts
39+
- name: Build contracts (release, wasm32v1-none)
2840
run: |
2941
source $HOME/.cargo/env
3042
stellar contract build
3143
32-
- name: Check WASM Size
44+
# ── Per-contract size gates ──────────────────────────────────────────────
45+
# Each WASM is checked against its own budget. Any file that does not
46+
# match a known contract name triggers a strict 20 KB fallback so unknown
47+
# contracts cannot sneak through with unbounded size.
48+
49+
- name: Check WASM sizes
3350
run: |
34-
MAX_SIZE=102400 # 100 KB budget
35-
# stellar contract build usually outputs to target/wasm32v1-none/release/*.wasm
51+
set -euo pipefail
52+
source $HOME/.cargo/env
53+
54+
# Budgets in bytes (release + opt-level=z + LTO)
55+
BUDGET_HELLO_WORLD=20480 # 20 KB
56+
BUDGET_GOVERNANCE_EVENTS=24576 # 24 KB (FWC26 governance-events crate)
57+
BUDGET_PREDICTIFY_HYBRID=921600 # 900 KB
58+
BUDGET_FALLBACK=20480 # 20 KB (any unknown contract)
59+
60+
FAIL=0
61+
3662
for wasm_file in target/wasm32v1-none/release/*.wasm; do
37-
if [ -f "$wasm_file" ]; then
38-
SIZE=$(stat -f%z "$wasm_file")
39-
echo "Checking $wasm_file: $SIZE bytes"
40-
if [ "$SIZE" -gt "$MAX_SIZE" ]; then
41-
echo "::error::WASM size budget exceeded for $wasm_file ($SIZE bytes > $MAX_SIZE bytes)"
42-
exit 1
43-
fi
63+
[ -f "$wasm_file" ] || continue
64+
65+
SIZE=$(stat -f%z "$wasm_file")
66+
BASE=$(basename "$wasm_file" .wasm)
67+
68+
case "$BASE" in
69+
hello_world)
70+
BUDGET=$BUDGET_HELLO_WORLD ;;
71+
governance_events)
72+
BUDGET=$BUDGET_GOVERNANCE_EVENTS ;;
73+
predictify_hybrid)
74+
BUDGET=$BUDGET_PREDICTIFY_HYBRID ;;
75+
*)
76+
BUDGET=$BUDGET_FALLBACK ;;
77+
esac
78+
79+
BUDGET_KB=$(( BUDGET / 1024 ))
80+
SIZE_KB=$(echo "scale=1; $SIZE / 1024" | bc)
81+
82+
if [ "$SIZE" -gt "$BUDGET" ]; then
83+
echo "::error file=$wasm_file::WASM size budget exceeded: $BASE = ${SIZE_KB} KB (budget ${BUDGET_KB} KB)"
84+
FAIL=1
85+
else
86+
echo "OK $BASE: ${SIZE_KB} KB / ${BUDGET_KB} KB"
4487
fi
4588
done
46-
echo "All WASM files are within the 100 KB budget."
89+
90+
if [ "$FAIL" -eq 1 ]; then
91+
echo ""
92+
echo "One or more contracts exceeded their WASM size budget."
93+
echo "If the growth is intentional, update the budget table in"
94+
echo ".github/workflows/wasm-size.yml with a justification comment."
95+
exit 1
96+
fi
97+
98+
echo ""
99+
echo "All contracts are within their WASM size budgets."
100+
101+
# ── Size report (informational, never fails the build) ───────────────────
102+
- name: Report WASM sizes
103+
if: always()
104+
run: |
105+
echo "### WASM size report" >> $GITHUB_STEP_SUMMARY
106+
echo "" >> $GITHUB_STEP_SUMMARY
107+
echo "| Contract | Size |" >> $GITHUB_STEP_SUMMARY
108+
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
109+
for wasm_file in target/wasm32v1-none/release/*.wasm; do
110+
[ -f "$wasm_file" ] || continue
111+
SIZE=$(stat -f%z "$wasm_file")
112+
SIZE_KB=$(echo "scale=1; $SIZE / 1024" | bc)
113+
BASE=$(basename "$wasm_file" .wasm)
114+
echo "| \`$BASE\` | ${SIZE_KB} KB |" >> $GITHUB_STEP_SUMMARY
115+
done
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"specId": "f34fcfc8-0919-4db6-af1e-7536d95694de", "workflowType": "requirements-first", "specType": "feature"}
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
# Requirements Document
2+
3+
## Introduction
4+
5+
The `predictify-hybrid` contract resolves prediction markets by consulting a primary oracle and,
6+
when configured, a fallback oracle. Currently, when the two oracle sources return data with
7+
conflicting staleness characteristics — for example, the primary data is fresh while the fallback
8+
data is stale, or both sources are stale — the contract does not emit a dedicated on-chain event
9+
to signal this cross-oracle staleness condition. Downstream consumers (indexers, monitoring
10+
dashboards, dispute tools) therefore have no structured, filterable signal indicating that a
11+
multi-source staleness anomaly occurred during a resolution attempt.
12+
13+
This feature adds a new `CrossOracleStalenessEvent` Soroban contract event that is emitted
14+
precisely when a cross-oracle staleness mismatch is detected in `OracleResolutionManager::fetch_oracle_result`
15+
and in the median-resolution path (`resolve_with_median`). It also adds a corresponding emitter
16+
method to `EventEmitter`, registers the event in `EventSchemaRegistry`, documents the new event in
17+
`docs/EVENT_SCHEMA.md`, and provides focused tests covering the happy-path and all staleness
18+
mismatch scenarios.
19+
20+
---
21+
22+
## Glossary
23+
24+
- **The Contract**: The `predictify-hybrid` Soroban smart contract.
25+
- **Primary_Oracle**: The oracle source identified by `market.oracle_config`.
26+
- **Fallback_Oracle**: The optional secondary oracle source identified by
27+
`market.fallback_oracle_config`, present when `market.has_fallback == true`.
28+
- **Cross-Oracle Staleness**: The condition where at least one oracle source's price data exceeds
29+
the effective `max_staleness_secs` threshold _and_ a second oracle source is present, so the
30+
staleness state differs across the two sources (one fresh, one stale; or both stale).
31+
- **OraclePriceData**: The struct returned by `OracleInterface::get_price_data`, containing
32+
`price`, `confidence`, and `publish_time` fields.
33+
- **Staleness Age**: `env.ledger().timestamp().saturating_sub(publish_time)` for a given
34+
`OraclePriceData` value.
35+
- **Effective Config**: The per-event `EventOracleValidationConfig` if set; otherwise the global
36+
`GlobalOracleValidationConfig`. Resolved by `OracleValidationConfigManager::get_effective_config`.
37+
- **EventEmitter**: The `EventEmitter` struct in `src/events.rs` that centralises all on-chain
38+
event emission.
39+
- **EventSchemaRegistry**: The `EventSchemaRegistry` struct in `src/events.rs` that maps event
40+
names to topic symbols and schema versions.
41+
- **topic symbol**: The ≤ 9-character `Symbol` used as the first element of the Soroban event
42+
topic tuple, created via `symbol_short!`.
43+
- **nonce**: The per-topic monotonically-increasing replay-protection counter managed by
44+
`EventEmitter::get_and_increment_nonce`.
45+
- **OracleResultEvent**: The existing `#[contracttype]` struct emitted after a successful oracle
46+
fetch, under topic `"oracle_rs"`.
47+
- **OracleValidationFailedEvent**: The existing `#[contracttype]` struct emitted when a single
48+
oracle source fails staleness or confidence validation, under topic `"orc_val"`.
49+
- **CrossOracleStalenessEvent**: The new `#[contracttype]` struct introduced by this feature,
50+
emitted under topic `"orc_xstl"` when a cross-oracle staleness condition is detected.
51+
52+
---
53+
54+
## Requirements
55+
56+
### Requirement 1: CrossOracleStalenessEvent struct
57+
58+
**User Story:** As a market indexer operator, I want a named, versioned on-chain event struct
59+
whenever two oracle sources have mismatched staleness, so that I can filter and alert on this
60+
condition without parsing untyped raw data.
61+
62+
#### Acceptance Criteria
63+
64+
1. THE Contract SHALL define a `CrossOracleStalenessEvent` struct annotated with `#[contracttype]`
65+
and `#[derive(Clone, Debug, Eq, PartialEq)]` in `src/events.rs`.
66+
67+
2. THE `CrossOracleStalenessEvent` SHALL contain the following fields exactly:
68+
- `market_id: Symbol` — identifier of the market being resolved
69+
- `primary_provider: String` — display name of the Primary_Oracle provider
70+
- `primary_feed_id: String` — feed ID used by the Primary_Oracle
71+
- `primary_age_secs: u64` — Staleness Age of the Primary_Oracle's data at detection time
72+
- `fallback_provider: String` — display name of the Fallback_Oracle provider
73+
- `fallback_feed_id: String` — feed ID used by the Fallback_Oracle
74+
- `fallback_age_secs: u64` — Staleness Age of the Fallback_Oracle's data at detection time
75+
- `max_age_secs: u64` — the `max_staleness_secs` from the Effective Config at detection time
76+
- `nonce: u64` — replay-protection nonce
77+
- `timestamp: u64``env.ledger().timestamp()` at emission time
78+
79+
3. IF any field listed in criterion 2 is absent or has a different type, THEN THE Contract SHALL
80+
fail to compile, preventing deployment of a malformed event schema.
81+
82+
---
83+
84+
### Requirement 2: EventEmitter emission method
85+
86+
**User Story:** As a contract developer integrating new oracle resolution paths, I want a single
87+
`EventEmitter::emit_cross_oracle_staleness` function, so that all call sites emit the event
88+
consistently without duplicating struct construction logic.
89+
90+
#### Acceptance Criteria
91+
92+
1. THE `EventEmitter` SHALL expose a public method with the signature:
93+
```
94+
pub fn emit_cross_oracle_staleness(
95+
env: &Env,
96+
market_id: &Symbol,
97+
primary_provider: &String,
98+
primary_feed_id: &String,
99+
primary_age_secs: u64,
100+
fallback_provider: &String,
101+
fallback_feed_id: &String,
102+
fallback_age_secs: u64,
103+
max_age_secs: u64,
104+
)
105+
```
106+
107+
2. WHEN `emit_cross_oracle_staleness` is called, THE Contract SHALL build a
108+
`CrossOracleStalenessEvent` struct populated with all provided parameters, with `nonce` set
109+
by `EventEmitter::get_and_increment_nonce` using the topic symbol for `"orc_xstl"`, and
110+
`timestamp` set to `env.ledger().timestamp()`.
111+
112+
3. WHEN `emit_cross_oracle_staleness` is called, THE Contract SHALL persist the event via
113+
`Self::store_event(env, &symbol_short!("orc_xstl"), &event)`.
114+
115+
4. WHEN `emit_cross_oracle_staleness` is called, THE Contract SHALL publish the event to the
116+
Soroban ledger stream via
117+
`env.events().publish((symbol_short!("orc_xstl"), market_id.clone()), event)`.
118+
119+
5. THE topic symbol for `CrossOracleStalenessEvent` SHALL be `symbol_short!("orc_xstl")`.
120+
121+
---
122+
123+
### Requirement 3: Detection in the dual-oracle resolution path
124+
125+
**User Story:** As a market participant, I want the contract to emit `CrossOracleStalenessEvent`
126+
whenever the dual-oracle path detects a staleness mismatch across sources, so that I can be
127+
alerted that resolution data quality may be degraded.
128+
129+
#### Acceptance Criteria
130+
131+
1. WHEN `OracleResolutionManager::fetch_oracle_result` fetches price data from both the
132+
Primary_Oracle and the Fallback_Oracle and the Staleness Age of either source exceeds
133+
`max_staleness_secs` from the Effective Config, THEN THE Contract SHALL call
134+
`EventEmitter::emit_cross_oracle_staleness` before returning from the function.
135+
136+
2. WHEN only one oracle source has a Staleness Age that exceeds `max_staleness_secs` (i.e., a
137+
partial staleness mismatch), THEN THE Contract SHALL still emit `CrossOracleStalenessEvent`.
138+
139+
3. WHEN both oracle sources have a Staleness Age that exceeds `max_staleness_secs`, THEN THE
140+
Contract SHALL emit `CrossOracleStalenessEvent` exactly once for the resolution call.
141+
142+
4. WHEN only the primary oracle is available (i.e., `market.has_fallback == false`), THEN THE
143+
Contract SHALL NOT emit `CrossOracleStalenessEvent`, because no cross-source comparison is
144+
possible.
145+
146+
5. WHEN `fetch_oracle_result` is called and the Primary_Oracle fetch itself returns an error
147+
before price data is available, THEN THE Contract SHALL NOT emit `CrossOracleStalenessEvent`
148+
for the primary source.
149+
150+
6. THE emission of `CrossOracleStalenessEvent` SHALL NOT alter the existing return value or
151+
error behaviour of `fetch_oracle_result`; cross-oracle staleness is an observability signal,
152+
not a resolution blocker.
153+
154+
---
155+
156+
### Requirement 4: Detection in the median-resolution path
157+
158+
**User Story:** As an on-chain analytics consumer, I want `CrossOracleStalenessEvent` to also be
159+
emitted when the multi-source median resolution path detects cross-oracle staleness, so that
160+
monitoring is consistent across both resolution strategies.
161+
162+
#### Acceptance Criteria
163+
164+
1. WHEN `OracleResolutionManager::resolve_with_median` collects price quotes from multiple oracle
165+
sources and the Staleness Age of any included quote exceeds `max_staleness_secs` from the
166+
Effective Config while at least one other quote is within the staleness limit, THEN THE
167+
Contract SHALL call `EventEmitter::emit_cross_oracle_staleness` once per resolution call.
168+
169+
2. WHEN all included quotes in `resolve_with_median` exceed `max_staleness_secs`, THEN THE
170+
Contract SHALL emit `CrossOracleStalenessEvent` exactly once, using the first two quotes as
171+
representative primary and fallback sources.
172+
173+
3. THE emission of `CrossOracleStalenessEvent` in `resolve_with_median` SHALL NOT alter the
174+
existing return value or error behaviour of that function.
175+
176+
---
177+
178+
### Requirement 5: EventSchemaRegistry registration
179+
180+
**User Story:** As a contract integrator using `EventSchemaRegistry::get_schema`, I want the new
181+
event to be registered in the registry, so that I can discover its canonical topic symbol and
182+
schema version programmatically.
183+
184+
#### Acceptance Criteria
185+
186+
1. THE `EventSchemaRegistry::get_schema` function SHALL return a valid `EventSchemaEntry` when
187+
called with the name `"cross_oracle_staleness"`.
188+
189+
2. THE returned `EventSchemaEntry` SHALL have `topic` equal to `symbol_short!("orc_xstl")` and
190+
`schema_version` equal to `1`.
191+
192+
3. IF `"cross_oracle_staleness"` is not registered and `get_schema` falls back to the default
193+
branch, THEN THE Contract SHALL still return an `EventSchemaEntry` consistent with
194+
`topic = symbol_short!("orc_xstl")` and `schema_version = 1`.
195+
196+
---
197+
198+
### Requirement 6: Documentation update
199+
200+
**User Story:** As a downstream consumer building an indexer, I want `docs/EVENT_SCHEMA.md` to
201+
document `CrossOracleStalenessEvent`, so that I know its topic, all fields, and stability
202+
guarantee without reading source code.
203+
204+
#### Acceptance Criteria
205+
206+
1. THE file `contracts/predictify-hybrid/docs/EVENT_SCHEMA.md` SHALL include a row for
207+
`CrossOracleStalenessEvent` in the Oracle Events table with topic `"orc_xstl"` and stability
208+
badge 🟢 Stable.
209+
210+
2. THE file SHALL include a `### CrossOracleStalenessEvent` subsection that lists every field
211+
from the struct defined in Requirement 1 criterion 2, including field name, type, and a
212+
one-line description.
213+
214+
3. THE documentation SHALL state that the event is emitted in both `fetch_oracle_result` and
215+
`resolve_with_median` when a cross-oracle staleness mismatch is detected.
216+
217+
4. THE documentation SHALL state that emitting this event does not block or alter market
218+
resolution.
219+
220+
---
221+
222+
### Requirement 7: Tests
223+
224+
**User Story:** As a code reviewer, I want focused tests that prove each staleness scenario
225+
triggers (or suppresses) the event correctly, so that I can approve the PR with confidence.
226+
227+
#### Acceptance Criteria
228+
229+
1. THE codebase SHALL include a test named `test_cross_oracle_staleness_event_emitted_when_primary_stale`
230+
that verifies `CrossOracleStalenessEvent` is emitted when the Primary_Oracle data is stale
231+
and the Fallback_Oracle data is fresh.
232+
233+
2. THE codebase SHALL include a test named `test_cross_oracle_staleness_event_emitted_when_fallback_stale`
234+
that verifies `CrossOracleStalenessEvent` is emitted when the Fallback_Oracle data is stale
235+
and the Primary_Oracle data is fresh.
236+
237+
3. THE codebase SHALL include a test named `test_cross_oracle_staleness_event_emitted_when_both_stale`
238+
that verifies `CrossOracleStalenessEvent` is emitted exactly once when both oracle sources
239+
exceed `max_staleness_secs`.
240+
241+
4. THE codebase SHALL include a test named `test_cross_oracle_staleness_event_not_emitted_single_oracle`
242+
that verifies `CrossOracleStalenessEvent` is NOT emitted when only a single oracle source is
243+
present (`has_fallback == false`).
244+
245+
5. THE codebase SHALL include a test named `test_cross_oracle_staleness_event_not_emitted_both_fresh`
246+
that verifies `CrossOracleStalenessEvent` is NOT emitted when both oracle sources return data
247+
within `max_staleness_secs`.
248+
249+
6. WHEN any test listed in criteria 1–5 asserts that the event IS emitted, THE test SHALL verify
250+
the following fields carry correct values: `market_id`, `primary_age_secs`,
251+
`fallback_age_secs`, `max_age_secs`, and `timestamp`.
252+
253+
7. THE tests SHALL use the Soroban SDK's `env.events().all()` or equivalent introspection API to
254+
assert event emission without relying on side effects in persistent storage.
255+
256+
8. WHEN adding the new tests, THE codebase SHALL continue to compile and all existing tests SHALL
257+
pass without modification.
258+
259+
---
260+
261+
### Requirement 8: Code style and lint compliance
262+
263+
**User Story:** As a code reviewer, I want the new code to be indistinguishable in style from the
264+
existing `events.rs` and `resolution.rs` modules, so that the PR diff is easy to read and
265+
review.
266+
267+
#### Acceptance Criteria
268+
269+
1. THE new `CrossOracleStalenessEvent` struct SHALL follow the same rustdoc comment style used
270+
by neighbouring event structs in `src/events.rs` (doc comment on the struct, per-field doc
271+
comments).
272+
273+
2. THE `emit_cross_oracle_staleness` method SHALL be placed in the `EventEmitter` impl block
274+
in alphabetical or logical order relative to neighbouring oracle-related emit methods.
275+
276+
3. WHEN `cargo clippy` is run on the workspace, THE new code SHALL produce zero new warnings
277+
or errors.
278+
279+
4. WHEN `cargo fmt --check` is run on the workspace, THE new code SHALL produce no formatting
280+
violations.

0 commit comments

Comments
 (0)