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
3 changes: 3 additions & 0 deletions contracts/predictify-hybrid/src/audit_trail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub struct AuditRecord {
pub timestamp: u64,
pub details: Map<Symbol, String>,
pub prev_record_hash: BytesN<32>,
pub override_nonce: Option<u64>,
}

/// Head of the audit trail, tracking the latest state.
Expand All @@ -83,6 +84,7 @@ impl AuditTrailManager {
action: AuditAction,
actor: Address,
details: Map<Symbol, String>,
override_nonce: Option<u64>,
) -> u64 {
let mut head: AuditTrailHead = env
.storage()
Expand All @@ -102,6 +104,7 @@ impl AuditTrailManager {
timestamp: env.ledger().timestamp(),
details,
prev_record_hash: head.latest_hash.clone(),
override_nonce: override_nonce,
};

// Use a tuple key for distinct storage namespace (Symbol, index)
Expand Down
2 changes: 2 additions & 0 deletions contracts/predictify-hybrid/src/err.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ pub enum Error {
GasBudgetExceeded = 417,
/// Admin address has not been set. Contract initialization is incomplete.
AdminNotSet = 418,
/// Admin override verification failed due to replay attack - nonce <= stored nonce.
ReplayedOverride = 419,

// ===== METADATA LENGTH LIMIT ERRORS (420-434) =====
/// Market question exceeds maximum allowed length.
Expand Down
23 changes: 23 additions & 0 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2880,6 +2880,7 @@ impl PredictifyHybrid {
market_id: Symbol,
outcome: String,
reason: String,
provided_nonce: u64,
) -> Result<(), Error> {
Self::require_primary_admin(&env, &admin)?;

Expand All @@ -2903,6 +2904,27 @@ impl PredictifyHybrid {
markets::MarketStateManager::update_market(&env, &market_id, &market);

// Append an immutable audit record
// Validate and store the admin override nonce for replay protection
let key = DataKey::AdminOverrideNonce(admin.clone());
let mut stored_nonce: u64 = env
.storage()
.persistent()
.get(&key)
.unwrap_or(0);

if provided_nonce <= stored_nonce {
return Err(Error::ReplayedOverride);
}

// Update the nonce for this admin
env.storage().persistent().set(&key, &provided_nonce);
env.storage().persistent().extend_ttl(
&key,
env.storage().max_ttl(),
env.storage().max_ttl(),
);

// Append an immutable audit record with the nonce for replay protection
let mut details = Map::new(&env);
details.set(Symbol::new(&env, "old_result"), old_result.clone());
details.set(Symbol::new(&env, "new_result"), outcome.clone());
Expand All @@ -2912,6 +2934,7 @@ impl PredictifyHybrid {
AuditAction::OracleVerificationOverride,
admin.clone(),
details,
Some(provided_nonce),
);

// Emit the dedicated override event for off-chain monitors
Expand Down
111 changes: 111 additions & 0 deletions contracts/predictify-hybrid/src/override_audit_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ fn test_override_rejects_empty_reason() {
&market_id,
&String::from_str(&ctx.env, "yes"),
&String::from_str(&ctx.env, ""),
&0u64,
);

assert_eq!(result, Err(Ok(Error::InvalidInput)));
Expand All @@ -86,6 +87,7 @@ fn test_override_appends_audit_record() {
&market_id,
&String::from_str(&ctx.env, "yes"),
&String::from_str(&ctx.env, "Oracle feed was stale; manual data confirmed"),
&0u64,
);

ctx.env.as_contract(&ctx.contract_id, || {
Expand All @@ -104,6 +106,8 @@ fn test_override_appends_audit_record() {
recorded_reason,
String::from_str(&ctx.env, "Oracle feed was stale; manual data confirmed")
);

assert_eq!(record.override_nonce, Some(0u64));
});
}

Expand All @@ -119,6 +123,7 @@ fn test_override_preserves_audit_integrity() {
&market_id,
&String::from_str(&ctx.env, "no"),
&String::from_str(&ctx.env, "Community consensus contradicted oracle"),
&0u64,
);

ctx.env.as_contract(&ctx.contract_id, || {
Expand All @@ -138,6 +143,7 @@ fn test_override_resolves_market() {
&market_id,
&String::from_str(&ctx.env, "yes"),
&String::from_str(&ctx.env, "Verified via secondary source"),
&0u64,
);

let market = ctx.client().get_market(&market_id).unwrap();
Expand Down Expand Up @@ -193,6 +199,7 @@ fn test_override_rejects_non_admin() {
&market_id,
&String::from_str(&env, "yes"),
&String::from_str(&env, "Trying to cheat"),
&0u64,
);

assert!(result.is_err());
Expand All @@ -209,6 +216,7 @@ fn test_override_unknown_market() {
&Symbol::new(&ctx.env, "ghost"),
&String::from_str(&ctx.env, "yes"),
&String::from_str(&ctx.env, "Some reason"),
&0u64,
);

assert_eq!(result, Err(Ok(Error::MarketNotFound)));
Expand Down Expand Up @@ -259,9 +267,112 @@ fn test_override_no_partial_state_on_auth_failure() {
&market_id,
&String::from_str(&env, "yes"),
&String::from_str(&env, "Sneaky"),
&0u64,
);

let after = client.get_market(&market_id).unwrap();
assert_eq!(before.state, after.state);
assert_eq!(before.oracle_result, after.oracle_result);
}

// ── nonce replay protection ───────────────────────────────────────────────────

#[test]
fn test_override_rejects_replay_nonce() {
let ctx = Ctx::new();
let market_id = ctx.create_market();

// First override succeeds
ctx.client().admin_override_verification(
&ctx.admin,
&market_id,
&String::from_str(&ctx.env, "yes"),
&String::from_str(&ctx.env, "First override"),
&0u64,
);

// Second override with same nonce should be rejected
let result = ctx.client().try_admin_override_verification(
&ctx.admin,
&market_id,
&String::from_str(&ctx.env, "no"),
&String::from_str(&ctx.env, "Replay attempt"),
&0u64,
);

assert_eq!(result, Err(Ok(Error::ReplayedOverride)));

let market = ctx.client().get_market(&market_id).unwrap();
// The market should still have the first override result
assert_eq!(market.oracle_result, Some(String::from_str(&ctx.env, "yes")));
}

#[test]
fn test_override_rejects_out_of_order_nonce() {
let ctx = Ctx::new();
let market_id = ctx.create_market();

// First override with nonce 100 succeeds
ctx.client().admin_override_verification(
&ctx.admin,
&market_id,
&String::from_str(&ctx.env, "yes"),
&String::from_str(&ctx.env, "First override (nonce 100)"),
&100u64,
);

// Second override with nonce 50 (out of order) should be rejected
let result = ctx.client().try_admin_override_verification(
&ctx.admin,
&market_id,
&String::from_str(&ctx.env, "no"),
&String::from_str(&ctx.env, "Out of order nonce"),
&50u64,
);

assert_eq!(result, Err(Ok(Error::ReplayedOverride)));

let market = ctx.client().get_market(&market_id).unwrap();
// The market should still have the first override result
assert_eq!(market.oracle_result, Some(String::from_str(&ctx.env, "yes")));
}

#[test]
fn test_override_fresh_admin_can_succeed() {
let ctx = Ctx::new();
let market_id = ctx.create_market();

// First admin can override with nonce 0
ctx.client().admin_override_verification(
&ctx.admin,
&market_id,
&String::from_str(&ctx.env, "yes"),
&String::from_str(&ctx.env, "First admin"),
&0u64,
);

let market1 = ctx.client().get_market(&market_id).unwrap();
assert_eq!(market1.oracle_result, Some(String::from_str(&ctx.env, "yes")));
}

// ── nonce persisted in audit trail ─────────────────────────────────────────────

#[test]
fn test_override_nonce_persisted_in_audit() {
let ctx = Ctx::new();
let market_id = ctx.create_market();

ctx.client().admin_override_verification(
&ctx.admin,
&market_id,
&String::from_str(&ctx.env, "yes"),
&String::from_str(&ctx.env, "Test reason with nonce"),
&42u64,
);

ctx.env.as_contract(&ctx.contract_id, || {
let head = AuditTrailManager::get_head(&ctx.env).unwrap();
let record = AuditTrailManager::get_record(&ctx.env, head.latest_index).unwrap();
assert_eq!(record.override_nonce, Some(42u64));
});
}
1 change: 1 addition & 0 deletions contracts/predictify-hybrid/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub enum DataKey {
Whitelisted(Address),
Blacklisted(Address),
ArchivedMarket(Symbol, u64),
AdminOverrideNonce(Address),
}

/// Storage format version for migration tracking
Expand Down
Loading
Loading