Skip to content
Open
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
84 changes: 64 additions & 20 deletions contracts/solar_grid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -628,6 +631,8 @@ impl SolarGridContract {
amount: i128,
plan: PaymentPlan,
) -> Result<(), ContractError> {
// ── CHECKS ──────────────────────────────────────────────────────────
// Validate contract state and permissions
if env
.storage()
.instance()
Expand All @@ -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));
Expand All @@ -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(())
}

Expand All @@ -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);
}
Expand All @@ -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(())
}

Expand Down Expand Up @@ -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<Map<Address, i128>, 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)
}

Expand All @@ -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 {
Expand All @@ -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(())
}

Expand Down
85 changes: 56 additions & 29 deletions frontend/src/app/dashboard/provider/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,6 +47,7 @@ function ProviderDashboardPageContent() {
const [meters, setMeters] = useState<MeterData[]>([]);
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [showComparison, setShowComparison] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -286,40 +288,64 @@ function ProviderDashboardPageContent() {
<div className="w-full max-w-5xl">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-white">Registered Meters</h2>
<button
onClick={fetchMeters}
disabled={fetching}
className="text-xs text-gray-400 hover:text-solar-yellow transition flex items-center gap-1"
>
{fetching ? "Refreshing..." : "↻ Refresh List"}
</button>
</div>

{/* Search Input — focus with "/" shortcut */}
<div className="relative mb-4">
<input
ref={searchInputRef}
type="search"
placeholder="Search by owner address… (press / to focus)"
value={search}
onChange={(e) => 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 && (
<div className="flex items-center gap-2">
{meters.length >= 2 && (
<button
onClick={() => setShowComparison(!showComparison)}
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition ${
showComparison
? "bg-solar-yellow text-solar-dark"
: "bg-solar-accent text-gray-300 hover:bg-solar-accent/80"
}`}
>
{showComparison ? "Hide" : "Compare"}
</button>
)}
<button
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition"
aria-label="Clear search"
onClick={fetchMeters}
disabled={fetching}
className="text-xs text-gray-400 hover:text-solar-yellow transition flex items-center gap-1"
>
{fetching ? "Refreshing..." : "↻ Refresh List"}
</button>
)}
</div>
</div>

<div className="rounded-xl border border-white/10 bg-solar-accent overflow-hidden">
{/* Comparison View */}
{showComparison && (
<div className="mb-6">
<MeterComparison meters={filteredMeters} isLoading={fetching} />
</div>
)}

{/* Search Input — focus with "/" shortcut */}
{!showComparison && (
<div className="relative mb-4">
<input
ref={searchInputRef}
type="search"
placeholder="Search by owner address… (press / to focus)"
value={search}
onChange={(e) => 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 && (
<button
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition"
aria-label="Clear search"
>
</button>
)}
</div>
)}

{!showComparison && (
<div className="rounded-xl border border-white/10 bg-solar-accent overflow-hidden">
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full text-left text-sm text-gray-300">
<thead className="border-b border-white/10 bg-white/5 text-xs uppercase tracking-wider text-gray-400">
Expand Down Expand Up @@ -436,6 +462,7 @@ function ProviderDashboardPageContent() {
</table>
</div>
</div>
)}
</div>
</main>
</ErrorBoundary>
Expand Down
Loading