diff --git a/.github/workflows/contracts-ci.yml b/.github/workflows/contracts-ci.yml
index f5361d3..88a3e7c 100644
--- a/.github/workflows/contracts-ci.yml
+++ b/.github/workflows/contracts-ci.yml
@@ -1,49 +1,73 @@
name: Contract CI/CD Pipeline
on:
- push:
- branches: [main, develop]
- paths:
- - 'contract/**'
-
- pull_request:
- branches: [ main, develop ]
- paths:
- - 'contract/**'
+ push:
+ branches: [main, develop]
+ paths:
+ - "contract/**"
+ pull_request:
+ branches: [main, develop]
+ paths:
+ - "contract/**"
jobs:
- build:
+ build-and-test:
+ name: Contract Build & Test
runs-on: ubuntu-latest
steps:
- - name: Checkout code
- uses: actions/checkout@v3
+ - name: Checkout repository
+ uses: actions/checkout@v4
- - name: Setup Rust
- uses: actions-rs/toolchain@v1
+ - name: Setup Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- profile: minimal
- override: true
+ components: rustfmt, clippy
+ targets: wasm32-unknown-unknown
+
+ - name: Cache Cargo dependencies
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.cargo/bin/
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ contract/target/
+ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-cargo-
- name: Install Stellar CLI
run: |
curl -s https://get.stellar.org | bash
+ echo "$HOME/.stellar/bin" >> $GITHUB_PATH
- - name: Check formatting
+ - name: Check Formatting
run: cargo fmt --all -- --check
- working-directory: ./contract/contract
+ working-directory: ./contract
- - name: Run contract tests
+ - name: Lint with Clippy
+ run: cargo clippy --all-targets --all-features -- -D warnings
+ working-directory: ./contract
+
+ - name: Run Contract Tests
run: cargo test
- working-directory: ./contract/contract
+ working-directory: ./contract
+
+ - name: Build Contracts (WASM)
+ run: cargo build --target wasm32-unknown-unknown --release
+ working-directory: ./contract
- - name: Build contracts
- run: cargo build --release
- working-directory: ./contract/contract
+ - name: Verify WASM Build
+ run: |
+ ls -l target/wasm32-unknown-unknown/release/*.wasm
+ working-directory: ./contract
- - name: Deploy to Stellar testnet
+ - name: Deploy to Stellar Testnet (Manual/Workflow Dispatch)
+ if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')
run: echo "Add Stellar deployment script here"
- working-directory: ./contract/contract
+ working-directory: ./contract
env:
STELLAR_SECRET: ${{ secrets.STELLAR_SECRET }}
diff --git a/contract/contract/src/base/errors.rs b/contract/contract/src/base/errors.rs
index 593df9a..148ed95 100644
--- a/contract/contract/src/base/errors.rs
+++ b/contract/contract/src/base/errors.rs
@@ -51,4 +51,5 @@ pub enum CrowdfundingError {
PoolAlreadyClosed = 45,
PoolNotDisbursedOrRefunded = 46,
InsufficientFees = 47,
+ FlashDonationDetected = 54,
}
diff --git a/contract/contract/src/base/types.rs b/contract/contract/src/base/types.rs
index 44140c9..35b4142 100644
--- a/contract/contract/src/base/types.rs
+++ b/contract/contract/src/base/types.rs
@@ -18,6 +18,7 @@ pub struct Contribution {
pub campaign_id: BytesN<32>,
pub contributor: Address,
pub amount: i128,
+ pub last_donation_ledger: u32,
}
#[contracttype]
@@ -164,6 +165,7 @@ pub struct PoolContribution {
pub contributor: Address,
pub amount: i128,
pub asset: Address,
+ pub last_donation_ledger: u32,
}
#[contracttype]
diff --git a/contract/contract/src/crowdfunding.rs b/contract/contract/src/crowdfunding.rs
index 78120df..ad11651 100644
--- a/contract/contract/src/crowdfunding.rs
+++ b/contract/contract/src/crowdfunding.rs
@@ -327,12 +327,14 @@ impl CrowdfundingTrait for CrowdfundingContract {
campaign_id: campaign_id.clone(),
contributor: donor.clone(),
amount: 0,
+ last_donation_ledger: 0,
});
let updated_contribution = Contribution {
campaign_id: campaign_id.clone(),
contributor: donor.clone(),
amount: existing_contribution.amount + amount,
+ last_donation_ledger: env.ledger().sequence(),
};
env.storage()
.instance()
@@ -720,6 +722,7 @@ impl CrowdfundingTrait for CrowdfundingContract {
contributor: contributor.clone(),
amount: 0,
asset: asset.clone(),
+ last_donation_ledger: 0,
});
// Only increment contributor_count if this is a new contributor
@@ -738,6 +741,7 @@ impl CrowdfundingTrait for CrowdfundingContract {
contributor: contributor.clone(),
amount: existing_contribution.amount + amount,
asset: asset.clone(),
+ last_donation_ledger: env.ledger().sequence(),
};
env.storage()
.instance()
@@ -818,6 +822,9 @@ impl CrowdfundingTrait for CrowdfundingContract {
return Err(CrowdfundingError::NoContributionToRefund);
}
+ // Flash donation protection
+ verify_ledger_age(&env, contribution.last_donation_ledger)?;
+
// Transfer tokens back to contributor
use soroban_sdk::token;
let token_client = token::Client::new(&env, &contribution.asset);
@@ -848,6 +855,7 @@ impl CrowdfundingTrait for CrowdfundingContract {
contributor: contributor.clone(),
amount: 0,
asset: contribution.asset.clone(),
+ last_donation_ledger: contribution.last_donation_ledger,
};
env.storage()
.instance()
@@ -1090,3 +1098,10 @@ impl CrowdfundingTrait for CrowdfundingContract {
Ok(())
}
}
+
+fn verify_ledger_age(env: &Env, last_ledger: u32) -> Result<(), CrowdfundingError> {
+ if last_ledger == env.ledger().sequence() {
+ return Err(CrowdfundingError::FlashDonationDetected);
+ }
+ Ok(())
+}
diff --git a/contract/contract/test/flash_donation_test.rs b/contract/contract/test/flash_donation_test.rs
new file mode 100644
index 0000000..987da2c
--- /dev/null
+++ b/contract/contract/test/flash_donation_test.rs
@@ -0,0 +1,80 @@
+#![cfg(test)]
+
+use soroban_sdk::{
+ testutils::{Address as _, Ledger},
+ Address, Env, String,
+};
+
+use crate::{
+ base::{
+ errors::CrowdfundingError,
+ types::{PoolConfig},
+ },
+ crowdfunding::{CrowdfundingContract, CrowdfundingContractClient},
+};
+
+fn setup_test(env: &Env) -> (CrowdfundingContractClient<'_>, Address, Address) {
+ env.mock_all_auths();
+ let contract_id = env.register(CrowdfundingContract, ());
+ let client = CrowdfundingContractClient::new(env, &contract_id);
+
+ let admin = Address::generate(env);
+ let token_admin = Address::generate(env);
+ let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone());
+ let token_address = token_contract.address();
+
+ client.initialize(&admin, &token_address, &0);
+
+ (client, admin, token_address)
+}
+
+#[test]
+fn test_flash_donation_protection() {
+ let env = Env::default();
+ let (client, _, token_address) = setup_test(&env);
+
+ let creator = Address::generate(&env);
+ let donor = Address::generate(&env);
+
+ // Set initial sequence
+ env.ledger().with_mut(|li| li.sequence = 100);
+
+ // 1. Create a pool
+ let name = String::from_str(&env, "Flash Test");
+ let target = 10_000i128;
+ let duration = 3600; // 1 hour
+ let pool_config = PoolConfig {
+ name,
+ description: String::from_str(&env, "Desc"),
+ target_amount: target,
+ is_private: false,
+ duration,
+ created_at: env.ledger().timestamp(),
+ };
+ let pool_id = client.create_pool(&creator, &pool_config);
+
+ // Give tokens to donor
+ let token_admin = Address::generate(&env); // Not used but we need some admin logic if mocked
+ let token = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);
+ token.mint(&donor, &5000);
+
+ // 2. Donate
+ client.contribute(&pool_id, &donor, &token_address, &1000, &false);
+
+ // 3. Attempt refund in the SAME ledger sequence
+ // First, expire the pool to allow refund
+ env.ledger().with_mut(|li| {
+ li.timestamp += duration + 700000; // Pass deadline + 7 days grace period
+ });
+
+ let result = client.try_refund(&pool_id, &donor);
+ assert_eq!(result, Err(Ok(CrowdfundingError::FlashDonationDetected)));
+
+ // 4. Advance ledger sequence and try again
+ env.ledger().with_mut(|li| {
+ li.sequence += 1;
+ });
+
+ let result_success = client.try_refund(&pool_id, &donor);
+ assert!(result_success.is_ok());
+}
diff --git a/contract/contract/test/mod.rs b/contract/contract/test/mod.rs
index 1eb7cf3..a1004c5 100644
--- a/contract/contract/test/mod.rs
+++ b/contract/contract/test/mod.rs
@@ -1,4 +1,5 @@
mod close_pool_test;
mod create_pool;
mod crowdfunding_test;
+mod flash_donation_test;
mod verify_cause;
diff --git a/frontend/src/app/contact-us/Page.tsx b/frontend/src/app/contact-us/Page.tsx
index 2630aab..6c5c75a 100644
--- a/frontend/src/app/contact-us/Page.tsx
+++ b/frontend/src/app/contact-us/Page.tsx
@@ -1,5 +1,5 @@
"use client";
-import { useState } from "react";
+import React, { useState } from "react";
import { ArrowUpRight, ChevronDown } from "lucide-react";
import Navigation from "../../components/Navigation";
import Footer from "../../components/Footer";
@@ -27,7 +27,7 @@ export default function ContactPage() {
setError("");
// Simulate successful submission
- console.log({ fullName, subject, message, email });
+ // console.log({ fullName, subject, message, email });
setSuccess(true);
@@ -146,11 +146,10 @@ export default function ContactPage() {
type="submit"
disabled={!isFormValid}
className={`flex items-center justify-center gap-2 px-8 py-3 rounded-t-lg rounded-b-[18px] font-semibold transition-all duration-300
- ${
- isFormValid
- ? "bg-[#50C878] text-[#0F172A] cursor-pointer"
- : "bg-[#50C878]/60 text-[#0F172A]/70 cursor-not-allowed"
- }`}
+ ${isFormValid
+ ? "bg-[#50C878] text-[#0F172A] cursor-pointer"
+ : "bg-[#50C878]/60 text-[#0F172A]/70 cursor-not-allowed"
+ }`}
>
SEND MESSAGE
diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx
new file mode 100644
index 0000000..3cd8f1b
--- /dev/null
+++ b/frontend/src/app/dashboard/page.tsx
@@ -0,0 +1,28 @@
+import DashboardHeader from "@/components/DashboardHeader";
+import { PoolGrid } from "@/components/PoolGrid";
+
+export default function DashboardPage() {
+ return (
+
+
+
+
+ {/* Page heading */}
+
+ Dashboard
+
+ Manage your donation pools and track contributions.
+
+
+
+ {/* Pools */}
+
+
+
+ );
+}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 774189a..bbe0dc8 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -1,3 +1,4 @@
+import React from "react";
import type { Metadata } from "next";
// import { DM_Sans, Geist, Geist_Mono } from "next/font/google";
// import "./globals.css";
diff --git a/frontend/src/app/stellar-wallets-kit.ts b/frontend/src/app/stellar-wallets-kit.ts
index 00907df..548d95e 100644
--- a/frontend/src/app/stellar-wallets-kit.ts
+++ b/frontend/src/app/stellar-wallets-kit.ts
@@ -51,7 +51,7 @@ export async function connect(callback?: () => Promise) {
await setWallet(option.id);
if (callback) await callback();
} catch (e) {
- console.error(e);
+ // console.error(e);
}
return option.id;
},
diff --git a/frontend/src/app/terms-&-conditions/Page.tsx b/frontend/src/app/terms-&-conditions/Page.tsx
index 30d247e..ac07b01 100644
--- a/frontend/src/app/terms-&-conditions/Page.tsx
+++ b/frontend/src/app/terms-&-conditions/Page.tsx
@@ -1,3 +1,5 @@
+import React from "react";
+
export default function TermsPage() {
return (
diff --git a/frontend/src/components/ConnectWallet.tsx b/frontend/src/components/ConnectWallet.tsx
index 1c0ef27..da1fa16 100644
--- a/frontend/src/components/ConnectWallet.tsx
+++ b/frontend/src/components/ConnectWallet.tsx
@@ -1,59 +1,114 @@
"use client";
-import { useEffect, useState } from "react";
-import { getPublicKey, connect, disconnect } from "../app/stellar-wallets-kit";
+import { Loader2, Wallet, ChevronDown } from "lucide-react";
+import { useWallet } from "./hooks/useWallet";
+import { useStellarBalances } from "./hooks/useStellarBalances";
+import { cn } from "@/lib/utils";
+import { useState, useRef, useEffect } from "react";
+import WalletDropdown from "./WalletDropdown";
+
+/** Shorten a Stellar public key to "GABCD…WXYZ" format. */
+function truncateKey(key: string): string {
+ if (key.length <= 12) return key;
+ return `${key.slice(0, 4)}…${key.slice(-4)}`;
+}
export default function ConnectWallet() {
- const [publicKey, setPublicKey] = useState
(null);
- const [loading, setLoading] = useState(true);
-
- async function showConnected() {
- const key = await getPublicKey();
- if (key) {
- setPublicKey(key);
- } else {
- setPublicKey(null);
- }
- setLoading(false);
- }
+ const { publicKey, isConnected, isLoading, connect, disconnect } =
+ useWallet();
+ const { balances, isLoading: balancesLoading } =
+ useStellarBalances(publicKey);
- async function showDisconnected() {
- setPublicKey(null);
- setLoading(false);
- }
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+ const dropdownRef = useRef(null);
+ // Close dropdown on outside click
useEffect(() => {
- (async () => {
- const key = await getPublicKey();
- if (key) {
- setPublicKey(key);
+ function handleClickOutside(e: MouseEvent) {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(e.target as Node)
+ ) {
+ setDropdownOpen(false);
}
- setLoading(false);
- })();
+ }
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
+ if (isLoading) {
+ return (
+
+
+ Connecting…
+
+ );
+ }
+
+ if (!isConnected || !publicKey) {
+ return (
+
+
+ Connect Wallet
+
+ );
+ }
+
+ const xlm = balances.find((b) => b.asset === "XLM");
+ const usdc = balances.find((b) => b.asset === "USDC");
+
return (
-
- {!loading && publicKey && (
- <>
-
- Signed in as {publicKey}
-
-
disconnect(showDisconnected)}>
- Disconnect
-
- >
- )}
+
+ {/* Trigger button */}
+ setDropdownOpen((prev: boolean) => !prev)}
+ aria-haspopup="true"
+ aria-expanded={dropdownOpen}
+ className={cn(
+ "flex items-center gap-2 rounded-lg border border-[#50C878]/50 bg-[#1E293B] px-4 py-2 text-sm transition-colors hover:border-[#50C878]",
+ dropdownOpen && "border-[#50C878]"
+ )}
+ >
+ {/* Wallet icon */}
+
+
+
+
+ {/* Truncated key */}
+
+ {truncateKey(publicKey)}
+
+
+ {/* Balance pill */}
+ {balancesLoading ? (
+
+ ) : xlm ? (
+
+ {xlm.balance} XLM
+
+ ) : null}
+
+
+
- {!loading && !publicKey && (
- <>
- connect(showConnected)}
- className="bg-transparent text-[#50C878] hover:bg-blue-700 border border-[#50C878] px-6 py-2 rounded-lg transition font-medium"
- >
- Connect
-
- >
+ {/* Dropdown */}
+ {dropdownOpen && (
+ setDropdownOpen(false)}
+ />
)}
);
diff --git a/frontend/src/components/DashboardHeader.tsx b/frontend/src/components/DashboardHeader.tsx
new file mode 100644
index 0000000..e1f4f52
--- /dev/null
+++ b/frontend/src/components/DashboardHeader.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import Link from "next/link";
+import { LayoutDashboard } from "lucide-react";
+import ConnectWallet from "./ConnectWallet";
+
+/**
+ * Top bar for the dashboard page.
+ *
+ * Contains the Nevo brand mark on the left and the wallet status widget
+ * (ConnectWallet) on the right. ConnectWallet handles all connect /
+ * disconnect / balance display logic internally.
+ */
+export default function DashboardHeader() {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/DonationModal.tsx b/frontend/src/components/DonationModal.tsx
index fae09df..ead6b92 100644
--- a/frontend/src/components/DonationModal.tsx
+++ b/frontend/src/components/DonationModal.tsx
@@ -37,7 +37,7 @@ export const DonationModal: React.FC
= ({
const handleDonate = () => {
// Implement actual donation logic here later
- console.log(`Donating ${amount} ${asset} to ${poolTitle}`);
+ // console.log(`Donating ${amount} ${asset} to ${poolTitle}`);
onClose();
};
@@ -81,11 +81,10 @@ export const DonationModal: React.FC = ({
setAsset(a)}
- className={`flex items-center justify-center space-x-2 py-3 px-4 rounded-xl border transition-all ${
- asset === a
+ className={`flex items-center justify-center space-x-2 py-3 px-4 rounded-xl border transition-all ${asset === a
? "border-blue-500 bg-blue-50/50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400"
: "border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 text-slate-600 dark:text-slate-400"
- }`}
+ }`}
>
{a}
diff --git a/frontend/src/components/FeaturesSection.tsx b/frontend/src/components/FeaturesSection.tsx
index 9f350f9..497ee9c 100644
--- a/frontend/src/components/FeaturesSection.tsx
+++ b/frontend/src/components/FeaturesSection.tsx
@@ -1,3 +1,4 @@
+import React from "react";
import { Lock, TrendingUp, Zap, Eye, CheckCircle, Shield } from "lucide-react";
interface FeatureCardProps {
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx
index 48266b8..0fb4e82 100644
--- a/frontend/src/components/Footer.tsx
+++ b/frontend/src/components/Footer.tsx
@@ -1,4 +1,4 @@
-export default function Footer () {
+export default function Footer() {
return (
@@ -100,9 +103,12 @@ export default function Navigation() {
)
)}
-
+
Launch App
-
+
)}
diff --git a/frontend/src/components/SecuritySection.tsx b/frontend/src/components/SecuritySection.tsx
index 958354b..3c6e944 100644
--- a/frontend/src/components/SecuritySection.tsx
+++ b/frontend/src/components/SecuritySection.tsx
@@ -1,3 +1,4 @@
+import React from "react";
import { CheckCircle, Lock, Shield, Eye, Zap } from "lucide-react";
interface TrustIndicatorProps {
diff --git a/frontend/src/components/WalletDropdown.tsx b/frontend/src/components/WalletDropdown.tsx
new file mode 100644
index 0000000..fe48b2a
--- /dev/null
+++ b/frontend/src/components/WalletDropdown.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { LogOut, Loader2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface StellarBalance {
+ asset: string;
+ balance: string;
+}
+
+interface WalletDropdownProps {
+ publicKey: string;
+ balances: StellarBalance[];
+ balancesLoading: boolean;
+ onDisconnect: () => Promise;
+ onClose: () => void;
+}
+
+/**
+ * Dropdown panel showing detailed wallet information and actions.
+ * Extracted from ConnectWallet to keep component sizes manageable.
+ */
+export default function WalletDropdown({
+ publicKey,
+ balances,
+ balancesLoading,
+ onDisconnect,
+ onClose,
+}: WalletDropdownProps) {
+ const xlm = balances.find((b) => b.asset === "XLM");
+ const usdc = balances.find((b) => b.asset === "USDC");
+
+ return (
+
+ {/* Address section */}
+
+
+ Wallet Address
+
+
+ {publicKey}
+
+
+
+ {/* Balances section */}
+
+
+ Asset Balances
+
+ {balancesLoading ? (
+
+
+ Updating balances…
+
+ ) : (
+
+ {xlm && (
+
+ )}
+ {usdc ? (
+
+ ) : (
+
+ No USDC trustline found
+
+ )}
+
+ )}
+
+
+ {/* Action section */}
+
{
+ onClose();
+ await onDisconnect();
+ }}
+ className="flex w-full items-center justify-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-400 transition-all hover:bg-red-500/20 hover:border-red-500/50"
+ >
+
+ Disconnect Wallet
+
+
+ );
+}
+
+function BalanceRow({
+ label,
+ value,
+ color,
+}: {
+ label: string;
+ value: string;
+ color: string;
+}) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/frontend/src/components/hooks/Page.tsx b/frontend/src/components/hooks/Page.tsx
index 630bc12..761536b 100644
--- a/frontend/src/components/hooks/Page.tsx
+++ b/frontend/src/components/hooks/Page.tsx
@@ -1,5 +1,7 @@
-export default function hooks(){
- return(
+import React from "react";
+
+export default function hooks() {
+ return (
hooks
)
};
\ No newline at end of file
diff --git a/frontend/src/components/hooks/useStellarBalances.ts b/frontend/src/components/hooks/useStellarBalances.ts
new file mode 100644
index 0000000..32f0f73
--- /dev/null
+++ b/frontend/src/components/hooks/useStellarBalances.ts
@@ -0,0 +1,113 @@
+"use client";
+
+import { useState, useEffect } from "react";
+
+export interface StellarBalance {
+ asset: "XLM" | "USDC" | string;
+ balance: string;
+}
+
+export interface StellarBalancesState {
+ balances: StellarBalance[];
+ isLoading: boolean;
+ error: string | null;
+}
+
+const HORIZON_URL = "https://horizon.stellar.org";
+
+// USDC issuer on Stellar mainnet (Centre / Circle)
+const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
+
+/**
+ * Fetch XLM and USDC balances for a given Stellar public key from the
+ * Horizon REST API. No extra SDK is required – a plain `fetch` call is
+ * sufficient and keeps the bundle lean.
+ *
+ * Returns `isLoading: true` while the request is in-flight and re-fetches
+ * automatically whenever `publicKey` changes.
+ */
+export function useStellarBalances(
+ publicKey: string | null
+): StellarBalancesState {
+ const [balances, setBalances] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!publicKey) {
+ setBalances([]);
+ setError(null);
+ setIsLoading(false);
+ return;
+ }
+
+ let cancelled = false;
+ setIsLoading(true);
+ setError(null);
+
+ (async () => {
+ try {
+ const res = await fetch(
+ `${HORIZON_URL}/accounts/${publicKey}`
+ );
+
+ if (!res.ok) {
+ if (res.status === 404) {
+ // Account not yet funded on the network
+ if (!cancelled) {
+ setBalances([{ asset: "XLM", balance: "0" }]);
+ setError(null);
+ }
+ return;
+ }
+ throw new Error(`Horizon error ${res.status}`);
+ }
+
+ const data = await res.json();
+
+ const parsed: StellarBalance[] = (
+ data.balances as Array<{
+ asset_type: string;
+ asset_code?: string;
+ asset_issuer?: string;
+ balance: string;
+ }>
+ )
+ .filter((b) => {
+ // Include native XLM always
+ if (b.asset_type === "native") return true;
+ // Include USDC (credit_alphanum4 / alphanum12) from the canonical issuer
+ if (
+ b.asset_code === "USDC" &&
+ b.asset_issuer === USDC_ISSUER
+ )
+ return true;
+ return false;
+ })
+ .map((b) => ({
+ asset: b.asset_type === "native" ? "XLM" : (b.asset_code as string),
+ // Round to 4 decimal places for display
+ balance: parseFloat(b.balance).toFixed(4),
+ }));
+
+ if (!cancelled) {
+ setBalances(parsed);
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setError(
+ err instanceof Error ? err.message : "Failed to fetch balances"
+ );
+ }
+ } finally {
+ if (!cancelled) setIsLoading(false);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [publicKey]);
+
+ return { balances, isLoading, error };
+}
diff --git a/frontend/src/components/hooks/useWallet.ts b/frontend/src/components/hooks/useWallet.ts
new file mode 100644
index 0000000..e76561b
--- /dev/null
+++ b/frontend/src/components/hooks/useWallet.ts
@@ -0,0 +1,68 @@
+"use client";
+
+import { useState, useCallback, useEffect } from "react";
+import {
+ getPublicKey,
+ connect,
+ disconnect,
+} from "@/app/stellar-wallets-kit";
+
+export interface WalletState {
+ publicKey: string | null;
+ isConnected: boolean;
+ isLoading: boolean;
+ connect: () => Promise;
+ disconnect: () => Promise;
+}
+
+/**
+ * Centralised wallet state hook.
+ *
+ * - Reads the persisted wallet selection from localStorage on mount.
+ * - Exposes `connect` / `disconnect` helpers that keep React state in sync.
+ * - All other components should consume this hook instead of calling the
+ * stellar-wallets-kit helpers directly.
+ */
+export function useWallet(): WalletState {
+ const [publicKey, setPublicKey] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ // Restore session on mount
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const key = await getPublicKey();
+ if (!cancelled) setPublicKey(key);
+ } catch {
+ // Wallet not available or not connected – that's fine
+ } finally {
+ if (!cancelled) setIsLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const handleConnect = useCallback(async () => {
+ await connect(async () => {
+ const key = await getPublicKey();
+ setPublicKey(key);
+ });
+ }, []);
+
+ const handleDisconnect = useCallback(async () => {
+ await disconnect(async () => {
+ setPublicKey(null);
+ });
+ }, []);
+
+ return {
+ publicKey,
+ isConnected: publicKey !== null,
+ isLoading,
+ connect: handleConnect,
+ disconnect: handleDisconnect,
+ };
+}