diff --git a/backend/src/services/contractMonitoring.service.js b/backend/src/services/contractMonitoring.service.js new file mode 100644 index 0000000..b898e74 --- /dev/null +++ b/backend/src/services/contractMonitoring.service.js @@ -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(); diff --git a/backend/src/utils/contractMonitoringAlerts.js b/backend/src/utils/contractMonitoringAlerts.js new file mode 100644 index 0000000..1bc260d --- /dev/null +++ b/backend/src/utils/contractMonitoringAlerts.js @@ -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, +}; diff --git a/backend/tests/unit/contractMonitoringAlerts.test.js b/backend/tests/unit/contractMonitoringAlerts.test.js new file mode 100644 index 0000000..fb4b3b6 --- /dev/null +++ b/backend/tests/unit/contractMonitoringAlerts.test.js @@ -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); + }); +}); diff --git a/contracts/contract-monitoring/Cargo.toml b/contracts/contract-monitoring/Cargo.toml new file mode 100644 index 0000000..c5cbeb1 --- /dev/null +++ b/contracts/contract-monitoring/Cargo.toml @@ -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 diff --git a/contracts/contract-monitoring/README.md b/contracts/contract-monitoring/README.md new file mode 100644 index 0000000..d38ebc4 --- /dev/null +++ b/contracts/contract-monitoring/README.md @@ -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 +``` diff --git a/contracts/contract-monitoring/src/lib.rs b/contracts/contract-monitoring/src/lib.rs new file mode 100644 index 0000000..1bb02bd --- /dev/null +++ b/contracts/contract-monitoring/src/lib.rs @@ -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 { + 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); + } +} diff --git a/contracts/gas-optimization-analysis/Cargo.toml b/contracts/gas-optimization-analysis/Cargo.toml new file mode 100644 index 0000000..f9c7641 --- /dev/null +++ b/contracts/gas-optimization-analysis/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "stellarcade-gas-optimization-analysis" +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 diff --git a/contracts/gas-optimization-analysis/README.md b/contracts/gas-optimization-analysis/README.md new file mode 100644 index 0000000..b7c6c32 --- /dev/null +++ b/contracts/gas-optimization-analysis/README.md @@ -0,0 +1,33 @@ +# Gas Optimization Analysis + +Lightweight analysis contract to track compute/storage cost per method and generate optimization guidance. + +## Public Interface + +- `init(admin)` - Initializes the analyzer. +- `record_sample(admin, method, cpu, read_bytes, write_bytes)` - Stores one measurement sample. +- `get_method_profile(method)` - Returns aggregate profile for a method. +- `get_hotspots(limit)` - Returns methods with computed hotspot scores. +- `get_recommendations(limit)` - Returns optimization recommendations with estimated savings. + +## Recommendation Rules + +- `split_method` when average CPU usage is high (`>= 50_000`). +- `cache_writes` when writes dominate reads (`avg_write > avg_read * 2`). + +## Security and Validation + +- Only admin can record samples. +- Samples with `cpu == 0` are rejected. +- All counters use saturating arithmetic. + +## CI Artifact Integration + +The output from `get_hotspots` and `get_recommendations` is deterministic and can be serialized by CI scripts as JSON artifacts for build reports. + +## Build and Test + +```bash +cd contracts/gas-optimization-analysis +cargo test +``` diff --git a/contracts/gas-optimization-analysis/src/lib.rs b/contracts/gas-optimization-analysis/src/lib.rs new file mode 100644 index 0000000..c1dfdc7 --- /dev/null +++ b/contracts/gas-optimization-analysis/src/lib.rs @@ -0,0 +1,223 @@ +#![no_std] +#![allow(unexpected_cfgs)] + +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, vec, Address, Env, Symbol, Vec}; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + NotAuthorized = 3, + InvalidMetric = 4, +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + Methods, + MethodProfile(Symbol), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct MethodProfile { + pub calls: u64, + pub total_cpu: u64, + pub total_read_bytes: u64, + pub total_write_bytes: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MethodHotspot { + pub method: Symbol, + pub score: u64, + pub avg_cpu: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OptimizationRecommendation { + pub method: Symbol, + pub recommendation: Symbol, + pub estimated_savings_bps: u32, +} + +#[contract] +pub struct GasOptimizationAnalysis; + +#[contractimpl] +impl GasOptimizationAnalysis { + 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::Methods, &Vec::::new(&env)); + Ok(()) + } + + pub fn record_sample( + env: Env, + admin: Address, + method: Symbol, + cpu: u64, + read_bytes: u64, + write_bytes: u64, + ) -> Result { + require_admin(&env, &admin)?; + if cpu == 0 { + return Err(Error::InvalidMetric); + } + + let key = DataKey::MethodProfile(method.clone()); + let mut profile: MethodProfile = env.storage().persistent().get(&key).unwrap_or_default(); + + profile.calls = profile.calls.saturating_add(1); + profile.total_cpu = profile.total_cpu.saturating_add(cpu); + profile.total_read_bytes = profile.total_read_bytes.saturating_add(read_bytes); + profile.total_write_bytes = profile.total_write_bytes.saturating_add(write_bytes); + + env.storage().persistent().set(&key, &profile); + register_method(&env, method); + Ok(profile) + } + + pub fn get_method_profile(env: Env, method: Symbol) -> MethodProfile { + env.storage() + .persistent() + .get(&DataKey::MethodProfile(method)) + .unwrap_or_default() + } + + pub fn get_hotspots(env: Env, limit: u32) -> Vec { + let methods: Vec = env.storage().instance().get(&DataKey::Methods).unwrap_or(vec![&env]); + let mut out = vec![&env]; + let max = if limit == 0 { methods.len() } else { core::cmp::min(limit, methods.len()) }; + + let mut i = 0; + while i < methods.len() && out.len() < max { + let method = methods.get(i).unwrap(); + let profile = Self::get_method_profile(env.clone(), method.clone()); + if profile.calls > 0 { + let avg_cpu = profile.total_cpu / profile.calls; + let score = avg_cpu.saturating_add(profile.total_write_bytes / profile.calls); + out.push_back(MethodHotspot { method, score, avg_cpu }); + } + i += 1; + } + + out + } + + pub fn get_recommendations(env: Env, limit: u32) -> Vec { + let hotspots = Self::get_hotspots(env.clone(), limit); + let mut out = vec![&env]; + + let mut i = 0; + while i < hotspots.len() { + let hotspot = hotspots.get(i).unwrap(); + let profile = Self::get_method_profile(env.clone(), hotspot.method.clone()); + let recommendation = recommend_for_profile(&env, hotspot.method, &profile); + if let Some(entry) = recommendation { + out.push_back(entry); + } + i += 1; + } + + out + } +} + +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 register_method(env: &Env, method: Symbol) { + let mut methods: Vec = env.storage().instance().get(&DataKey::Methods).unwrap_or(vec![env]); + if !methods.contains(&method) { + methods.push_back(method); + env.storage().instance().set(&DataKey::Methods, &methods); + } +} + +fn recommend_for_profile( + env: &Env, + method: Symbol, + profile: &MethodProfile, +) -> Option { + if profile.calls == 0 { + return None; + } + + let avg_cpu = profile.total_cpu / profile.calls; + let avg_read = profile.total_read_bytes / profile.calls; + let avg_write = profile.total_write_bytes / profile.calls; + + if avg_cpu >= 50_000 { + return Some(OptimizationRecommendation { + method, + recommendation: Symbol::new(env, "split_method"), + estimated_savings_bps: 2000, + }); + } + + if avg_write > avg_read.saturating_mul(2) { + return Some(OptimizationRecommendation { + method, + recommendation: Symbol::new(env, "cache_writes"), + estimated_savings_bps: 1500, + }); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::Env; + + #[test] + fn recommends_split_for_high_cpu_methods() { + let env = Env::default(); + let method = Symbol::new(&env, "resolve_game"); + let profile = MethodProfile { + calls: 5, + total_cpu: 300_000, + total_read_bytes: 10_000, + total_write_bytes: 10_000, + }; + + let rec = recommend_for_profile(&env, method.clone(), &profile).unwrap(); + assert_eq!(rec.method, method); + assert_eq!(rec.recommendation, Symbol::new(&env, "split_method")); + } + + #[test] + fn recommends_write_cache_when_write_dominates() { + let env = Env::default(); + let method = Symbol::new(&env, "settle"); + let profile = MethodProfile { + calls: 10, + total_cpu: 100_000, + total_read_bytes: 1_000, + total_write_bytes: 10_000, + }; + + let rec = recommend_for_profile(&env, method, &profile).unwrap(); + assert_eq!(rec.recommendation, Symbol::new(&env, "cache_writes")); + } +} diff --git a/docs/contracts/contract-monitoring.md b/docs/contracts/contract-monitoring.md new file mode 100644 index 0000000..ac63df4 --- /dev/null +++ b/docs/contracts/contract-monitoring.md @@ -0,0 +1,17 @@ +# Contract Monitoring + +The Contract Monitoring module tracks contract event ingestion and exposes health flags for backend dashboards. + +## Health Flags + +- `failed_settlement_alert` when failed settlements reach threshold. +- `high_error_rate` when error ratio crosses threshold. +- `paused` when the system is paused. + +## Metrics + +- `total_events` +- `settlement_success` +- `settlement_failed` +- `error_events` +- `paused_events` diff --git a/docs/contracts/gas-optimization-analysis.md b/docs/contracts/gas-optimization-analysis.md new file mode 100644 index 0000000..35e368b --- /dev/null +++ b/docs/contracts/gas-optimization-analysis.md @@ -0,0 +1,18 @@ +# Gas Optimization Analysis + +Gas Optimization Analysis captures contract execution samples and provides hotspot and recommendation outputs. + +## Inputs + +- method name +- CPU usage +- storage read bytes +- storage write bytes + +## Outputs + +- aggregate method profiles +- hotspot ranking scores +- estimated optimization recommendations + +These outputs can be exported by CI and attached as build artifacts. diff --git a/frontend/src/components/v1/AsyncStateBoundary.md b/frontend/src/components/v1/AsyncStateBoundary.md new file mode 100644 index 0000000..e8b0303 --- /dev/null +++ b/frontend/src/components/v1/AsyncStateBoundary.md @@ -0,0 +1,16 @@ +# AsyncStateBoundary + +Shared renderer for async lifecycle branches (`idle`, `loading`, `success`, `error`). + +## Example + +```tsx +

