Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions contracts/claims-processor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ enum StorageKey {
UserLastClaim(Address),
/// Registered valid claim type (Symbol) -> bool (issue #734).
RegisteredClaimType(Symbol),
/// Evidence attachments for a claim: claim_id (u128) -> Vec<Bytes> hashes (issue #747).
ClaimEvidence(u128),
}

/// Maximum number of evidence files allowed per claim (issue #747).
Expand Down Expand Up @@ -229,6 +231,12 @@ pub enum Error {
InvalidDisputeDeadline = 38,
/// Claim type must be registered and non-empty (issue #734).
InvalidClaimType = 39,
/// Evidence array exceeds `MAX_EVIDENCE_FILES` (issue #747).
EvidenceLimitExceeded = 40,
/// A supplied evidence hash is malformed or empty (issue #747).
InvalidEvidence = 41,
/// Payment token does not match the product's expected token.
InvalidToken = 42,
}

/// Approximate Stellar ledger close time in seconds, used to convert
Expand Down Expand Up @@ -724,6 +732,17 @@ impl ClaimsProcessor {
/// `partial_payout_bps` is an optional payout ratio in basis points (0-10000).
/// - `None` or `Some(10000)` → full coverage payment (default behavior).
/// - `Some(bps)` where bps < 10000 → proportional partial payment, e.g. `Some(5000)` pays 50%.
///
/// Boundary semantics (issue #776). The CoverageMismatch guard below
/// uses `!=` rather than `>`/`<`, so a claim whose `coverage_amount`
/// equals the policy's `coverage_amount` exactly is permitted — the
/// equality case is the common case, not a rejection branch. The
/// downstream payout logic in `evaluate_and_settle` mirrors this:
/// `paid_amount > claim.coverage_amount` (strict), so a payout exactly
/// at the coverage ceiling is accepted and only a payout *above* it is
/// rejected. The same `>` strictness applies to the installment
/// schedule check (`total_amount > claim.coverage_amount`). An
/// equal-to-ceiling value is always a valid claim, never a defect.
pub fn process_claim(
env: Env,
keeper: Address,
Expand Down
86 changes: 86 additions & 0 deletions contracts/oracle-verifier/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ enum StorageKey {
/// Timestamp (u64) of an oracle's last accepted submission for a
/// data_type — (data_type, oracle) → last submission time
LastSubmission(Symbol, Address),
/// Monotonic guard: the latest reading-timestamp (u64) this oracle has
/// published for a specific (data_type, key) stream. Any subsequent
/// `submit_data` must carry a strictly greater `timestamp` or a stale
/// reading could silently overwrite a fresher one (issue #775).
LastReadingTimestamp(Symbol, Symbol, Address),
/// Token used for oracle stake deposits (Address). Unset = staking disabled.
StakeToken,
/// Minimum stake (i128) an oracle must hold for a data_type before
Expand Down Expand Up @@ -260,6 +265,11 @@ pub enum Error {
WithdrawalAlreadyRequested = 35,
/// Oracle reputation score is out of bounds (0-1000 bps) (issue #749).
InvalidReputation = 36,
/// A submission arrived with a timestamp older than or equal to the most
/// recent reading this oracle already published for the same (data_type,
/// key) tuple. Readings for a single stream must advance monotonically or
/// a stale reading can overwrite a fresher one (issue #775).
StaleReadingTimestamp = 37,
}

// ─── Contract ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -2382,6 +2392,15 @@ impl OracleVerifier {

Self::enforce_rate_limit(&env, &data_type, &oracle, now);

// Issue #775: monotonic reading-timestamp per (data_type, key, oracle).
// Readings for one stream from one oracle must advance strictly
// forward; otherwise a replayed or out-of-order submission from an
// honest-but-flaky feed can overwrite a fresher reading already
// folded into the aggregate. This check is intentionally scoped per
// oracle so one oracle's lagged clock does not block submissions
// from the others on the same key.
Self::require_strictly_newer_reading(&env, &data_type, &key, &oracle, timestamp);

// Issue #459: stale-submission gate. If the submitted timestamp is
// older than this data_type's `effective_max_age`, fold the point
// into the stale-penalty pipeline and return without adding it to
Expand All @@ -2402,6 +2421,7 @@ impl OracleVerifier {
timestamp,
};
Self::store_plain_reading(&env, &data_type, &key, new_point, now, max_data_age);
Self::record_reading_timestamp(&env, &data_type, &key, &oracle, timestamp);

// Check cross-validation rules after storing new data
Self::check_cross_validation(&env, &data_type, &key);
Expand Down Expand Up @@ -3138,6 +3158,11 @@ impl OracleVerifier {
panic_with_error!(&env, Error::InvalidTimestamp);
}

// Issue #775: monotonic reading-timestamp per (data_type, key,
// oracle). Same guard as submit_data — batch callers must not
// bypass it.
Self::require_strictly_newer_reading(&env, &data_type, &key, &oracle, timestamp);

let max_data_age = Self::effective_max_age(&env, &data_type);
if now.saturating_sub(timestamp) > max_data_age {
Self::handle_stale_submission(&env, &oracle, &data_type, timestamp, max_data_age);
Expand All @@ -3150,6 +3175,7 @@ impl OracleVerifier {
timestamp,
};
Self::store_plain_reading(&env, &data_type, &key, new_point, now, max_data_age);
Self::record_reading_timestamp(&env, &data_type, &key, &oracle, timestamp);

env.events().publish(
(Symbol::new(&env, "oracle_data_submitted"),),
Expand Down Expand Up @@ -3226,6 +3252,16 @@ impl OracleVerifier {
if sub.timestamp < now.saturating_sub(max_timestamp_age) {
panic_with_error!(&env, Error::InvalidTimestamp);
}
// Issue #775: monotonic reading-timestamp per (data_type, key,
// oracle). Same guard as submit_data — batch callers must not
// bypass it.
Self::require_strictly_newer_reading(
&env,
&data_type,
&sub.key,
&oracle,
sub.timestamp,
);
// Issue #459: per-item stale-submission gate. A stale item in a
// batch is skipped (with penalty accounting) rather than failing
// the whole batch, so a partial-good batch still lands its
Expand Down Expand Up @@ -3255,6 +3291,7 @@ impl OracleVerifier {
now,
max_data_age,
);
Self::record_reading_timestamp(&env, &data_type, &sub.key, &oracle, sub.timestamp);

env.events().publish(
(Symbol::new(&env, "oracle_data_submitted"),),
Expand Down Expand Up @@ -3423,6 +3460,52 @@ impl OracleVerifier {
}
}

/// Issue #775: panic with `StaleReadingTimestamp` if this `oracle` has
/// already published a reading for `(data_type, key)` whose timestamp is
/// greater than or equal to `new_timestamp`. First submission (no prior
/// value) is always accepted. Call before `store_plain_reading` so stale
/// readings never reach the aggregator.
fn require_strictly_newer_reading(
env: &Env,
data_type: &Symbol,
key: &Symbol,
oracle: &Address,
new_timestamp: u64,
) {
let storage_key = StorageKey::LastReadingTimestamp(
data_type.clone(),
key.clone(),
oracle.clone(),
);
if let Some(last) = env.storage().persistent().get::<_, u64>(&storage_key) {
if new_timestamp <= last {
panic_with_error!(env, Error::StaleReadingTimestamp);
}
}
}

/// Issue #775: record the accepted reading's timestamp as the oracle's
/// new high-water mark for this stream. Only called after every other
/// gate accepts the submission so a rejected reading does not advance
/// the monotonic guard and let a later legitimate reading be rejected.
fn record_reading_timestamp(
env: &Env,
data_type: &Symbol,
key: &Symbol,
oracle: &Address,
timestamp: u64,
) {
let storage_key = StorageKey::LastReadingTimestamp(
data_type.clone(),
key.clone(),
oracle.clone(),
);
env.storage().persistent().set(&storage_key, &timestamp);
env.storage()
.persistent()
.extend_ttl(&storage_key, TTL_THRESHOLD, TTL_EXTEND_TO);
}

/// Enforce the per-oracle submission cooldown for `data_type`: panics with
/// `RateLimited` if `oracle` submitted for this data_type more recently
/// than `get_min_submit_interval()` seconds ago, otherwise records `now`
Expand Down Expand Up @@ -4061,3 +4144,6 @@ mod test_advanced;
mod test_offline;
#[cfg(test)]
mod test_confidence_bounds;

#[cfg(test)]
mod test_monotonic;
150 changes: 150 additions & 0 deletions contracts/oracle-verifier/src/test_monotonic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#![allow(clippy::inconsistent_digit_grouping)]
#![cfg(test)]

//! Regression coverage for issue #775: readings for the same
//! `(data_type, key, oracle)` must advance strictly forward in time.
//! A replayed or out-of-order submission from a flaky feed used to be
//! accepted and could overwrite a fresher reading already folded into
//! the aggregate.

use super::*;
use soroban_sdk::{
symbol_short,
testutils::{Address as _, Ledger},
Env,
};

fn setup() -> (Env, Address, Address, Address) {
let env = Env::default();
env.ledger().set_timestamp(1_750_000_000);
env.mock_all_auths();

let admin = Address::generate(&env);
let oracle = Address::generate(&env);
let contract_id = env.register(OracleVerifier, ());
let client = OracleVerifierClient::new(&env, &contract_id);
client.initialize(&admin);
client.add_oracle(&admin, &oracle, &symbol_short!("weather"), &100u32);

(env, admin, oracle, contract_id)
}

#[test]
fn first_submission_is_accepted() {
let (env, _admin, oracle, contract_id) = setup();
let client = OracleVerifierClient::new(&env, &contract_id);
// No prior reading exists for this (data_type, key, oracle), so the
// monotonic guard is a no-op.
client.submit_data(
&oracle,
&symbol_short!("weather"),
&symbol_short!("kis001"),
&10i128,
&100u32,
&1_749_900_000u64,
);
}

#[test]
fn second_submission_with_a_strictly_later_timestamp_is_accepted() {
let (env, _admin, oracle, contract_id) = setup();
let client = OracleVerifierClient::new(&env, &contract_id);
client.submit_data(
&oracle,
&symbol_short!("weather"),
&symbol_short!("kis001"),
&10i128,
&100u32,
&1_749_900_000u64,
);
// Advance the ledger enough to clear MinSubmitInterval.
env.ledger().set_timestamp(1_750_000_060);
client.submit_data(
&oracle,
&symbol_short!("weather"),
&symbol_short!("kis001"),
&20i128,
&100u32,
&1_749_900_030u64,
);
}

/// Error 37 = `StaleReadingTimestamp`: a second submission with the same
/// timestamp as the first is rejected (strictly-newer rule).
#[test]
#[should_panic(expected = "Error(Contract, #37)")]
fn equal_timestamp_resubmission_is_rejected() {
let (env, _admin, oracle, contract_id) = setup();
let client = OracleVerifierClient::new(&env, &contract_id);
client.submit_data(
&oracle,
&symbol_short!("weather"),
&symbol_short!("kis001"),
&10i128,
&100u32,
&1_749_900_000u64,
);
env.ledger().set_timestamp(1_750_000_060);
client.submit_data(
&oracle,
&symbol_short!("weather"),
&symbol_short!("kis001"),
&20i128,
&100u32,
&1_749_900_000u64, // same timestamp
);
}

/// Error 37 = `StaleReadingTimestamp`: an older timestamp overwrites a
/// fresher reading only when the fix is absent; with it, the stale one
/// is rejected.
#[test]
#[should_panic(expected = "Error(Contract, #37)")]
fn earlier_timestamp_resubmission_is_rejected() {
let (env, _admin, oracle, contract_id) = setup();
let client = OracleVerifierClient::new(&env, &contract_id);
client.submit_data(
&oracle,
&symbol_short!("weather"),
&symbol_short!("kis001"),
&10i128,
&100u32,
&1_749_900_060u64,
);
env.ledger().set_timestamp(1_750_000_060);
// Try to overwrite with an older reading.
client.submit_data(
&oracle,
&symbol_short!("weather"),
&symbol_short!("kis001"),
&20i128,
&100u32,
&1_749_900_030u64,
);
}

/// The monotonic guard is scoped per (data_type, key, oracle). A lagged
/// clock on one key does not block submissions on an unrelated key.
#[test]
fn monotonic_guard_is_scoped_per_key() {
let (env, _admin, oracle, contract_id) = setup();
let client = OracleVerifierClient::new(&env, &contract_id);
client.submit_data(
&oracle,
&symbol_short!("weather"),
&symbol_short!("kis001"),
&10i128,
&100u32,
&1_749_900_060u64,
);
env.ledger().set_timestamp(1_750_000_060);
// Different key — an older timestamp here is allowed.
client.submit_data(
&oracle,
&symbol_short!("weather"),
&symbol_short!("kis002"),
&20i128,
&100u32,
&1_749_900_030u64,
);
}
Loading
Loading