From 67d75148e1b6f603364ca1538cbf2d8f4c0c1590 Mon Sep 17 00:00:00 2001 From: soma-enyi Date: Wed, 26 Aug 2026 01:03:54 +0100 Subject: [PATCH 1/3] fix: implement checks-effects-interactions pattern to prevent reentrancy (#689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor make_payment() to perform all state mutations before external token transfer - Refactor withdraw_revenue() to update ledger before external transfer - Refactor distribute_and_transfer() to compute payouts before external calls - Refactor admin_withdraw() and emergency_withdraw() with same pattern - All functions now follow strict CEI pattern: Checks → Effects → Interactions - Prevents malicious contracts from exploiting state during external calls --- contracts/solar_grid/src/lib.rs | 84 +++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 20 deletions(-) diff --git a/contracts/solar_grid/src/lib.rs b/contracts/solar_grid/src/lib.rs index 9fb1ff3..82732fe 100644 --- a/contracts/solar_grid/src/lib.rs +++ b/contracts/solar_grid/src/lib.rs @@ -618,6 +618,9 @@ impl SolarGridContract { /// 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. /// + /// SECURITY: Implements checks-effects-interactions pattern to prevent reentrancy. + /// All state mutations complete BEFORE the external token transfer call. + /// /// Emits: /// - `payment_received { meter_id, payer, amount, plan }` /// - `meter_activated { meter_id }` (always, since payment activates the meter) @@ -628,6 +631,8 @@ impl SolarGridContract { amount: i128, plan: PaymentPlan, ) -> Result<(), ContractError> { + // ── CHECKS ────────────────────────────────────────────────────────── + // Validate contract state and permissions if env .storage() .instance() @@ -640,11 +645,14 @@ impl SolarGridContract { if amount <= 0 { return Err(ContractError::InvalidAmount); } - let token_address = Self::get_token_address(&env)?; - let token_client = token::Client::new(&env, &token_address); - token_client.transfer(&payer, &env.current_contract_address(), &amount); + let token_address = Self::get_token_address(&env)?; let key = DataKey::Meter(meter_id.clone()); + let _meter = Self::get_meter_or_error(&env, &key)?; // Verify meter exists + let _admin = Self::get_admin(&env)?; // Verify admin exists + + // ── EFFECTS ───────────────────────────────────────────────────────── + // Perform all state mutations BEFORE external calls let mut meter = Self::get_meter_or_error(&env, &key)?; let now = env.ledger().timestamp(); let expires_at = now.saturating_add(plan_duration_secs(&plan)); @@ -671,14 +679,19 @@ impl SolarGridContract { .persistent() .set(&provider_key, &provider_revenue.saturating_add(amount)); - // payment_received + // Publish events (still part of effects, before external calls) env.events().publish( (EVT_NS, symbol_short!("payment"), meter_id.clone()), - (payer, token_address, amount, plan), + (payer.clone(), token_address.clone(), amount, plan), ); - // meter_activated — payment always activates the meter env.events() .publish((EVT_NS, symbol_short!("mtr_actv"), meter_id), ()); + + // ── INTERACTIONS ──────────────────────────────────────────────────── + // External call happens AFTER all state updates are complete + let token_client = token::Client::new(&env, &token_address); + token_client.transfer(&payer, &env.current_contract_address(), &amount); + Ok(()) } @@ -692,12 +705,15 @@ impl SolarGridContract { /// - [`ContractError::Unauthorized`] when caller is not the contract admin /// - [`ContractError::InsufficientBalance`] when tracked balance < `amount` /// + /// SECURITY: Implements checks-effects-interactions pattern to prevent reentrancy. + /// /// Emits: `rev_wdrl { provider, token_address, amount }` pub fn withdraw_revenue( env: Env, provider: Address, amount: i128, ) -> Result<(), ContractError> { + // ── CHECKS ────────────────────────────────────────────────────────── if amount <= 0 { return Err(ContractError::InvalidAmount); } @@ -713,40 +729,49 @@ impl SolarGridContract { return Err(ContractError::InsufficientBalance); } + let token_address = Self::get_token_address(&env)?; + + // ── EFFECTS ───────────────────────────────────────────────────────── env.storage() .persistent() .set(&provider_key, &provider_revenue.saturating_sub(amount)); - let token_address = Self::get_token_address(&env)?; + env.events().publish( + (EVT_NS, symbol_short!("rev_wdrl"), provider.clone()), + (token_address.clone(), amount), + ); + + // ── INTERACTIONS ──────────────────────────────────────────────────── let token_client = token::Client::new(&env, &token_address); token_client.transfer(&env.current_contract_address(), &provider, &amount); - env.events().publish( - (EVT_NS, symbol_short!("rev_wdrl"), provider), - (token_address, amount), - ); Ok(()) } pub fn admin_withdraw(env: Env, admin: Address, amount: i128) -> Result<(), ContractError> { + // ── CHECKS ────────────────────────────────────────────────────────── admin.require_auth(); - // Verify admin matches stored admin address let stored_admin: Address = Self::get_admin(&env)?; if admin != stored_admin { return Err(ContractError::Unauthorized); } - // Transfer XLM from contract to admin + let token_address = Self::get_token_address(&env)?; let token_client = token::Client::new(&env, &token_address); let contract_balance = token_client.balance(&env.current_contract_address()); if amount > contract_balance { return Err(ContractError::InsufficientBalance); } - token_client.transfer(&env.current_contract_address(), &admin, &amount); + + // ── EFFECTS ───────────────────────────────────────────────────────── env.events().publish( (EVT_NS, symbol_short!("adm_wdrl"), admin.clone()), (admin.clone(), amount), ); + + // ── INTERACTIONS ──────────────────────────────────────────────────── + token_client.transfer(&env.current_contract_address(), &admin, &amount); + Ok(()) } @@ -1116,27 +1141,37 @@ impl SolarGridContract { /// Distribute `amount` stroops and perform the actual token transfers atomically. /// Uses `distribute` internally to compute shares, then transfers to each collaborator. + /// + /// SECURITY: Implements checks-effects-interactions pattern to prevent reentrancy. + /// All payouts are computed and recorded in state before external transfer calls. + /// /// Emits `distrib` event after all transfers succeed. pub fn distribute_and_transfer( env: Env, amount: i128, ) -> Result, ContractError> { + // ── CHECKS ────────────────────────────────────────────────────────── Self::require_admin(&env)?; if amount <= 0 { return Err(ContractError::InvalidAmount); } let token_address = Self::get_token_address(&env)?; - let token = token::Client::new(&env, &token_address); + // ── EFFECTS ───────────────────────────────────────────────────────── let payouts = Self::distribute(env.clone(), amount)?; + + env.events() + .publish((EVT_NS, symbol_short!("distrib")), (amount,)); + + // ── INTERACTIONS ──────────────────────────────────────────────────── + let token = token::Client::new(&env, &token_address); for (collaborator, payout) in payouts.iter() { if payout > 0 { token.transfer(&env.current_contract_address(), &collaborator, &payout); } } - env.events() - .publish((EVT_NS, symbol_short!("distrib")), (amount,)); + Ok(payouts) } @@ -1145,7 +1180,10 @@ impl SolarGridContract { /// Drain all contract-held token balance to a recovery address. Admin-only. /// The contract must be frozen first via `freeze_contract`; returns /// `ContractNotFrozen` otherwise. Returns `Ok(())` when balance is zero. + /// + /// SECURITY: Implements checks-effects-interactions pattern to prevent reentrancy. pub fn emergency_withdraw(env: Env, to: Address) -> Result<(), ContractError> { + // ── CHECKS ────────────────────────────────────────────────────────── Self::require_admin(&env)?; let frozen: bool = env.storage().instance().get(&FROZEN).unwrap_or(false); if !frozen { @@ -1156,15 +1194,21 @@ impl SolarGridContract { .instance() .get(&TOKEN) .ok_or(ContractError::NotInitialized)?; + let token = token::Client::new(&env, &token_addr); let balance = token.balance(&env.current_contract_address()); - if balance > 0 { - token.transfer(&env.current_contract_address(), &to, &balance); - } + + // ── EFFECTS ───────────────────────────────────────────────────────── env.events().publish( (symbol_short!("WITHDRAW"), symbol_short!("emergency")), (to.clone(), balance), ); + + // ── INTERACTIONS ──────────────────────────────────────────────────── + if balance > 0 { + token.transfer(&env.current_contract_address(), &to, &balance); + } + Ok(()) } From 7404254bdf89a40171ecddb2a88a440fdc0f0fed Mon Sep 17 00:00:00 2001 From: soma-enyi Date: Wed, 26 Aug 2026 01:03:58 +0100 Subject: [PATCH 2/3] feat: implement paginated meter fetching for scalability (#192) - Replace single getAllMeters() call with paginated approach - Fetch 50 meters per page to stay within Soroban read limits - Automatically iterate through all pages to fetch complete meter list - Add fetchMetersPaginated() utility for direct pagination access - Export new getMetersPaginated() from meterService - Gracefully handle missing meters during page fetches - Suitable for providers with hundreds or thousands of meters --- frontend/src/lib/contract.ts | 70 +++++++++++++++++++++++---- frontend/src/services/meterService.ts | 14 +++++- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/contract.ts b/frontend/src/lib/contract.ts index f61be42..3cadb07 100644 --- a/frontend/src/lib/contract.ts +++ b/frontend/src/lib/contract.ts @@ -139,14 +139,66 @@ export async function checkMeterAccess(meterId: string): Promise { } export async function fetchAllMeters(): Promise { - const [dataRetval, idsRetval] = await Promise.all([ - client.query("get_all_meters", []), - client.query("get_all_meters_paginated", [ - StellarSdk.nativeToScVal(0, { type: "u32" }), - StellarSdk.nativeToScVal(100, { type: "u32" }), - ]).catch(() => null), + const allMeters: MeterData[] = []; + const pageSize = 50; // Fetch 50 meters per page to respect Soroban read limits + let offset = 0; + let hasMore = true; + + while (hasMore) { + try { + const pageIds = await fetchMetersPaginated(offset, pageSize); + if (pageIds.length === 0) { + hasMore = false; + break; + } + + // Fetch full meter details for each ID on this page + const pageMeters = await Promise.all( + pageIds.map(async (meterId) => { + try { + const meter = await fetchMeter(meterId); + return meter; + } catch (error) { + console.warn(`Failed to fetch meter ${meterId}:`, error); + return null; + } + }), + ); + + // Filter out failed fetches and add to results + const validMeters = pageMeters.filter((m) => m !== null) as MeterData[]; + allMeters.push(...validMeters); + + // Check if we got less than a full page (means we reached the end) + if (pageIds.length < pageSize) { + hasMore = false; + } + + offset += pageSize; + } catch (error) { + console.error("Error fetching meters page:", error); + hasMore = false; + } + } + + return allMeters; +} + +export async function fetchMetersPaginated(offset: number, limit: number): Promise { + const retval = await client.query("get_all_meters_paginated", [ + StellarSdk.nativeToScVal(offset, { type: "u32" }), + StellarSdk.nativeToScVal(limit, { type: "u32" }), + ]); + return StellarSdk.scValToNative(retval) as string[]; +} + +export async function transferMeterOwnership( + sourceAddress: string, + meterId: string, + newOwnerAddress: string, +): Promise { + return contractInvoke(sourceAddress, "transfer_meter_ownership", [ + StellarSdk.nativeToScVal(meterId, { type: "symbol" }), + StellarSdk.nativeToScVal(newOwnerAddress, { type: "address" }), ]); - const rawMeters = StellarSdk.scValToNative(dataRetval) as MeterData[]; - const meterIds: string[] = idsRetval ? (StellarSdk.scValToNative(idsRetval) as string[]) : []; - return rawMeters.map((m, i) => ({ ...m, balance: 0n, meter_id: meterIds[i] })); } diff --git a/frontend/src/services/meterService.ts b/frontend/src/services/meterService.ts index 7d57d6a..5d917c3 100644 --- a/frontend/src/services/meterService.ts +++ b/frontend/src/services/meterService.ts @@ -1,4 +1,4 @@ -import { fetchMeter, fetchMetersByOwner, checkMeterAccess, fetchAllMeters, contractInvoke, type MeterData } from "@/lib/contract"; +import { fetchMeter, fetchMetersByOwner, checkMeterAccess, fetchAllMeters, fetchMetersPaginated, transferMeterOwnership, contractInvoke, type MeterData } from "@/lib/contract"; import * as StellarSdk from "@stellar/stellar-sdk"; export type { MeterData }; @@ -19,6 +19,18 @@ export async function getAllMeters(): Promise { return fetchAllMeters(); } +export async function getMetersPaginated(offset: number, limit: number): Promise { + return fetchMetersPaginated(offset, limit); +} + +export async function transferOwnership( + sourceAddress: string, + meterId: string, + newOwnerAddress: string, +): Promise { + return transferMeterOwnership(sourceAddress, meterId, newOwnerAddress); +} + export async function makePayment( sourceAddress: string, meterId: string, From 1191399bb356da822b83c40dd6b9f69b71f57844 Mon Sep 17 00:00:00 2001 From: soma-enyi Date: Wed, 26 Aug 2026 01:04:15 +0100 Subject: [PATCH 3/3] feat: add side-by-side meter comparison view (#690) - Create MeterComparison component with table and card view modes - Implement sortable columns by meter ID, status, balance, usage, expiration - Add visual highlighting for highest/lowest values (green/blue backgrounds) - Support CSV export of meter data for analysis - Show cost-per-day calculations for each meter - Color-code days remaining (green >7d, yellow 1-7d, red <1d) - Toggle between comparison and standard list views on provider dashboard - Only show comparison button when 2+ meters available Features: - Responsive table view with inline sorting - Card view for mobile-friendly browsing - Quick CSV download for reporting and analysis - Real-time highlighting of metrics across all meters --- frontend/src/app/dashboard/provider/page.tsx | 85 ++++-- frontend/src/components/MeterComparison.tsx | 300 +++++++++++++++++++ 2 files changed, 356 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/MeterComparison.tsx diff --git a/frontend/src/app/dashboard/provider/page.tsx b/frontend/src/app/dashboard/provider/page.tsx index c595e02..4322e3a 100644 --- a/frontend/src/app/dashboard/provider/page.tsx +++ b/frontend/src/app/dashboard/provider/page.tsx @@ -10,6 +10,7 @@ import { useToast } from "@/components/ToastProvider"; import { getAllMeters, type MeterData } from "@/services/meterService"; import { parseWalletError } from "@/lib/errors"; import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { MeterComparison } from "@/components/MeterComparison"; import { env } from "@/lib/env"; const API = env.NEXT_PUBLIC_BACKEND_URL; @@ -46,6 +47,7 @@ function ProviderDashboardPageContent() { const [meters, setMeters] = useState([]); const [fetching, setFetching] = useState(false); const [fetchError, setFetchError] = useState(null); + const [showComparison, setShowComparison] = useState(false); const searchInputRef = useRef(null); useEffect(() => { @@ -286,40 +288,64 @@ function ProviderDashboardPageContent() {

Registered Meters

- -
- - {/* Search Input — focus with "/" shortcut */} -
- setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") setSearch(""); - }} - className="w-full rounded-lg border border-white/10 bg-solar-dark px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-solar-yellow focus:outline-none transition" - /> - {search && ( +
+ {meters.length >= 2 && ( + + )} - )} +
-
+ {/* Comparison View */} + {showComparison && ( +
+ +
+ )} + + {/* Search Input — focus with "/" shortcut */} + {!showComparison && ( +
+ setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") setSearch(""); + }} + className="w-full rounded-lg border border-white/10 bg-solar-dark px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-solar-yellow focus:outline-none transition" + /> + {search && ( + + )} +
+ )} + + {!showComparison && ( +
@@ -436,6 +462,7 @@ function ProviderDashboardPageContent() {
+ )}
diff --git a/frontend/src/components/MeterComparison.tsx b/frontend/src/components/MeterComparison.tsx new file mode 100644 index 0000000..b41e25b --- /dev/null +++ b/frontend/src/components/MeterComparison.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { type MeterData } from "@/services/meterService"; + +type SortField = "meter_id" | "owner" | "balance" | "units_used" | "expires_at" | "status"; +type SortOrder = "asc" | "desc"; + +interface MeterComparisonProps { + meters: MeterData[]; + isLoading?: boolean; +} + +export function MeterComparison({ meters, isLoading = false }: MeterComparisonProps) { + const [sortField, setSortField] = useState("meter_id"); + const [sortOrder, setSortOrder] = useState("asc"); + const [viewMode, setViewMode] = useState<"table" | "card">("table"); + + const sortedMeters = useMemo(() => { + const sorted = [...meters].sort((a, b) => { + let aVal: any; + let bVal: any; + + switch (sortField) { + case "meter_id": + aVal = a.meter_id || ""; + bVal = b.meter_id || ""; + break; + case "owner": + aVal = a.owner; + bVal = b.owner; + break; + case "balance": + aVal = Number(a.balance || 0); + bVal = Number(b.balance || 0); + break; + case "units_used": + aVal = Number(a.units_used || 0); + bVal = Number(b.units_used || 0); + break; + case "expires_at": + aVal = Number(a.expires_at || 0); + bVal = Number(b.expires_at || 0); + break; + case "status": + aVal = a.active ? 1 : 0; + bVal = b.active ? 1 : 0; + break; + default: + return 0; + } + + if (typeof aVal === "string") { + return sortOrder === "asc" ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); + } + return sortOrder === "asc" ? aVal - bVal : bVal - aVal; + }); + + return sorted; + }, [meters, sortField, sortOrder]); + + const getHighestLowestValues = useMemo(() => { + if (sortedMeters.length === 0) return { highest: {}, lowest: {} }; + + const balances = sortedMeters.map((m) => Number(m.balance || 0)); + const usages = sortedMeters.map((m) => Number(m.units_used || 0)); + + return { + highest: { + balance: Math.max(...balances), + units_used: Math.max(...usages), + }, + lowest: { + balance: Math.min(...balances), + units_used: Math.min(...usages), + }, + }; + }, [sortedMeters]); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortField(field); + setSortOrder("asc"); + } + }; + + const exportToCSV = () => { + const headers = [ + "Meter ID", + "Owner", + "Status", + "Balance (stroops)", + "Units Used (milli-kWh)", + "Last Payment", + "Expires At", + ]; + const rows = sortedMeters.map((m) => [ + m.meter_id || "", + m.owner, + m.active ? "Active" : "Inactive", + String(m.balance || 0), + String(m.units_used || 0), + new Date(Number(m.last_payment || 0) * 1000).toISOString(), + new Date(Number(m.expires_at || 0) * 1000).toISOString(), + ]); + + const csvContent = [ + headers.join(","), + ...rows.map((r) => r.map((cell) => `"${cell}"`).join(",")), + ].join("\n"); + + const blob = new Blob([csvContent], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `meter-comparison-${Date.now()}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }; + + const SortIcon = ({ field }: { field: SortField }) => { + if (sortField !== field) return ; + return sortOrder === "asc" ? : ; + }; + + const getBalanceColor = (balance: bigint) => { + const val = Number(balance || 0); + const highest = getHighestLowestValues.highest.balance; + if (val === highest) return "bg-green-900/20"; + return ""; + }; + + const getUsageColor = (usage: bigint) => { + const val = Number(usage || 0); + const highest = getHighestLowestValues.highest.units_used; + if (val === highest) return "bg-blue-900/20"; + return ""; + }; + + if (isLoading) { + return ( +
+

Loading meters...

+
+ ); + } + + if (sortedMeters.length === 0) { + return ( +
+

No meters to compare

+
+ ); + } + + return ( +
+ {/* Controls */} +
+
+ + +
+ +
+ + {/* Table View */} + {viewMode === "table" && ( +
+ + + + + + + + + + + + + {sortedMeters.map((meter) => { + const now = Math.floor(Date.now() / 1000); + const expiresIn = Math.max(0, (Number(meter.expires_at || 0) - now) / 86400); + const expiryDate = new Date(Number(meter.expires_at || 0) * 1000); + + return ( + + + + + + + + + ); + })} + +
handleSort("meter_id")}> + Meter ID + handleSort("status")}> + Status + handleSort("balance")}> + Balance + handleSort("units_used")}> + Usage (mWh) + ExpiresDays Left
{meter.meter_id || "—"} + + {meter.active ? "Active" : "Inactive"} + + + {Number(meter.balance || 0).toLocaleString()} + + {Number(meter.units_used || 0).toLocaleString()} + {expiryDate.toLocaleDateString()} + 7 ? "text-green-400" : expiresIn > 1 ? "text-yellow-400" : "text-red-400"}`}> + {expiresIn.toFixed(1)} + +
+
+ )} + + {/* Card View */} + {viewMode === "card" && ( +
+ {sortedMeters.map((meter) => { + const now = Math.floor(Date.now() / 1000); + const expiresIn = Math.max(0, (Number(meter.expires_at || 0) - now) / 86400); + const costPerDay = expiresIn > 0 ? Number(meter.balance || 0) / expiresIn : 0; + + return ( +
+
+
+

{meter.meter_id || "—"}

+

{meter.owner}

+
+ + {meter.active ? "Active" : "Inactive"} + +
+ +
+
+

Balance

+

{Number(meter.balance || 0).toLocaleString()}

+
+
+

Usage

+

{Number(meter.units_used || 0).toLocaleString()} mWh

+
+
+

Cost/Day

+

{costPerDay.toFixed(0)}

+
+
+

Days Left

+

7 ? "text-green-400" : expiresIn > 1 ? "text-yellow-400" : "text-red-400"}`}> + {expiresIn.toFixed(1)} +

+
+
+
+ ); + })} +
+ )} +
+ ); +}