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
78 changes: 78 additions & 0 deletions backend/src/services/contractMonitoring.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const { evaluateMonitoringAlerts } = require('../utils/contractMonitoringAlerts');

const INITIAL_METRICS = Object.freeze({
totalEvents: 0,
settlementSuccess: 0,
settlementFailed: 0,
errorEvents: 0,
pausedEvents: 0,
});

class ContractMonitoringService {
constructor() {
this.metrics = { ...INITIAL_METRICS };
this.paused = false;
this.seenEventIds = new Set();
}
Comment on lines +11 to +16
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unbounded memory growth in seenEventIds Set.

The seenEventIds Set grows indefinitely as events are ingested. For a long-running service, this will eventually exhaust memory. Consider implementing a bounded cache (e.g., LRU with a max size) or periodic pruning strategy.

🛡️ Example approach using a sliding window
// Option 1: Use a Map with timestamps and periodically prune old entries
// Option 2: Use a bounded Set that evicts oldest entries when full
// Option 3: If event IDs are time-ordered, keep only IDs from last N minutes

constructor() {
  this.metrics = { ...INITIAL_METRICS };
  this.paused = false;
  this.seenEventIds = new Set();
  this.maxSeenEventIds = 100000; // Configure based on expected volume
}

ingestEvent({ eventId, kind }) {
  if (this.seenEventIds.has(eventId)) {
    throw new Error(`Duplicate event id: ${eventId}`);
  }

  // Prune if at capacity (simple FIFO approximation)
  if (this.seenEventIds.size >= this.maxSeenEventIds) {
    const oldest = this.seenEventIds.values().next().value;
    this.seenEventIds.delete(oldest);
  }

  this.seenEventIds.add(eventId);
  // ...
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/services/contractMonitoring.service.js` around lines 11 - 16, The
seenEventIds Set in the ContractMonitoringService constructor grows unbounded;
add a bounded cache or pruning strategy (e.g., introduce this.maxSeenEventIds in
the constructor and replace seenEventIds with an eviction policy) and update the
ingestEvent method to evict oldest entries when at capacity or to store
timestamps and periodically prune entries older than a sliding window; reference
the ContractMonitoringService constructor, seenEventIds, ingestEvent and add a
configurable maxSeenEventIds and eviction/prune logic to prevent unbounded
memory growth.


ingestEvent({ eventId, kind }) {
if (this.seenEventIds.has(eventId)) {
throw new Error(`Duplicate event id: ${eventId}`);
}

this.seenEventIds.add(eventId);
this.metrics.totalEvents += 1;

switch (kind) {
case 'settlement_success':
this.metrics.settlementSuccess += 1;
break;
case 'settlement_failed':
this.metrics.settlementFailed += 1;
break;
case 'error':
this.metrics.errorEvents += 1;
break;
case 'paused':
this.metrics.pausedEvents += 1;
break;
default:
break;
}

return {
metrics: this.getMetrics(),
alerts: this.getAlerts(),
};
}

setPaused(paused) {
this.paused = Boolean(paused);
return this.getHealth();
}

getMetrics() {
return { ...this.metrics };
}

getAlerts() {
return evaluateMonitoringAlerts(
{
totalEvents: this.metrics.totalEvents,
settlementFailed: this.metrics.settlementFailed,
errorEvents: this.metrics.errorEvents,
},
this.paused,
);
}

getHealth() {
return {
status: this.paused ? 'paused' : 'running',
alerts: this.getAlerts(),
metrics: this.getMetrics(),
};
}
}

module.exports = new ContractMonitoringService();
22 changes: 22 additions & 0 deletions backend/src/utils/contractMonitoringAlerts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const FAILED_SETTLEMENT_ALERT_THRESHOLD = 3;
const HIGH_ERROR_RATE_PERCENT = 20;
const HIGH_ERROR_RATE_MIN_SAMPLE = 10;

const isHighErrorRate = (errorEvents, totalEvents) => {
if (totalEvents < HIGH_ERROR_RATE_MIN_SAMPLE || totalEvents === 0) {
return false;
}

return ((errorEvents * 100) / totalEvents) >= HIGH_ERROR_RATE_PERCENT;
};

const evaluateMonitoringAlerts = (metrics, paused = false) => ({
paused: Boolean(paused),
failedSettlementAlert: metrics.settlementFailed >= FAILED_SETTLEMENT_ALERT_THRESHOLD,
highErrorRate: isHighErrorRate(metrics.errorEvents, metrics.totalEvents),
});

module.exports = {
evaluateMonitoringAlerts,
isHighErrorRate,
};
22 changes: 22 additions & 0 deletions backend/tests/unit/contractMonitoringAlerts.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const {
evaluateMonitoringAlerts,
isHighErrorRate,
} = require('../../src/utils/contractMonitoringAlerts');

describe('contractMonitoringAlerts', () => {
it('detects high error rate after minimum sample', () => {
expect(isHighErrorRate(1, 9)).toBe(false);
expect(isHighErrorRate(2, 10)).toBe(true);
});

it('builds alert payload', () => {
const alerts = evaluateMonitoringAlerts(
{ totalEvents: 20, settlementFailed: 3, errorEvents: 5 },
true,
);

expect(alerts.paused).toBe(true);
expect(alerts.failedSettlementAlert).toBe(true);
expect(alerts.highErrorRate).toBe(true);
});
});
22 changes: 22 additions & 0 deletions contracts/contract-monitoring/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "stellarcade-contract-monitoring"
version = "0.1.0"
edition = "2021"
publish = false

[dependencies]
soroban-sdk = "25.0.2"

[dev-dependencies]
soroban-sdk = { version = "25.0.2", features = ["testutils"] }

[lib]
crate-type = ["cdylib", "rlib"]

[profile.release]
opt-level = "z"
overflow-checks = true
debug = false
panic = "abort"
lto = true
codegen-units = 1
42 changes: 42 additions & 0 deletions contracts/contract-monitoring/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Contract Monitoring

Contract-side monitoring state for event ingestion, anomaly alerts, and dashboard metrics.

## Public Interface

- `init(admin)` - Initialize monitoring config.
- `ingest_event(admin, event_id, kind)` - Ingests a unique event and updates metrics.
- `set_paused(admin, paused)` - Updates paused state.
- `get_metrics()` - Returns aggregate counters.
- `get_health()` - Returns alert flags for:
- failed settlements (`>= 3`)
- high error rate (`>= 20%` once at least 10 events exist)
- paused state

## Event Kinds

- `SettlementSuccess`
- `SettlementFailed`
- `Error`
- `Paused`
- `Resumed`

## Storage

- `Admin` (instance)
- `Paused` (instance)
- `Metrics` (instance)
- `SeenEvent(event_id)` (persistent duplicate guard)

## Security and Invariants

- Only `admin` can ingest events and change pause state.
- Duplicate event IDs are rejected.
- Health rules are deterministic and computed from stored counters.

## Build and Test

```bash
cd contracts/contract-monitoring
cargo test
```
203 changes: 203 additions & 0 deletions contracts/contract-monitoring/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#![no_std]
#![allow(unexpected_cfgs)]

use soroban_sdk::{contract, contracterror, contractevent, contractimpl, contracttype, Address, Env};

pub const PERSISTENT_BUMP_LEDGERS: u32 = 518_400;
const FAILED_SETTLEMENT_ALERT_THRESHOLD: u64 = 3;
const ERROR_RATE_ALERT_PERCENT: u64 = 20;
const ERROR_RATE_MIN_SAMPLE: u64 = 10;

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum Error {
AlreadyInitialized = 1,
NotInitialized = 2,
NotAuthorized = 3,
DuplicateEvent = 4,
}

#[contracttype]
#[derive(Clone)]
pub enum DataKey {
Admin,
Paused,
Metrics,
SeenEvent(u64),
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum EventKind {
SettlementSuccess = 0,
SettlementFailed = 1,
Error = 2,
Paused = 3,
Resumed = 4,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq, Default)]
pub struct Metrics {
pub total_events: u64,
pub settlement_success: u64,
pub settlement_failed: u64,
pub error_events: u64,
pub paused_events: u64,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HealthSnapshot {
pub paused: bool,
pub high_error_rate: bool,
pub failed_settlement_alert: bool,
}

#[contractevent]
pub struct EventIngested {
#[topic]
pub event_id: u64,
pub kind: EventKind,
}

#[contractevent]
pub struct AlertRaised {
#[topic]
pub alert: u32,
}

#[contract]
pub struct ContractMonitoring;

#[contractimpl]
impl ContractMonitoring {
pub fn init(env: Env, admin: Address) -> Result<(), Error> {
if env.storage().instance().has(&DataKey::Admin) {
return Err(Error::AlreadyInitialized);
}
admin.require_auth();

env.storage().instance().set(&DataKey::Admin, &admin);
env.storage().instance().set(&DataKey::Paused, &false);
env.storage().instance().set(&DataKey::Metrics, &Metrics::default());
Ok(())
}

pub fn ingest_event(env: Env, admin: Address, event_id: u64, kind: EventKind) -> Result<Metrics, Error> {
require_admin(&env, &admin)?;

let seen_key = DataKey::SeenEvent(event_id);
if env.storage().persistent().has(&seen_key) {
return Err(Error::DuplicateEvent);
}

let mut metrics: Metrics = env.storage().instance().get(&DataKey::Metrics).unwrap_or_default();
apply_event(&mut metrics, &kind);

env.storage().instance().set(&DataKey::Metrics, &metrics);
env.storage().persistent().set(&seen_key, &true);
env.storage().persistent().extend_ttl(&seen_key, PERSISTENT_BUMP_LEDGERS, PERSISTENT_BUMP_LEDGERS);

EventIngested { event_id, kind: kind.clone() }.publish(&env);

let health = evaluate_health(&metrics, is_paused(&env));
if health.failed_settlement_alert {
AlertRaised { alert: 1 }.publish(&env);
}
if health.high_error_rate {
AlertRaised { alert: 2 }.publish(&env);
}
if health.paused {
AlertRaised { alert: 3 }.publish(&env);
}

Ok(metrics)
}

pub fn set_paused(env: Env, admin: Address, paused: bool) -> Result<(), Error> {
require_admin(&env, &admin)?;
env.storage().instance().set(&DataKey::Paused, &paused);
Ok(())
}

pub fn get_metrics(env: Env) -> Metrics {
env.storage().instance().get(&DataKey::Metrics).unwrap_or_default()
}

pub fn get_health(env: Env) -> HealthSnapshot {
evaluate_health(&Self::get_metrics(env.clone()), is_paused(&env))
}
}

fn require_admin(env: &Env, admin: &Address) -> Result<(), Error> {
if !env.storage().instance().has(&DataKey::Admin) {
return Err(Error::NotInitialized);
}
admin.require_auth();
let owner: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
if &owner != admin {
return Err(Error::NotAuthorized);
}
Ok(())
}

fn is_paused(env: &Env) -> bool {
env.storage().instance().get(&DataKey::Paused).unwrap_or(false)
}

fn apply_event(metrics: &mut Metrics, kind: &EventKind) {
metrics.total_events = metrics.total_events.saturating_add(1);
match kind {
EventKind::SettlementSuccess => metrics.settlement_success = metrics.settlement_success.saturating_add(1),
EventKind::SettlementFailed => metrics.settlement_failed = metrics.settlement_failed.saturating_add(1),
EventKind::Error => metrics.error_events = metrics.error_events.saturating_add(1),
EventKind::Paused => metrics.paused_events = metrics.paused_events.saturating_add(1),
EventKind::Resumed => {}
}
}

fn evaluate_health(metrics: &Metrics, paused: bool) -> HealthSnapshot {
let high_error_rate = is_high_error_rate(metrics.error_events, metrics.total_events);
let failed_settlement_alert = metrics.settlement_failed >= FAILED_SETTLEMENT_ALERT_THRESHOLD;

HealthSnapshot {
paused,
high_error_rate,
failed_settlement_alert,
}
}

fn is_high_error_rate(error_events: u64, total_events: u64) -> bool {
if total_events < ERROR_RATE_MIN_SAMPLE || total_events == 0 {
return false;
}
(error_events.saturating_mul(100) / total_events) >= ERROR_RATE_ALERT_PERCENT
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn marks_error_rate_when_threshold_crossed() {
assert!(!is_high_error_rate(1, 5));
assert!(!is_high_error_rate(1, 10));
assert!(is_high_error_rate(2, 10));
assert!(is_high_error_rate(3, 10));
}

#[test]
fn applies_event_counts_deterministically() {
let mut metrics = Metrics::default();
apply_event(&mut metrics, &EventKind::SettlementSuccess);
apply_event(&mut metrics, &EventKind::SettlementFailed);
apply_event(&mut metrics, &EventKind::Error);

assert_eq!(metrics.total_events, 3);
assert_eq!(metrics.settlement_success, 1);
assert_eq!(metrics.settlement_failed, 1);
assert_eq!(metrics.error_events, 1);
}
}
Comment on lines +179 to +203
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there are integration tests elsewhere or examples to reference
fd -e rs . contracts/ --exec grep -l "soroban_sdk::testutils\|register_contract" {} \;

Repository: TheBlockCade/StellarCade

Length of output: 409


🏁 Script executed:

# First, let's check the structure of the contract-monitoring file
wc -l contracts/contract-monitoring/src/lib.rs

Repository: TheBlockCade/StellarCade

Length of output: 112


🏁 Script executed:

# Get the full content of the contract file to understand entry points
cat -n contracts/contract-monitoring/src/lib.rs

Repository: TheBlockCade/StellarCade

Length of output: 7535


🏁 Script executed:

# Verify the pattern - check one of the contracts that does use soroban-sdk testutils
head -50 contracts/streak-bonus/src/test.rs

Repository: TheBlockCade/StellarCade

Length of output: 1685


Add integration tests using soroban-sdk testutils for public entry points.

The contract lacks integration tests that instantiate the contract and test the public entry points (init, ingest_event, set_paused, get_metrics, get_health) using Soroban's test environment. Per coding guidelines, smart contract tests must use soroban-sdk::testutils to properly verify authorization checks, event emissions, and contract state interactions.

Reference the pattern in other contracts (e.g., streak-bonus/src/test.rs) which uses Env::default(), env.register() to instantiate the contract, and a generated client to call entry points with proper auth mocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/contract-monitoring/src/lib.rs` around lines 179 - 203, Add
integration tests that use soroban-sdk::testutils to instantiate and exercise
the contract's public entry points (init, ingest_event, set_paused, get_metrics,
get_health) instead of only unit tests for internal helpers like Metrics,
apply_event, and EventKind; create an Env via Env::default(), register the
contract with env.register(), use the generated client to call each entry point
with proper auth mocking, assert emitted events and returned state (metrics
totals, paused flag, health status), and include tests that exercise
authorization checks for set_paused and ingest_event to mirror patterns used in
streak-bonus/src/test.rs.

Loading
Loading