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
40 changes: 40 additions & 0 deletions app/contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,46 @@ The contract enforces full runtime schema validation (`validate_event_schemas`)
- Presence of all required replay fields.
- Runtime cross-checking of all emitted events against `EVENT_SCHEMAS`.

### Event Deduplication & Pre-Publish Validation (Issue #560)

The contract provides two layers of protection against malformed payloads, duplicate events, and schema drift:

#### 1. Pre-Publish Validation (`validate_emission_preconditions`)

Before any event is published, `validate_emission_preconditions` validates:
- The event type ID is non-zero and matches a registered schema.
- The schema version matches the current `EVENT_SCHEMA_VERSION` (prevents schema drift).
- The domain namespace is valid (`TOPIC_ADMIN`, `TOPIC_ESCROW`, etc.).
- The event is not a duplicate of a previously emitted event.

#### 2. Deterministic Event Deduplication

Every emission is recorded in persistent storage using a 32-byte SHA-256 key derived from the core replay fields: `(event_type_id, ledger_sequence, schema_version, timestamp)`. This provides:

- **At-most-once delivery**: identical replay field tuples are rejected.
- **TTL-based expiry**: dedup entries expire after 6 months of ledgers, preventing unbounded storage growth.
- **Cross-field sensitivity**: any change to any replay field produces a fresh dedup key.

```rust
// Example: check and record in a single call
let is_fresh = events::check_and_record_event_dedup(
&env, event_type_id, ledger_sequence, schema_version, timestamp
)?;
if !is_fresh {
return Err(RustAcademyError::DuplicateEvent);
}
```

#### API Reference

| Function | Purpose |
|----------|---------|
| `validate_emission_preconditions(env, etid, version, ledger, ts)` | Full pre-publish check: schema lookup + namespace + dedup. |
| `check_and_record_event_dedup(env, etid, ledger, version, ts)` | Atomic check-and-record for event deduplication. |
| `compute_event_dedup_key(env, etid, ledger, version, ts)` | Derive the 32-byte dedup key from replay fields. |
| `check_event_dedup(env, key)` | Check whether a dedup key has been recorded. |
| `record_event_emission(env, key)` | Record a dedup key with 6-month TTL. |

---

## Fee Configuration (Fee Router v2 — Issue #305)
Expand Down
138 changes: 137 additions & 1 deletion app/contract/contracts/Folder/src/events.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use soroban_sdk::{contractevent, Address, BytesN, Env, Symbol, TryIntoVal, Val};
use soroban_sdk::{contractevent, contracttype, Address, BytesN, Env, Symbol, TryIntoVal, Val};

use crate::errors::RustAcademyError;
use crate::storage::{self, DataKey, LEDGER_THRESHOLD, SIX_MONTHS_IN_LEDGERS};

/// Canonical event schema version.
///
Expand Down Expand Up @@ -1839,3 +1842,136 @@ pub fn validate_emitted_event(
Ok(schema)
}

// -----------------------------------------------------------------------------
// Runtime Pre-Publish Validation & Event Deduplication (Issue #560)
// -----------------------------------------------------------------------------

/// Storage key for the event deduplication registry.
///
/// Stored as `true` with a 6-month TTL so the registry does not grow
/// unboundedly while still covering realistic replay windows.
#[contracttype]
#[derive(Clone)]
pub enum EventDedupKey {
Emitted(BytesN<32>),
}

/// Compute a deterministic 32-byte dedup key from the core replay fields of
/// an event.
///
/// The key is `SHA-256(event_type_id || ledger_sequence || schema_version ||
/// timestamp)` — any change to those fields produces a different key, and
/// identical fields always produce the same key.
#[allow(dead_code)]
pub fn compute_event_dedup_key(
env: &Env,
event_type_id: u32,
ledger_sequence: u32,
schema_version: u32,
timestamp: u64,
) -> BytesN<32> {
use soroban_sdk::Bytes;

let mut data = Bytes::new(env);
data.append(&Bytes::from_slice(env, &event_type_id.to_be_bytes()));
data.append(&Bytes::from_slice(env, &ledger_sequence.to_be_bytes()));
data.append(&Bytes::from_slice(env, &schema_version.to_be_bytes()));
data.append(&Bytes::from_slice(env, &timestamp.to_be_bytes()));

env.crypto().sha256(&data).into()
}

/// Check whether an event with the given dedup key has already been emitted.
///
/// Returns `Ok(true)` if the key is fresh (first emission), `Ok(false)` if
/// the event is a duplicate, or `Err` if the check encounters an internal
/// error.
#[allow(dead_code)]
pub fn check_event_dedup(env: &Env, dedup_key: &BytesN<32>) -> Result<bool, RustAcademyError> {
let key = DataKey::EventDedup(dedup_key.clone());
if env.storage().persistent().has(&key) {
return Ok(false); // duplicate
}
Ok(true) // fresh
}