Loading...

} + renderSuccess={(items) => } +/> +``` diff --git a/frontend/src/components/v1/AsyncStateBoundary.tsx b/frontend/src/components/v1/AsyncStateBoundary.tsx new file mode 100644 index 0000000..d19509a --- /dev/null +++ b/frontend/src/components/v1/AsyncStateBoundary.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import type { AsyncStatus } from '../../types/v1/async'; + +export interface AsyncStateBoundaryProps { + status: AsyncStatus; + data?: T | null; + error?: E | null; + onRetry?: () => void | Promise; + renderIdle?: () => React.ReactNode; + renderLoading?: () => React.ReactNode; + renderEmpty?: () => React.ReactNode; + renderError?: (params: { error: E | null | undefined; retry?: () => void | Promise }) => React.ReactNode; + renderSuccess: (data: T) => React.ReactNode; + isEmpty?: (data: T) => boolean; + testId?: string; +} + +const VALID_STATUS: readonly AsyncStatus[] = ['idle', 'loading', 'success', 'error'] as const; + +export function AsyncStateBoundary({ + status, + data = null, + error = null, + onRetry, + renderIdle, + renderLoading, + renderEmpty, + renderError, + renderSuccess, + isEmpty, + testId = 'async-state-boundary', +}: AsyncStateBoundaryProps) { + const safeStatus: AsyncStatus = VALID_STATUS.includes(status) ? status : 'idle'; + + if (safeStatus === 'idle') { + return <>{renderIdle?.() ?? null}; + } + + if (safeStatus === 'loading') { + return <>{renderLoading?.() ??
Loading...
}; + } + + if (safeStatus === 'error') { + if (renderError) { + return <>{renderError({ error, retry: onRetry })}; + } + + return ( +
+

Something went wrong.

+ {onRetry && ( + + )} +
+ ); + } + + if (data == null) { + return <>{renderEmpty?.() ??
No data available.
}; + } + + if (isEmpty && isEmpty(data)) { + return <>{renderEmpty?.() ??
No data available.
}; + } + + return <>{renderSuccess(data)}; +} + +export default AsyncStateBoundary; diff --git a/frontend/src/components/v1/ContractActionButton.md b/frontend/src/components/v1/ContractActionButton.md new file mode 100644 index 0000000..a32fefc --- /dev/null +++ b/frontend/src/components/v1/ContractActionButton.md @@ -0,0 +1,16 @@ +# ContractActionButton + +Guarded mutation trigger for contract actions. Handles preconditions, in-flight locking, and mapped errors. + +## Example + +```tsx + submitFlipTx()} + walletConnected={isWalletConnected} + networkSupported={isSupportedNetwork} + onSuccess={(result) => onTxSubmitted(result)} + onError={(err) => trackError(err)} +/> +``` diff --git a/frontend/src/components/v1/ContractActionButton.tsx b/frontend/src/components/v1/ContractActionButton.tsx new file mode 100644 index 0000000..1918cf4 --- /dev/null +++ b/frontend/src/components/v1/ContractActionButton.tsx @@ -0,0 +1,91 @@ +import React, { useMemo, useState } from 'react'; +import type { AppError } from '../../types/errors'; +import { toAppError } from '../../utils/v1/errorMapper'; +import { ErrorNotice } from './ErrorNotice'; + +export interface ContractActionButtonProps { + label: string; + loadingLabel?: string; + action: () => Promise; + walletConnected: boolean; + networkSupported: boolean; + disabled?: boolean; + onSuccess?: (result: T) => void | Promise; + onError?: (error: AppError) => void | Promise; + className?: string; + testId?: string; +} + +export function ContractActionButton({ + label, + loadingLabel = 'Processing...', + action, + walletConnected, + networkSupported, + disabled = false, + onSuccess, + onError, + className = '', + testId = 'contract-action-button', +}: ContractActionButtonProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const sanitizedLabel = useMemo(() => { + const trimmed = label.trim(); + return trimmed.length > 0 ? trimmed : 'Run action'; + }, [label]); + + const blockedReason = useMemo(() => { + if (!walletConnected) { + return 'Connect wallet to continue.'; + } + if (!networkSupported) { + return 'Switch to a supported network.'; + } + return null; + }, [walletConnected, networkSupported]); + + const isDisabled = disabled || isLoading || blockedReason !== null; + + const handleClick = async () => { + if (isDisabled) { + return; + } + + setError(null); + setIsLoading(true); + + try { + const result = await action(); + await onSuccess?.(result); + } catch (err) { + const mapped = toAppError(err); + setError(mapped); + await onError?.(mapped); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + {blockedReason &&

{blockedReason}

} + + {error && } +
+ ); +} + +export default ContractActionButton; diff --git a/frontend/src/components/v1/index.ts b/frontend/src/components/v1/index.ts index d22234e..3746cf4 100644 --- a/frontend/src/components/v1/index.ts +++ b/frontend/src/components/v1/index.ts @@ -41,4 +41,10 @@ export type { WalletStatus, WalletCapabilities, WalletStatusError, -} from './WalletStatusCard.types'; \ No newline at end of file +} from './WalletStatusCard.types'; + +export { AsyncStateBoundary } from './AsyncStateBoundary'; +export type { AsyncStateBoundaryProps } from './AsyncStateBoundary'; + +export { ContractActionButton } from './ContractActionButton'; +export type { ContractActionButtonProps } from './ContractActionButton'; diff --git a/frontend/tests/components/v1/AsyncStateBoundary.test.tsx b/frontend/tests/components/v1/AsyncStateBoundary.test.tsx new file mode 100644 index 0000000..d9f42de --- /dev/null +++ b/frontend/tests/components/v1/AsyncStateBoundary.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { AsyncStateBoundary } from '../../../src/components/v1/AsyncStateBoundary'; + +describe('AsyncStateBoundary', () => { + it('renders loading branch', () => { + render( +
ok
} + />, + ); + + expect(screen.getByTestId('async-state-boundary-loading')).toBeInTheDocument(); + }); + + it('renders error branch and calls retry', () => { + const onRetry = jest.fn(); + + render( +
ok
} + />, + ); + + fireEvent.click(screen.getByTestId('async-state-boundary-retry')); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('renders empty when success data is null', () => { + render( +
ok
} + />, + ); + + expect(screen.getByTestId('async-state-boundary-empty')).toBeInTheDocument(); + }); + + it('renders success branch with data', () => { + render( +
{data.id}
} + />, + ); + + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('uses custom error renderer', () => { + render( +
custom
} + renderSuccess={() =>
ok
} + />, + ); + + expect(screen.getByText('custom')).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/components/v1/ContractActionButton.test.tsx b/frontend/tests/components/v1/ContractActionButton.test.tsx new file mode 100644 index 0000000..b946d62 --- /dev/null +++ b/frontend/tests/components/v1/ContractActionButton.test.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ContractActionButton } from '../../../src/components/v1/ContractActionButton'; + +describe('ContractActionButton', () => { + it('runs action and calls onSuccess', async () => { + const action = jest.fn().mockResolvedValue({ tx: 'abc' }); + const onSuccess = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId('contract-action-button')); + + await waitFor(() => expect(action).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + }); + + it('blocks when wallet is not connected', () => { + const action = jest.fn().mockResolvedValue({}); + + render( + , + ); + + expect(screen.getByTestId('contract-action-button')).toBeDisabled(); + expect(screen.getByTestId('contract-action-button-precondition')).toHaveTextContent('Connect wallet'); + }); + + it('blocks duplicate triggers while in-flight', async () => { + let resolveAction: (() => void) | undefined; + const action = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveAction = resolve; + }), + ); + + render( + , + ); + + const button = screen.getByTestId('contract-action-button'); + fireEvent.click(button); + fireEvent.click(button); + + expect(action).toHaveBeenCalledTimes(1); + resolveAction?.(); + await waitFor(() => expect(button).not.toBeDisabled()); + }); + + it('maps failures and calls onError', async () => { + const action = jest.fn().mockRejectedValue(new Error('contract failed')); + const onError = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId('contract-action-button')); + + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('contract-action-button-error')).toBeInTheDocument(); + }); +});