diff --git a/contracts/solar_grid/Cargo.toml b/contracts/solar_grid/Cargo.toml index 8b8a63c..3d8c756 100644 --- a/contracts/solar_grid/Cargo.toml +++ b/contracts/solar_grid/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { version = "=27.0.2", features = ["alloc"] } diff --git a/contracts/solar_grid/src/lib.rs b/contracts/solar_grid/src/lib.rs index 73f0c17..b4394bc 100644 --- a/contracts/solar_grid/src/lib.rs +++ b/contracts/solar_grid/src/lib.rs @@ -31,6 +31,12 @@ pub enum ContractError { CollaboratorNotFound = 18, RefundExceedsPayments = 19, RefundLimitExceeded = 20, + /// The contract-wide emergency pause is active. + ContractPaused = 21, + /// The contract is already paused. + AlreadyPaused = 22, + /// The contract is not currently paused. + NotPaused = 23, } // ── Storage keys ────────────────────────────────────────────────────────────── @@ -53,6 +59,11 @@ const SECONDS_PER_WEEK: u64 = 604_800; /// Max total i128 refunded across all recipients per rolling window; 0 = unlimited. const REFUND_LIMIT: Symbol = symbol_short!("RFND_LIM"); const REFUND_WINDOW: Symbol = symbol_short!("RFND_WIN"); +/// Contract-wide emergency pause state and timestamp. +const PAUSED: Symbol = symbol_short!("PAUSED"); +const PAUSED_AT: Symbol = symbol_short!("PAUSE_AT"); +/// A pause automatically expires after 48 hours (Unix seconds). +const MAX_PAUSE_DURATION: u64 = 48 * 60 * 60; // ── Data types ──────────────────────────────────────────────────────────────── @@ -250,6 +261,9 @@ impl SolarGridContract { /// - `owner` must co-sign the registration (require_auth), confirming they /// consent to being the meter owner. pub fn register_meter(env: Env, meter_id: String, owner: Address) -> Result<(), ContractError> { + if Self::pause_is_active(&env) { + return Err(ContractError::ContractPaused); + } Self::require_admin(&env)?; let allowlist = Self::get_allowlist(env.clone())?; if !allowlist.contains(&owner) { @@ -633,6 +647,83 @@ impl SolarGridContract { .unwrap_or(false)) } + // ── Issue #672: emergency pause ─────────────────────────────────────────── + + /// Pause payments and meter registration for up to 48 hours. + /// + /// Usage reporting and all read-only methods remain available while paused, + /// allowing the oracle and dashboards to continue operating during an + /// incident. The pause is admin-only and emits the compact on-chain event + /// topic `paused` (logical event name: `contract_paused`). + pub fn pause(env: Env) -> Result<(), ContractError> { + Self::require_admin(&env)?; + + // A stale pause is cleared before evaluating whether a new pause is + // already active. This makes the expiry deterministic even if no + // transaction touched the contract during the 48-hour window. + if Self::pause_is_active(&env) { + return Err(ContractError::AlreadyPaused); + } + + let now = env.ledger().timestamp(); + env.storage().instance().set(&PAUSED, &true); + env.storage().instance().set(&PAUSED_AT, &now); + env.events().publish( + (EVT_NS, Symbol::new(&env, "contract_paused")), + (Self::get_admin(&env)?, now, MAX_PAUSE_DURATION), + ); + Ok(()) + } + + /// Resume payments and meter registration before the automatic expiry. + /// Admin-only; emits the compact topic `unpaused` (logical event name: + /// `contract_unpaused`). + pub fn unpause(env: Env) -> Result<(), ContractError> { + Self::require_admin(&env)?; + if !Self::pause_is_active(&env) { + return Err(ContractError::NotPaused); + } + + let now = env.ledger().timestamp(); + env.storage().instance().remove(&PAUSED); + env.storage().instance().remove(&PAUSED_AT); + env.events().publish( + (EVT_NS, Symbol::new(&env, "contract_unpaused")), + (Self::get_admin(&env)?, now), + ); + Ok(()) + } + + /// Return whether the emergency pause is active. A pause older than the + /// 48-hour maximum is treated as expired automatically. + pub fn is_paused(env: Env) -> Result { + Self::require_initialized(&env)?; + Ok(Self::pause_is_active(&env)) + } + + /// Read the pause flag and clear it when the maximum duration has elapsed. + /// This helper is called by state-changing guards as well as the view method + /// so the policy remains enforced even when no explicit `unpause` is sent. + fn pause_is_active(env: &Env) -> bool { + let paused: bool = env.storage().instance().get(&PAUSED).unwrap_or(false); + if !paused { + return false; + } + + let paused_at: u64 = env.storage().instance().get(&PAUSED_AT).unwrap_or(0); + let now = env.ledger().timestamp(); + if now.saturating_sub(paused_at) >= MAX_PAUSE_DURATION { + env.storage().instance().remove(&PAUSED); + env.storage().instance().remove(&PAUSED_AT); + env.events().publish( + (EVT_NS, Symbol::new(env, "contract_unpaused")), + (now, true), + ); + return false; + } + true + } + /// Make a payment to top up a meter's balance and activate it. /// `amount` is in the token's smallest unit. `plan` sets the billing cycle. /// @@ -646,6 +737,9 @@ impl SolarGridContract { amount: i128, plan: PaymentPlan, ) -> Result<(), ContractError> { + if Self::pause_is_active(&env) { + return Err(ContractError::ContractPaused); + } if env .storage() .instance() @@ -1507,6 +1601,8 @@ impl SolarGridContract { return Err(ContractError::DailyLimitReached); } meter.day_spent = meter.day_spent.saturating_add(cost); + let bal_key = DataKey::MeterBalance(meter_id.clone()); + let balance: i128 = env.storage().persistent().get(&bal_key).unwrap_or(0); let new_balance = balance.saturating_sub(cost).max(0); env.storage().persistent().set(&bal_key, &new_balance); meter.units_used = meter.units_used.saturating_add(units); @@ -3847,6 +3943,8 @@ mod tests { let reason = String::from_str(&env, "full refund"); client.refund_payment(&meter_id, &1_000_i128, &user, &reason); assert!(!client.get_meter(&meter_id).active); + } + // ── Issue #703: DST / Timezone independence tests ──────────────────────── #[test] diff --git a/contracts/solar_grid/tests/emergency_pause.rs b/contracts/solar_grid/tests/emergency_pause.rs new file mode 100644 index 0000000..653b448 --- /dev/null +++ b/contracts/solar_grid/tests/emergency_pause.rs @@ -0,0 +1,71 @@ +use solar_grid::{ContractError, PaymentPlan, SolarGridContract, SolarGridContractClient}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, String, +}; + +fn setup() -> (Env, SolarGridContractClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let contract_id = env.register(SolarGridContract, (&admin, &token_address)); + let client = SolarGridContractClient::new(&env, &contract_id); + (env, client, admin, token_address) +} + +#[test] +fn pause_blocks_payment_and_registration_but_allows_usage_updates() { + let (env, client, _admin, token_address) = setup(); + let token_admin = token::StellarAssetClient::new(&env, &token_address); + let oracle = Address::generate(&env); + client.set_oracle(&oracle); + + let owner = Address::generate(&env); + let meter_id = String::from_str(&env, "PAUSE_MAIN"); + client.allowlist_add(&owner); + client.register_meter(&meter_id, &owner); + token_admin.mint(&owner, &2_000_i128); + client.make_payment(&meter_id, &owner, &1_000_i128, &PaymentPlan::Daily); + + client.pause(); + assert!(client.is_paused()); + + assert_eq!( + client.try_make_payment(&meter_id, &owner, &100_i128, &PaymentPlan::Daily), + Err(Ok(ContractError::ContractPaused)) + ); + + let new_meter_id = String::from_str(&env, "PAUSE_REGISTER"); + assert_eq!( + client.try_register_meter(&new_meter_id, &owner), + Err(Ok(ContractError::ContractPaused)) + ); + + client.update_usage(&meter_id, &1_u64, &10_i128); + assert_eq!(client.get_meter_balance(&meter_id), 990_i128); +} + +#[test] +fn pause_expires_at_48_hours_and_allows_payments_again() { + let (env, client, _admin, token_address) = setup(); + let token_admin = token::StellarAssetClient::new(&env, &token_address); + let owner = Address::generate(&env); + let meter_id = String::from_str(&env, "PAUSE_EXPIRY"); + client.allowlist_add(&owner); + client.register_meter(&meter_id, &owner); + token_admin.mint(&owner, &1_000_i128); + + env.ledger().with_mut(|li| li.timestamp = 10); + client.pause(); + assert!(client.is_paused()); + + env.ledger().with_mut(|li| li.timestamp = 10 + 48 * 60 * 60); + assert!(!client.is_paused()); + + // The automatic expiry is equivalent to an explicit unpause. + client.make_payment(&meter_id, &owner, &1_000_i128, &PaymentPlan::Daily); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 9a3012c..58cbf55 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { ToastProvider } from "@/components/ToastProvider"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { I18nProvider } from "@/components/I18nProvider"; +import { ContractPauseBanner } from "@/components/ContractPauseBanner"; import "./globals.css"; export const metadata: Metadata = { @@ -27,6 +28,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + {children} diff --git a/frontend/src/components/ContractPauseBanner.tsx b/frontend/src/components/ContractPauseBanner.tsx new file mode 100644 index 0000000..ae8b408 --- /dev/null +++ b/frontend/src/components/ContractPauseBanner.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { isContractPaused } from "@/lib/contract"; + +const POLL_INTERVAL_MS = 30_000; + +/** + * Shows a global warning while the on-chain contract pause is active. + * + * The initial query and polling are deliberately best-effort: a temporary RPC + * failure must not make the entire dashboard unusable or imply that payments + * are available. The contract remains the source of truth for enforcement. + */ +export function ContractPauseBanner() { + const t = useTranslations("pauseBanner"); + const [paused, setPaused] = useState(false); + const [checked, setChecked] = useState(false); + + useEffect(() => { + let mounted = true; + + const checkPauseState = async () => { + try { + const nextPaused = await isContractPaused(); + if (mounted) setPaused(nextPaused); + } catch { + // RPC failures are transient; keep the last known state and retry. + } finally { + if (mounted) setChecked(true); + } + }; + + void checkPauseState(); + const interval = window.setInterval(() => void checkPauseState(), POLL_INTERVAL_MS); + + return () => { + mounted = false; + window.clearInterval(interval); + }; + }, []); + + if (!checked || !paused) return null; + + return ( +
+ + + {t("title")}{" "} + {t("message")} + +
+ ); +} diff --git a/frontend/src/lib/contract.ts b/frontend/src/lib/contract.ts index f61be42..039e6b5 100644 --- a/frontend/src/lib/contract.ts +++ b/frontend/src/lib/contract.ts @@ -138,6 +138,12 @@ export async function checkMeterAccess(meterId: string): Promise { return StellarSdk.scValToNative(retval) as boolean; } +/** Read the contract-wide emergency pause state for the global banner. */ +export async function isContractPaused(): Promise { + const retval = await client.query("is_paused", []); + return StellarSdk.scValToNative(retval) as boolean; +} + export async function fetchAllMeters(): Promise { const [dataRetval, idsRetval] = await Promise.all([ client.query("get_all_meters", []), diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 3f0c7c1..fbbb20c 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -130,6 +130,10 @@ "weekly": "Weekly", "usage": "Usage" }, + "pauseBanner": { + "title": "Emergency pause active", + "message": "Payments and meter registration are temporarily disabled. Usage reporting remains available." + }, "common": { "loading": "Loading…", "error": "Error", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 5a556ea..a03d435 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -130,6 +130,10 @@ "weekly": "Hebdomadaire", "usage": "Usage" }, + "pauseBanner": { + "title": "Pause d'urgence active", + "message": "Les paiements et l'enregistrement des compteurs sont temporairement désactivés. Le suivi de la consommation reste disponible." + }, "common": { "loading": "Chargement…", "error": "Erreur",