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 */} +
+

+ Active 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 ( + + ); + } + + const xlm = balances.find((b) => b.asset === "XLM"); + const usdc = balances.find((b) => b.asset === "USDC"); + return ( -
- {!loading && publicKey && ( - <> -
- Signed in as {publicKey} -
- - - )} +
+ {/* Trigger button */} + - {!loading && !publicKey && ( - <> - - + {/* 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 ( +
+
+ {/* Brand + page context */} +
+ +
+ N +
+ Nevo + + + {/* Breadcrumb separator */} + / + +
+ + Dashboard +
+
+ + {/* Wallet widget */} + +
+
+ ); +} 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 = ({ +
@@ -100,9 +103,12 @@ export default function Navigation() { ) )} - +
)} 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 */} + +
+ ); +} + +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, + }; +}