/// Record an event emission so future identical events are detected as
/// duplicates.
#[allow(dead_code)]
pub fn record_event_emission(env: &Env, dedup_key: &BytesN<32>) {
let key = DataKey::EventDedup(dedup_key.clone());
env.storage().persistent().set(&key, &true);
env.storage()
.persistent()
.extend_ttl(&key, LEDGER_THRESHOLD, SIX_MONTHS_IN_LEDGERS);
}

/// Combined check-and-record: returns `Ok(true)` if this is the first
/// emission, or `Ok(false)` if the event is a duplicate (already emitted).
///
/// Call this before publishing an event to enforce at-most-once delivery
/// semantics per (event_type_id, ledger_sequence, schema_version, timestamp)
/// tuple.
#[allow(dead_code)]
pub fn check_and_record_event_dedup(
env: &Env,
event_type_id: u32,
ledger_sequence: u32,
schema_version: u32,
timestamp: u64,
) -> Result<bool, RustAcademyError> {
let dedup_key = compute_event_dedup_key(env, event_type_id, ledger_sequence, schema_version, timestamp);
let is_fresh = check_event_dedup(env, &dedup_key)?;
if is_fresh {
record_event_emission(env, &dedup_key);
}
Ok(is_fresh)
}

/// Validate preconditions for event emission before publishing.
///
/// Checks that:
/// 1. The event type ID is non-zero and matches a known schema.
/// 2. The schema version matches the current `EVENT_SCHEMA_VERSION`.
/// 3. The namespace (topic[0]) is a valid domain namespace.
/// 4. All mandatory replay fields are present in the payload.
/// 5. The event is not a duplicate (dedup check).
///
/// Returns `Ok(schema)` on success, or `Err(message)` on failure.
#[allow(dead_code)]
pub fn validate_emission_preconditions(
env: &Env,
event_type_id: u32,
schema_version: u32,
ledger_sequence: u32,
timestamp: u64,
) -> Result<&'static EventSchema, &'static str> {
// 1. Non-zero event type ID.
if event_type_id == 0 {
return Err("Event type ID cannot be zero");
}

// 2. Schema version must match current version.
if schema_version != EVENT_SCHEMA_VERSION {
return Err("Event schema version does not match current EVENT_SCHEMA_VERSION");
}

// 3. Find matching schema.
let schema = EVENT_SCHEMAS
.iter()
.find(|s| s.event_type_id == event_type_id)
.ok_or("No schema registered for event_type_id")?;

// 4. Validate schema entry itself (defense-in-depth).
validate_event_schema_entry(schema)?;

// 5. Check dedup — reject if already emitted.
let dedup_key = compute_event_dedup_key(env, event_type_id, ledger_sequence, schema_version, timestamp);
if !check_event_dedup(env, &dedup_key)
.map_err(|_| "Internal error during dedup check")?
{
return Err("Duplicate event detected — already emitted");
}

Ok(schema)
}
178 changes: 178 additions & 0 deletions app/contract/contracts/Folder/src/events_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ fn setup() -> (Env, RustAcademyContractClient<'static>, Address) {
(env, client, admin)
}

/// Setup for event dedup tests — returns (env, client) where the contract is
/// registered so `env.as_contract` can access persistent storage.
fn setup_event_dedup() -> (Env, RustAcademyContractClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(RustAcademyContract, ());
let client = RustAcademyContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
client.initialize(&admin);
(env, client)
}

#[test]
fn test_validate_event_schemas_passes_canonical_catalog() {
let result = events::validate_event_schemas();
Expand Down Expand Up @@ -301,3 +313,169 @@ fn test_schema_validation_error_cases() {
Err("Missing mandatory event replay field in payload keys")
);
}

// ======================================================================
// Issue #560 — Pre-publish validation & event deduplication tests
// ======================================================================

#[test]
fn test_pre_publish_rejects_zero_event_type_id() {
let (env, client) = setup_event_dedup();
let result = env.as_contract(&client.address, || {
events::validate_emission_preconditions(&env, 0, events::EVENT_SCHEMA_VERSION, 1, 1000)
});
assert_eq!(result, Err("Event type ID cannot be zero"));
}

#[test]
fn test_pre_publish_rejects_schema_version_mismatch() {
let (env, client) = setup_event_dedup();
let result = env.as_contract(&client.address, || {
events::validate_emission_preconditions(&env, events::ETID_ESCROW_DEPOSITED, 999, 1, 1000)
});
assert_eq!(result, Err("Event schema version does not match current EVENT_SCHEMA_VERSION"));
}

#[test]
fn test_pre_publish_rejects_unknown_event_type_id() {
let (env, client) = setup_event_dedup();
let result = env.as_contract(&client.address, || {
events::validate_emission_preconditions(&env, 99999, events::EVENT_SCHEMA_VERSION, 1, 1000)
});
assert_eq!(result, Err("No schema registered for event_type_id"));
}

