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
2 changes: 1 addition & 1 deletion contracts/solar_grid/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
98 changes: 98 additions & 0 deletions contracts/solar_grid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────
Expand All @@ -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 ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<bool, ContractError> {
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.
///
Expand All @@ -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()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]
Expand Down
71 changes: 71 additions & 0 deletions contracts/solar_grid/tests/emergency_pause.rs
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 2 additions & 0 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -27,6 +28,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<body>
<ErrorBoundary>
<I18nProvider>
<ContractPauseBanner />
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ErrorBoundary>
Expand Down
62 changes: 62 additions & 0 deletions frontend/src/components/ContractPauseBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="alert"
aria-live="polite"
data-testid="contract-pause-banner"
className="flex items-center gap-3 border-b border-amber-500/40 bg-amber-950/70 px-4 py-3 text-sm text-amber-200"
>
<span aria-hidden="true" className="text-lg">
!
</span>
<span>
<strong className="font-semibold">{t("title")}</strong>{" "}
{t("message")}
</span>
</div>
);
}
6 changes: 6 additions & 0 deletions frontend/src/lib/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ export async function checkMeterAccess(meterId: string): Promise<boolean> {
return StellarSdk.scValToNative(retval) as boolean;
}

/** Read the contract-wide emergency pause state for the global banner. */
export async function isContractPaused(): Promise<boolean> {
const retval = await client.query("is_paused", []);
return StellarSdk.scValToNative(retval) as boolean;
}

export async function fetchAllMeters(): Promise<MeterData[]> {
const [dataRetval, idsRetval] = await Promise.all([
client.query("get_all_meters", []),
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading