-
-
Notifications
You must be signed in to change notification settings - Fork 34
feat: add guarded v1 actions and contract monitoring/gas analysis tooling #171
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| } | ||
|
|
||
| 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(); | ||
| 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, | ||
| }; |
| 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); | ||
| }); | ||
| }); |
| 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 |
| 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 | ||
| ``` |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.rsRepository: 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.rsRepository: 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.rsRepository: TheBlockCade/StellarCade Length of output: 1685 Add integration tests using The contract lacks integration tests that instantiate the contract and test the public entry points ( Reference the pattern in other contracts (e.g., 🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unbounded memory growth in
seenEventIdsSet.The
seenEventIdsSet 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
🤖 Prompt for AI Agents