#[test]
fn test_pre_publish_accepts_valid_event() {
let (env, client) = setup_event_dedup();
let result = env.as_contract(&client.address, || {
events::validate_emission_preconditions(
&env,
events::ETID_ESCROW_DEPOSITED,
events::EVENT_SCHEMA_VERSION,
1,
1000,
)
});
assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
let schema = result.unwrap();
assert_eq!(schema.name, "EscrowDeposited");
}

#[test]
fn test_event_dedup_allows_first_emission() {
let (env, client) = setup_event_dedup();
let is_fresh = env.as_contract(&client.address, || {
events::check_and_record_event_dedup(&env, 1, 100, 2, 5000).unwrap()
});
assert!(is_fresh, "First emission should be fresh");
}

#[test]
fn test_event_dedup_rejects_duplicate_emission() {
let (env, client) = setup_event_dedup();
env.as_contract(&client.address, || {
let is_fresh = events::check_and_record_event_dedup(&env, 1, 100, 2, 5000).unwrap();
assert!(is_fresh, "First emission should be fresh");
let is_fresh2 = events::check_and_record_event_dedup(&env, 1, 100, 2, 5000).unwrap();
assert!(!is_fresh2, "Second emission with same fields should be duplicate");
});
}

#[test]
fn test_event_dedup_different_ledger_is_fresh() {
let (env, client) = setup_event_dedup();
env.as_contract(&client.address, || {
let is_fresh = events::check_and_record_event_dedup(&env, 1, 100, 2, 5000).unwrap();
assert!(is_fresh);
let is_fresh2 = events::check_and_record_event_dedup(&env, 1, 101, 2, 5001).unwrap();
assert!(is_fresh2, "Different ledger_sequence should be fresh");
});
}

#[test]
fn test_event_dedup_different_event_type_is_fresh() {
let (env, client) = setup_event_dedup();
env.as_contract(&client.address, || {
let is_fresh = events::check_and_record_event_dedup(&env, 1, 100, 2, 5000).unwrap();
assert!(is_fresh);
let is_fresh2 = events::check_and_record_event_dedup(&env, 2, 100, 2, 5000).unwrap();
assert!(is_fresh2, "Different event_type_id should be fresh");
});
}

#[test]
fn test_event_dedup_different_timestamp_is_fresh() {
let (env, client) = setup_event_dedup();
env.as_contract(&client.address, || {
let is_fresh = events::check_and_record_event_dedup(&env, 1, 100, 2, 5000).unwrap();
assert!(is_fresh);
let is_fresh2 = events::check_and_record_event_dedup(&env, 1, 100, 2, 5001).unwrap();
assert!(is_fresh2, "Different timestamp should be fresh");
});
}

#[test]
fn test_pre_publish_rejects_duplicate_at_precondition_level() {
let (env, client) = setup_event_dedup();
env.as_contract(&client.address, || {
// First emission — succeeds.
let result1 = events::validate_emission_preconditions(
&env,
events::ETID_ESCROW_DEPOSITED,
events::EVENT_SCHEMA_VERSION,
1,
1000,
);
assert!(result1.is_ok());
// Record the emission.
let dedup_key = events::compute_event_dedup_key(
&env,
events::ETID_ESCROW_DEPOSITED,
1,
events::EVENT_SCHEMA_VERSION,
1000,
);
events::record_event_emission(&env, &dedup_key);

// Second emission with same params — should fail dedup.
let result2 = events::validate_emission_preconditions(
&env,
events::ETID_ESCROW_DEPOSITED,
events::EVENT_SCHEMA_VERSION,
1,
1000,
);
assert_eq!(result2, Err("Duplicate event detected — already emitted"));
});
}

#[test]
fn test_schema_invalid_namespace_detected_in_validation() {
let s = EventSchema {
name: "BadNamespace",
event_type_id: 9999,
topics: &["TOPIC_INVALID", "BadNamespace"],
payload_keys: &["event_type_id", "ledger_sequence", "schema_version", "timestamp"],
schema_version: events::EVENT_SCHEMA_VERSION,
};
assert_eq!(
events::validate_event_schema_entry(&s),
Err("Invalid event topic domain namespace")
);
}

#[test]
fn test_replay_fields_present_in_every_schema() {
// Every schema MUST carry all EVENT_REPLAY_FIELDS.
for schema in events::EVENT_SCHEMAS {
for &field in events::EVENT_REPLAY_FIELDS {
assert!(
schema.payload_keys.contains(&field),
"Schema {} is missing mandatory replay field '{}'",
schema.name,
field
);
}
}
}
4 changes: 4 additions & 0 deletions app/contract/contracts/Folder/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ pub enum DataKey {
UpgradeGateEnabled,
/// Snapshot of pre-upgrade invariants for drift detection (Issue #554).
PreUpgradeInvariantSnapshot,
/// Tracks emitted event deduplication keys to reject duplicate/replayed
/// events. Keyed by a 32-byte deterministic hash of (event_type_id,
/// ledger_sequence, schema_version, timestamp). Stored with a 6-month TTL.
EventDedup(BytesN<32>),
}

// -----------------------------------------------------------------------------
Expand Down
Loading