From 89afd1c52ccf90c59a4d5f9c93d31c00d743ff73 Mon Sep 17 00:00:00 2001 From: Daniel Konstantinov <95046104+danielbg14@users.noreply.github.com> Date: Sun, 22 Mar 2026 23:30:06 +0200 Subject: [PATCH 01/81] Delete README copy.md --- README copy.md | 348 ------------------------------------------------- 1 file changed, 348 deletions(-) delete mode 100644 README copy.md diff --git a/README copy.md b/README copy.md deleted file mode 100644 index a2f4fee9..00000000 --- a/README copy.md +++ /dev/null @@ -1,348 +0,0 @@ -# LearnVault — Official Documentation - -> **Learning is the proof of work. The community is the bank.** - ---- - -## Table of Contents - -1. [Introduction](#introduction) -2. [Problem Statement](#problem-statement) -3. [Solution](#solution) -4. [Platform Architecture](#platform-architecture) -5. [Smart Contract System](#smart-contract-system) -6. [User Roles](#user-roles) -7. [The Earn Loop](#the-earn-loop) -8. [Scholarship DAO](#scholarship-dao) -9. [Governance](#governance) -10. [Tech Stack](#tech-stack) -11. [Roadmap](#roadmap) -12. [Contributing](#contributing) -13. [Contact](#contact) - ---- - -## Introduction - -**LearnVault** is a decentralized learn-and-earn platform where education meets -opportunity. Learners earn reputation tokens by completing skill tracks, while -donors and sponsors fund a community treasury governed by DAO voting. The best -learners get funded to go further — no gatekeepers, no bureaucracy, just proof -of effort and community belief. - -LearnVault is designed specifically with African learners in mind — a generation -of ambitious builders who have the talent and drive but lack access to the -financial resources that would take their skills to the next level. By combining -blockchain-powered credentials, on-chain reputation, and decentralized -scholarship funding, LearnVault creates a self-sustaining education ecosystem -that rewards effort and amplifies potential. - ---- - -## Problem Statement - -Access to quality tech education across Africa is not limited by lack of -ambition — it is limited by lack of opportunity. Three core problems define the -gap: - -**Funding Barriers** — Bootcamps, courses, and developer tools cost money that -most learners in emerging markets simply do not have. Scholarship systems that -exist are slow, opaque, and often inaccessible. - -**Credential Trust Gap** — Traditional certificates are easy to fake and hard to -verify. Employers and DAOs have no reliable way to assess a candidate's real -on-chain track record. - -**Broken Incentive Systems** — Existing learn-to-earn platforms flood learners -with worthless tokens for clicking through slides. There is no real connection -between learning effort and financial reward. - -LearnVault addresses all three problems in a single, cohesive platform. - ---- - -## Solution - -LearnVault creates a three-pillar ecosystem: - -**1. Learn** — Learners enroll in skill tracks covering Web3 development, smart -contract engineering, DeFi, frontend development, and more. Every verified -milestone they complete earns them LearnTokens — soulbound, non-transferable -reputation tokens that live on-chain. - -**2. Earn** — LearnTokens are proof of real effort. They unlock governance -rights, scholarship eligibility, and platform reputation. High-achieving -learners convert a portion of their LearnTokens into Governance Tokens, giving -them a voice in the DAO. - -**3. Get Funded** — Learners with sufficient on-chain reputation can submit -scholarship proposals to the community treasury. Governance token holders vote -on proposals. Approved scholars receive milestone-based disbursements in -stablecoins — real, stable value delivered directly to their wallets. - ---- - -## Platform Architecture - -``` -┌─────────────────────────────────────────────────────┐ -│ LEARNVAULT │ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ LEARN │───▶│ EARN │───▶│ GET FUNDED│ │ -│ │ │ │ │ │ │ │ -│ │ Courses │ │ LRN │ │ Scholarship│ │ -│ │ Quizzes │ │ Tokens │ │ Proposals │ │ -│ │ Projects │ │ (Soulbound│ │ DAO Vote │ │ -│ │ │ │ ERC20) │ │ Escrow │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -│ │ -│ ┌─────────────────────────────────────────┐ │ -│ │ COMMUNITY TREASURY │ │ -│ │ Funded by Donors · Governed by DAO │ │ -│ └─────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────┘ -``` - ---- - -## Smart Contract System - -LearnVault is powered by six core smart contracts: - -### `LearnToken.sol` - -A **soulbound ERC20** token that is minted to learners upon verified milestone -completion. Non-transferable by design — it represents real effort, not -speculation. Your LearnToken balance is your on-chain academic reputation score. - -### `GovernanceToken.sol` - -A **transferable ERC20** distributed to donors upon treasury contribution and -earned by top learners at milestone thresholds. Used exclusively for DAO voting -on scholarship proposals. - -### `CourseMilestone.sol` - -Tracks learner progress per course. Each course has defined checkpoints verified -by a trusted multi-sig validator (transitioning to oracle-based verification in -V2). On successful verification, this contract triggers LearnToken minting. - -### `ScholarshipTreasury.sol` - -Holds all donor funds in stablecoins (USDC). Funds can only be released upon -successful proposal execution through the governance system. Tracks total -contributions per donor. Transparent and auditable by anyone. - -### `MilestoneEscrow.sol` - -Manages approved scholarship disbursements in tranches. Funds are released as -scholars hit agreed milestones. If a scholar is inactive for 30 days, unspent -funds automatically return to the treasury. - -### `ScholarNFT.sol` - -Mints a **soulbound ERC721 credential** to scholars who complete their funded -programs. Non-transferable, tamper-proof, and permanently verifiable on-chain. -Shareable with employers, DAOs, and the broader ecosystem. - ---- - -## User Roles - -### Learner - -- Connects wallet and enrolls in skill tracks -- Completes lessons, quizzes, and projects -- Earns soulbound LearnTokens per milestone -- Builds on-chain reputation score -- Submits scholarship proposals when eligible -- Receives milestone-based funding upon approval -- Earns ScholarNFT credential upon program completion - -### Donor / Sponsor - -- Deposits stablecoins (USDC) into the community treasury -- Receives Governance Tokens proportional to contribution -- Votes on scholarship proposals -- Tracks the impact of their contributions transparently on-chain -- Can set optional focus areas (e.g., only fund Web3 developers) - -### DAO Voter / Community Member - -- Any Governance Token holder can vote on proposals -- Votes are weighted by token balance -- Voting window: 7 days per proposal -- Quorum and threshold parameters set by DAO governance - ---- - -## The Earn Loop - -LearnVault's flywheel is designed so that effort compounds over time: - -``` -Complete Lesson - │ - ▼ -Earn LearnTokens (LRN) - │ - ▼ -Complete Full Track ──▶ Convert LRN to Governance Tokens - │ - ▼ -Submit Scholarship Proposal - │ - ▼ -Community Votes YES - │ - ▼ -Milestone-Based Funding Released - │ - ▼ -Complete Funded Program - │ - ▼ -Mint ScholarNFT Credential - │ - ▼ -Higher Reputation ──▶ Larger Future Proposals ──▶ Loop Continues -``` - -The more you learn, the more power and opportunity you unlock. Wealth is not the -barrier — effort is the currency. - ---- - -## Scholarship DAO - -The Scholarship DAO is the heart of LearnVault's funding mechanism. - -### Eligibility to Apply - -A learner must hold a minimum LearnToken balance (set by governance) before -submitting a proposal. This ensures only learners with a verified track record -can access treasury funds. - -### Proposal Contents - -Each scholarship proposal includes: - -- Learning goal and intended program or bootcamp -- Amount requested in USDC -- Timeline and milestone plan -- On-chain reputation score (LRN balance) -- Wallet address for disbursement - -### Voting - -Governance token holders vote YES or NO within a 7-day window. A proposal passes -if it meets the required quorum and approval threshold. Failed proposals can be -resubmitted after 30 days. - -### Disbursement - -Approved funds are locked in `MilestoneEscrow.sol` and released in tranches as -the scholar completes agreed milestones. Progress is reported by the scholar and -confirmed by a community-elected validator committee (transitioning to oracle -verification in V2). - -### Accountability - -Scholars who abandon funded programs without communication are flagged on-chain. -Repeated abandonment affects future proposal eligibility. Unspent funds always -return to the treasury. - ---- - -## Governance - -LearnVault's DAO governance covers: - -- Scholarship eligibility thresholds (minimum LRN to apply) -- Voting quorum and approval thresholds -- Treasury allocation limits per proposal -- Adding new course tracks to the platform -- Protocol upgrades and parameter changes - -Governance evolves over time. In V1, the founding team holds a multi-sig with -community governance as an advisory layer. In V2, full on-chain governance -transfers to token holders. - ---- - -## Tech Stack - -| Layer | Technology | -| ------------------ | ---------------------------------------------- | -| Blockchain | Stellar (primary), EVM-compatible L2 (planned) | -| Smart Contracts | Solidity / Stellar Soroban | -| Frontend | Next.js, TypeScript, TailwindCSS | -| Wallet Integration | Freighter (Stellar), MetaMask | -| Storage | IPFS (course content + proposal docs) | -| Stablecoin | USDC | -| Backend | Node.js, PostgreSQL | -| Deployment | Docker | - ---- - -## Roadmap - -### V1 — MVP (Current Phase) - -- Core smart contracts (LearnToken, GovernanceToken, Treasury, ProposalManager) -- Basic course completion tracker -- Scholarship proposal submission and voting -- Learner and donor dashboards - -### V2 — Growth - -- MilestoneEscrow and automated tranche disbursements -- ScholarNFT credential system -- Oracle-based milestone verification -- Expanded course catalog (Web3, DeFi, Smart Contracts, ZK basics) -- Mobile-responsive frontend -- Community leaderboard - -### V3 — Scale - -- Full on-chain governance transition -- Cross-chain support (Arbitrum, Base) -- Corporate sponsor portal with targeted funding -- ZK-powered credential proofs (prove achievement without revealing identity) -- API for third-party integrations - ---- - -## Contributing - -LearnVault is an open-source project and welcomes contributions from developers, -educators, designers, and community builders. If you believe in decentralized -education and want to help build the infrastructure for the next generation of -African builders, we would love to have you. - -To contribute: - -1. Fork the repository -2. Create a feature branch -3. Submit a pull request with a clear description of your changes -4. Join the community discussion in our Discord - -All contributors are recognized on-chain and in our official documentation. - ---- - -## Contact - -For partnerships, sponsorships, grant inquiries, or general questions about -LearnVault, please reach out through our official channels. - -- **GitHub**: github.com/learnvault -- **Twitter/X**: @LearnVaultDAO -- **Discord**: discord.gg/learnvault -- **Email**: hello@learnvault.xyz - ---- - -_LearnVault — Built for African learners. Powered by community. Governed by -effort._ From 714208bc1268dda4986fc4239766042447684f4d Mon Sep 17 00:00:00 2001 From: Rayan Date: Mon, 23 Mar 2026 09:41:31 +0000 Subject: [PATCH 02/81] feat: add treasury dashboard with summary stats and donation feed --- src/App.tsx | 28 +++++++++++----- src/pages/Treasury.tsx | 73 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 src/pages/Treasury.tsx diff --git a/src/App.tsx b/src/App.tsx index 7fcb2be0..a890d8ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,12 +5,14 @@ import ConnectAccount from "./components/ConnectAccount" import { labPrefix } from "./contracts/util" import Debug from "./pages/Debug" import Home from "./pages/Home" +import Treasury from "./pages/Treasury" function App() { return ( }> } /> + } /> } /> } /> @@ -21,11 +23,27 @@ function App() { const AppLayout: React.FC = () => (
+ + {({ isActive }) => ( + + )} + + + {({ isActive }) => ( + + )} + {({ isActive }) => ( )} - - - } contentRight={} diff --git a/src/pages/Treasury.tsx b/src/pages/Treasury.tsx new file mode 100644 index 00000000..04bd7e8b --- /dev/null +++ b/src/pages/Treasury.tsx @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from "react"; +import { Layout, Button, Icon } from "@stellar/design-system"; + +export default function Treasury() { + const [stats, setStats] = useState({ + totalTreasury: "125,000", + totalDisbursed: "45,000", + scholarsFunded: 120, + donorsCount: 85, + }); + + const [donations, setDonations] = useState([ + { id: 1, donor: "0xABC...123", amount: "500 USDC", time: "2 mins ago" }, + { id: 2, donor: "0xDEF...456", amount: "1,200 USDC", time: "15 mins ago" }, + { id: 3, donor: "0xGHI...789", amount: "250 USDC", time: "1 hour ago" }, + ]); + + return ( +
+

Community Treasury Dashboard

+ + {/* Summary Stats */} +
+ } /> + } /> + } /> + } /> +
+ +
+ {/* Recent Donations */} +
+

Recent Donations

+
+ {donations.map((d) => ( +
+ {d.donor} + {d.amount} + {d.time} +
+ ))} +
+
+ + {/* Treasury Health Chart Placeholder */} +
+

Treasury Health

+
+ [ Treasury Health Chart (Inflows vs Outflows) ] +
+
+
+ +
+ +
+
+ ); +} + +function StatCard({ title, value, icon }: { title: string; value: string | number; icon: React.ReactNode }) { + return ( +
+
+ {icon} + {title} +
+
{value}
+
+ ); +} From d3fbb16adcbb03e987098838eb95d568fceda995 Mon Sep 17 00:00:00 2001 From: Rayan Date: Mon, 23 Mar 2026 09:42:39 +0000 Subject: [PATCH 03/81] feat: add milestone progress tracker with on-chain status --- src/components/MilestoneTracker.tsx | 97 ++++++++++++++++++++++ src/pages/Home.tsx | 120 ++++++++++++++++------------ 2 files changed, 167 insertions(+), 50 deletions(-) create mode 100644 src/components/MilestoneTracker.tsx diff --git a/src/components/MilestoneTracker.tsx b/src/components/MilestoneTracker.tsx new file mode 100644 index 00000000..d636f561 --- /dev/null +++ b/src/components/MilestoneTracker.tsx @@ -0,0 +1,97 @@ +import React, { useState, useEffect } from "react"; +import { Icon, Button, Card, Badge } from "@stellar/design-system"; + +interface Milestone { + id: number; + label: string; + lrnReward: number; + status: "completed" | "in-progress" | "locked"; + txHash?: string; +} + +interface MilestoneTrackerProps { + courseId: string; + milestones: Milestone[]; +} + +export const MilestoneTracker: React.FC = ({ milestones }) => { + return ( +
+ {milestones.map((milestone, index) => ( +
+ {/* Progress Line */} + {index !== milestones.length - 1 && ( +
+ )} + + {/* Status Icon */} +
+
+ {milestone.status === "completed" ? ( + + ) : milestone.status === "in-progress" ? ( +
+ ) : ( + + )} +
+
+ + {/* Content Card */} + +
+

+ {milestone.label} +

+ + +{milestone.lrnReward} LRN + +
+ + {milestone.status === "completed" && milestone.txHash && ( + + )} + + {milestone.status === "in-progress" && ( +
+ +
+ )} +
+
+ ))} +
+ ); +}; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 11dd8982..86bd28ff 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -2,67 +2,87 @@ import { Button, Card, Icon } from "@stellar/design-system" import React from "react" import { Link } from "react-router-dom" import { GuessTheNumber } from "../components/GuessTheNumber" +import { MilestoneTracker } from "../components/MilestoneTracker" import { labPrefix } from "../contracts/util" import styles from "./Home.module.css" const Home: React.FC = () => (
-
-

Yay! You're on Stellar!

+
+
+

Yay! You're on Stellar!

-

- A local development template designed to help you build dApps on the - Stellar network. This environment lets you easily test wallet - connections, smart contract interactions, transaction verifications, - etc.{" "} - - View docs - -

-
+

+ A local development template designed to help you build dApps on the + Stellar network. This environment lets you easily test wallet + connections, smart contract interactions, transaction verifications, + etc.{" "} + + View docs + +

- -

- - Sample Contracts -

+
+

+ + Course Progress: Stellar Basics +

+ +
+
-

- Guess The Number: Interact with the sample contract - from the{" "} - - Scaffold Tutorial - {" "} - using an automatically generated contract client. -

+
+ +

+ + Sample Contracts +

- +

+ Guess The Number: Interact with the sample contract + from the{" "} + + Scaffold Tutorial + {" "} + using an automatically generated contract client. +

-

Or take a look at other sample contracts to get you started:

+ - -
+

Or take a look at other sample contracts to get you started:

+ + + +
+

From 02d267609960ab8868a4013893a81c3975623850 Mon Sep 17 00:00:00 2001 From: Rayan Date: Mon, 23 Mar 2026 09:47:04 +0000 Subject: [PATCH 04/81] chore: revert projectID to Scaffold to match core repo --- src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index a890d8ef..b90f23d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,8 +23,8 @@ function App() { const AppLayout: React.FC = () => (
From 5c6342cce78850fc33d020c1162784b082efdcb5 Mon Sep 17 00:00:00 2001 From: JOASH Date: Mon, 23 Mar 2026 15:52:35 +0100 Subject: [PATCH 05/81] docs: add project documentation --- docs/contracts.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/contracts.md diff --git a/docs/contracts.md b/docs/contracts.md new file mode 100644 index 00000000..ae8029bb --- /dev/null +++ b/docs/contracts.md @@ -0,0 +1,59 @@ +# LearnVault Smart Contract Reference + +## Contract Overview + +| Contract | Language | Purpose | +|---|---|---| +| `LearnToken` | Soroban/Rust | Soulbound reputation token (LRN) — minted on milestone completion, non-transferable | +| `GovernanceToken` | Soroban/Rust | Transferable DAO voting token (GOV) — minted to donors on deposit, earned by top learners | +| `CourseMilestone` | Soroban/Rust | Tracks learner progress per course, triggers LRN minting on verified checkpoint completion | +| `ScholarshipTreasury` | Soroban/Rust | Holds donor USDC funds, mints GOV to donors, creates escrows for approved proposals | +| `MilestoneEscrow` | Soroban/Rust | Manages tranche disbursements to scholars, returns unspent funds after 30-day inactivity | +| `ScholarNFT` | Soroban/Rust | Soulbound credential NFT minted to scholars who complete their funded program | + +--- + +## Contract Interaction Diagram + +```mermaid +graph TD + CM[CourseMilestone] -->|mint LRN on verified milestone| LT[LearnToken] + ST[ScholarshipTreasury] -->|mint GOV on deposit| GT[GovernanceToken] + ST -->|create_escrow on approved proposal| ME[MilestoneEscrow] + ME -->|mint credential on program completion| SN[ScholarNFT] +``` + +**Interaction summary:** + +- `CourseMilestone` → `LearnToken`: calls `mint` when a learner's checkpoint is verified +- `ScholarshipTreasury` → `GovernanceToken`: calls `mint` proportional to a donor's USDC deposit +- `ScholarshipTreasury` → `MilestoneEscrow`: calls `create_escrow` when a scholarship proposal passes DAO vote +- `MilestoneEscrow` → `ScholarNFT`: calls `mint` when a scholar completes all funded milestones + +--- + +## Deployment Order + +Contracts must be deployed in this order due to cross-contract dependencies: + +1. **`LearnToken`** — no dependencies +2. **`GovernanceToken`** — no dependencies +3. **`ScholarNFT`** — no dependencies +4. **`CourseMilestone`** — requires `LearnToken` address +5. **`ScholarshipTreasury`** — requires `GovernanceToken` address +6. **`MilestoneEscrow`** — requires `ScholarshipTreasury` and `ScholarNFT` addresses + +--- + +## Testnet Addresses + +> Fill in after deployment to Stellar Testnet. + +| Contract | Testnet Address | +|---|---| +| `LearnToken` | — | +| `GovernanceToken` | — | +| `CourseMilestone` | — | +| `ScholarshipTreasury` | — | +| `MilestoneEscrow` | — | +| `ScholarNFT` | — | From 06cedfaeafb1fe8b42cc58a94454f58c0fd51625 Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Mon, 23 Mar 2026 16:40:00 +0100 Subject: [PATCH 06/81] feat: add milestone escrow contract with tranche release and inactivity reclaim --- contracts/milestone_escrow/Cargo.toml | 22 +++ contracts/milestone_escrow/src/lib.rs | 218 +++++++++++++++++++++++++ contracts/milestone_escrow/src/test.rs | 127 ++++++++++++++ 3 files changed, 367 insertions(+) create mode 100644 contracts/milestone_escrow/Cargo.toml create mode 100644 contracts/milestone_escrow/src/lib.rs create mode 100644 contracts/milestone_escrow/src/test.rs diff --git a/contracts/milestone_escrow/Cargo.toml b/contracts/milestone_escrow/Cargo.toml new file mode 100644 index 00000000..ac22bd77 --- /dev/null +++ b/contracts/milestone_escrow/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "milestone-escrow" +description = "Milestone-based escrow for scholarship disbursements" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-registry = "0.0.4" + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/milestone_escrow/src/lib.rs b/contracts/milestone_escrow/src/lib.rs new file mode 100644 index 00000000..44c4f816 --- /dev/null +++ b/contracts/milestone_escrow/src/lib.rs @@ -0,0 +1,218 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address, Env, + Symbol, +}; + +const INACTIVITY_WINDOW_SECONDS: u64 = 30 * 24 * 60 * 60; +const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); +const TREASURY_KEY: Symbol = symbol_short!("TREAS"); + +#[derive(Clone)] +#[contracttype] +pub struct EscrowRecord { + pub scholar: Address, + pub total_amount: i128, + pub released_amount: i128, + pub total_tranches: u32, + pub tranches_released: u32, + pub last_activity: u64, + pub treasury: Address, + pub admin: Address, +} + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + Escrow(u32), +} + +#[contracterror] +#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + EscrowExists = 3, + EscrowNotFound = 4, + InvalidAmount = 5, + InvalidTranches = 6, + AllTranchesReleased = 7, + Overpayment = 8, + InactivityNotReached = 9, + NothingToReclaim = 10, +} + +#[contract] +pub struct MilestoneEscrow; + +#[contractimpl] +impl MilestoneEscrow { + pub fn initialize(env: Env, admin: Address, treasury: Address) { + if env.storage().instance().has(&ADMIN_KEY) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + admin.require_auth(); + + env.storage().instance().set(&ADMIN_KEY, &admin); + env.storage().instance().set(&TREASURY_KEY, &treasury); + } + + pub fn create_escrow( + env: Env, + proposal_id: u32, + scholar: Address, + amount: i128, + tranches: u32, + ) { + let treasury = Self::treasury(&env); + treasury.require_auth(); + + if amount <= 0 { + panic_with_error!(&env, Error::InvalidAmount); + } + if tranches == 0 { + panic_with_error!(&env, Error::InvalidTranches); + } + + let key = DataKey::Escrow(proposal_id); + if env.storage().persistent().has(&key) { + panic_with_error!(&env, Error::EscrowExists); + } + + xlm::token_client(&env).transfer(&treasury, &env.current_contract_address(), &amount); + + let record = EscrowRecord { + scholar, + total_amount: amount, + released_amount: 0, + total_tranches: tranches, + tranches_released: 0, + last_activity: env.ledger().timestamp(), + treasury: treasury.clone(), + admin: Self::admin(&env), + }; + env.storage().persistent().set(&key, &record); + } + + pub fn release_tranche(env: Env, proposal_id: u32) { + let admin = Self::admin(&env); + admin.require_auth(); + + let key = DataKey::Escrow(proposal_id); + let mut record = Self::get_or_panic(&env, &key); + + if record.tranches_released >= record.total_tranches { + panic_with_error!(&env, Error::AllTranchesReleased); + } + + let amount = Self::next_tranche_amount(&env, &record); + xlm::token_client(&env).transfer(&env.current_contract_address(), &record.scholar, &amount); + + record.released_amount += amount; + record.tranches_released += 1; + record.last_activity = env.ledger().timestamp(); + env.storage().persistent().set(&key, &record); + } + + pub fn reclaim_inactive(env: Env, proposal_id: u32) { + let key = DataKey::Escrow(proposal_id); + let mut record = Self::get_or_panic(&env, &key); + + let now = env.ledger().timestamp(); + let inactive_for = now.saturating_sub(record.last_activity); + if inactive_for < INACTIVITY_WINDOW_SECONDS { + panic_with_error!(&env, Error::InactivityNotReached); + } + + let unspent = record.total_amount - record.released_amount; + if unspent <= 0 { + panic_with_error!(&env, Error::NothingToReclaim); + } + + xlm::token_client(&env).transfer(&env.current_contract_address(), &record.treasury, &unspent); + + record.released_amount = record.total_amount; + record.last_activity = now; + env.storage().persistent().set(&key, &record); + } + + pub fn get_escrow(env: Env, proposal_id: u32) -> Option { + let key = DataKey::Escrow(proposal_id); + env.storage().persistent().get(&key) + } + + fn get_or_panic(env: &Env, key: &DataKey) -> EscrowRecord { + if let Some(record) = env.storage().persistent().get::<_, EscrowRecord>(key) { + record + } else { + panic_with_error!(env, Error::EscrowNotFound); + } + } + + fn next_tranche_amount(env: &Env, record: &EscrowRecord) -> i128 { + let remaining = record.total_amount - record.released_amount; + let is_last = record.tranches_released + 1 == record.total_tranches; + let amount = if is_last { + remaining + } else { + record.total_amount / (record.total_tranches as i128) + }; + + if amount <= 0 || record.released_amount + amount > record.total_amount { + panic_with_error!(env, Error::Overpayment); + } + amount + } + + fn admin(env: &Env) -> Address { + if let Some(admin) = env.storage().instance().get::<_, Address>(&ADMIN_KEY) { + admin + } else { + panic_with_error!(env, Error::NotInitialized); + } + } + + fn treasury(env: &Env) -> Address { + if let Some(treasury) = env.storage().instance().get::<_, Address>(&TREASURY_KEY) { + treasury + } else { + panic_with_error!(env, Error::NotInitialized); + } + } +} + +mod xlm { + #[cfg(test)] + mod test_xlm { + use soroban_sdk::{symbol_short, Address, Env, Symbol}; + + const XLM_KEY: Symbol = symbol_short!("XLM"); + + pub fn contract_id(env: &Env) -> Address { + env.storage() + .instance() + .get::<_, Address>(&XLM_KEY) + .expect("XLM contract not initialized") + } + + pub fn register(env: &Env, admin: &Address) { + let sac = env.register_stellar_asset_contract_v2(admin.clone()); + env.storage().instance().set(&XLM_KEY, &sac.address()); + } + + pub fn token_client<'a>(env: &Env) -> soroban_sdk::token::TokenClient<'a> { + soroban_sdk::token::TokenClient::new(env, &contract_id(env)) + } + } + + #[cfg(not(test))] + stellar_registry::import_asset!("xlm"); + + #[cfg(test)] + pub use test_xlm::*; +} + +#[cfg(test)] +mod test; diff --git a/contracts/milestone_escrow/src/test.rs b/contracts/milestone_escrow/src/test.rs new file mode 100644 index 00000000..70a05302 --- /dev/null +++ b/contracts/milestone_escrow/src/test.rs @@ -0,0 +1,127 @@ +extern crate std; + +use soroban_sdk::{ + testutils::{Address as _, Ledger, LedgerInfo}, + token::{StellarAssetClient, TokenClient}, + Address, Env, +}; + +use crate::{xlm, Error, MilestoneEscrow, MilestoneEscrowClient}; + +const START_TS: u64 = 1_700_000_000; +const THIRTY_DAYS: u64 = 30 * 24 * 60 * 60; + +fn set_timestamp(env: &Env, timestamp: u64) { + env.ledger().set(LedgerInfo { + timestamp, + protocol_version: 23, + sequence_number: 1, + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 16, + min_persistent_entry_ttl: 16, + max_entry_ttl: 6312000, + }); +} + +fn token_address(env: &Env, contract_id: &Address) -> Address { + env.as_contract(contract_id, || xlm::contract_id(env)) +} + +fn token_client<'a>(env: &Env, token: &Address) -> TokenClient<'a> { + TokenClient::new(env, token) +} + +fn stellar_asset_client<'a>(env: &Env, token: &Address) -> StellarAssetClient<'a> { + StellarAssetClient::new(env, token) +} + +fn setup() -> (Env, Address, Address, Address, Address, Address) { + let env = Env::default(); + set_timestamp(&env, START_TS); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let scholar = Address::generate(&env); + + let contract_id = env.register(MilestoneEscrow, ()); + env.mock_all_auths(); + env.as_contract(&contract_id, || xlm::register(&env, &admin)); + let token = token_address(&env, &contract_id); + stellar_asset_client(&env, &token).mint(&treasury, &1_000); + let client = MilestoneEscrowClient::new(&env, &contract_id); + client.initialize(&admin, &treasury); + + (env, contract_id, token, admin, treasury, scholar) +} + +#[test] +fn releases_tranches_in_order() { + let (env, contract_id, token, _admin, treasury, scholar) = setup(); + let client = MilestoneEscrowClient::new(&env, &contract_id); + + client.create_escrow(&7, &scholar, &100, &3); + assert_eq!(token_client(&env, &token).balance(&treasury), 900); + assert_eq!(token_client(&env, &token).balance(&contract_id), 100); + + client.release_tranche(&7); + let e1 = client.get_escrow(&7).unwrap(); + assert_eq!(e1.released_amount, 33); + assert_eq!(e1.tranches_released, 1); + assert_eq!(token_client(&env, &token).balance(&scholar), 33); + + client.release_tranche(&7); + let e2 = client.get_escrow(&7).unwrap(); + assert_eq!(e2.released_amount, 66); + assert_eq!(e2.tranches_released, 2); + assert_eq!(token_client(&env, &token).balance(&scholar), 66); + + client.release_tranche(&7); + let e3 = client.get_escrow(&7).unwrap(); + assert_eq!(e3.released_amount, 100); + assert_eq!(e3.tranches_released, 3); + assert_eq!(token_client(&env, &token).balance(&scholar), 100); + assert_eq!(token_client(&env, &token).balance(&contract_id), 0); +} + +#[test] +fn reclaims_after_30_days_of_inactivity() { + let (env, contract_id, token, _admin, treasury, scholar) = setup(); + let client = MilestoneEscrowClient::new(&env, &contract_id); + + client.create_escrow(&8, &scholar, &120, &4); + client.release_tranche(&8); + assert_eq!(token_client(&env, &token).balance(&scholar), 30); + + set_timestamp(&env, START_TS + THIRTY_DAYS - 1); + let early_reclaim = client.try_reclaim_inactive(&8); + assert_eq!( + early_reclaim.err(), + Some(Ok(soroban_sdk::Error::from_contract_error( + Error::InactivityNotReached as u32 + ))) + ); + + set_timestamp(&env, START_TS + THIRTY_DAYS); + client.reclaim_inactive(&8); + + let escrow = client.get_escrow(&8).unwrap(); + assert_eq!(escrow.released_amount, 120); + assert_eq!(token_client(&env, &token).balance(&treasury), 970); + assert_eq!(token_client(&env, &token).balance(&contract_id), 0); +} + +#[test] +fn overpayment_is_rejected() { + let (env, contract_id, _token, _admin, _treasury, scholar) = setup(); + let client = MilestoneEscrowClient::new(&env, &contract_id); + + client.create_escrow(&9, &scholar, &2, &4); + let first_release = client.try_release_tranche(&9); + assert_eq!( + first_release.err(), + Some(Ok(soroban_sdk::Error::from_contract_error( + Error::Overpayment as u32 + ))) + ); +} From 15f83e3ac087f25b8e77f1a8adb532aaaace65bf Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Mon, 23 Mar 2026 16:47:58 +0100 Subject: [PATCH 07/81] feat: add scholarship treasury and milestone escrow contracts --- contracts/scholarship_treasury/Cargo.toml | 22 ++++ contracts/scholarship_treasury/src/lib.rs | 136 +++++++++++++++++++++ contracts/scholarship_treasury/src/test.rs | 104 ++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 contracts/scholarship_treasury/Cargo.toml create mode 100644 contracts/scholarship_treasury/src/lib.rs create mode 100644 contracts/scholarship_treasury/src/test.rs diff --git a/contracts/scholarship_treasury/Cargo.toml b/contracts/scholarship_treasury/Cargo.toml new file mode 100644 index 00000000..e6af8401 --- /dev/null +++ b/contracts/scholarship_treasury/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "scholarship-treasury" +description = "Treasury contract for scholarship funding in USDC" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-registry = "0.0.4" + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/scholarship_treasury/src/lib.rs b/contracts/scholarship_treasury/src/lib.rs new file mode 100644 index 00000000..9828a528 --- /dev/null +++ b/contracts/scholarship_treasury/src/lib.rs @@ -0,0 +1,136 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address, Env, + Symbol, +}; + +const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); +const GOV_KEY: Symbol = symbol_short!("GOV"); +const USDC_KEY: Symbol = symbol_short!("USDC"); +const TOTAL_KEY: Symbol = symbol_short!("TOTAL"); + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + Donor(Address), +} + +#[contracterror] +#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + InvalidAmount = 3, + InsufficientFunds = 4, +} + +#[contract] +pub struct ScholarshipTreasury; + +#[contractimpl] +impl ScholarshipTreasury { + pub fn initialize(env: Env, admin: Address, usdc_token: Address, governance_contract: Address) { + if env.storage().instance().has(&ADMIN_KEY) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + admin.require_auth(); + + env.storage().instance().set(&ADMIN_KEY, &admin); + env.storage().instance().set(&USDC_KEY, &usdc_token); + env.storage().instance().set(&GOV_KEY, &governance_contract); + env.storage().instance().set(&TOTAL_KEY, &0_i128); + } + + pub fn deposit(env: Env, donor: Address, amount: i128) { + if amount <= 0 { + panic_with_error!(&env, Error::InvalidAmount); + } + donor.require_auth(); + + let usdc = token::client(&env); + usdc.transfer(&donor, &env.current_contract_address(), &amount); + + let donor_key = DataKey::Donor(donor); + let current = env + .storage() + .persistent() + .get::<_, i128>(&donor_key) + .unwrap_or(0); + env.storage().persistent().set(&donor_key, &(current + amount)); + + let total = env.storage().instance().get::<_, i128>(&TOTAL_KEY).unwrap_or(0); + env.storage().instance().set(&TOTAL_KEY, &(total + amount)); + } + + pub fn disburse(env: Env, recipient: Address, amount: i128) { + if amount <= 0 { + panic_with_error!(&env, Error::InvalidAmount); + } + + let governance = Self::governance_contract(&env); + governance.require_auth(); + + let total = env.storage().instance().get::<_, i128>(&TOTAL_KEY).unwrap_or(0); + if amount > total { + panic_with_error!(&env, Error::InsufficientFunds); + } + + token::client(&env).transfer(&env.current_contract_address(), &recipient, &amount); + env.storage().instance().set(&TOTAL_KEY, &(total - amount)); + } + + pub fn get_balance(env: Env) -> i128 { + env.storage().instance().get::<_, i128>(&TOTAL_KEY).unwrap_or(0) + } + + pub fn get_donor_total(env: Env, donor: Address) -> i128 { + env.storage() + .persistent() + .get::<_, i128>(&DataKey::Donor(donor)) + .unwrap_or(0) + } + + fn governance_contract(env: &Env) -> Address { + if let Some(governance) = env.storage().instance().get::<_, Address>(&GOV_KEY) { + governance + } else { + panic_with_error!(env, Error::NotInitialized); + } + } +} + +mod token { + #[cfg(test)] + mod test_token { + use soroban_sdk::{symbol_short, Address, Env, Symbol}; + + const TOKEN_KEY: Symbol = symbol_short!("TOK"); + + pub fn contract_id(env: &Env) -> Address { + env.storage() + .instance() + .get::<_, Address>(&TOKEN_KEY) + .expect("token contract not initialized") + } + + pub fn register(env: &Env, admin: &Address) { + let sac = env.register_stellar_asset_contract_v2(admin.clone()); + env.storage().instance().set(&TOKEN_KEY, &sac.address()); + } + + pub fn client<'a>(env: &Env) -> soroban_sdk::token::TokenClient<'a> { + soroban_sdk::token::TokenClient::new(env, &contract_id(env)) + } + } + + #[cfg(not(test))] + stellar_registry::import_asset!("usdc"); + + #[cfg(test)] + pub use test_token::*; +} + +#[cfg(test)] +mod test; diff --git a/contracts/scholarship_treasury/src/test.rs b/contracts/scholarship_treasury/src/test.rs new file mode 100644 index 00000000..bf7ba75c --- /dev/null +++ b/contracts/scholarship_treasury/src/test.rs @@ -0,0 +1,104 @@ +extern crate std; + +use soroban_sdk::{ + testutils::{Address as _, MockAuth, MockAuthInvoke}, + token::{StellarAssetClient, TokenClient}, + Address, Env, IntoVal, Val, Vec, +}; + +use crate::{token, Error, ScholarshipTreasury, ScholarshipTreasuryClient}; + +fn setup<'a>(env: &'a Env) -> (ScholarshipTreasuryClient<'a>, Address, Address, Address, Address) { + let admin = Address::generate(env); + let governance = Address::generate(env); + let donor = Address::generate(env); + let recipient = Address::generate(env); + + let contract_id = env.register(ScholarshipTreasury, ()); + let client = ScholarshipTreasuryClient::new(env, &contract_id); + + env.mock_all_auths(); + env.as_contract(&contract_id, || token::register(env, &admin)); + let token_id = env.as_contract(&contract_id, || token::contract_id(env)); + let sac = StellarAssetClient::new(env, &token_id); + sac.mint(&donor, &1_000); + client.initialize(&admin, &token_id, &governance); + env.set_auths(&[]); + + (client, governance, donor, recipient, token_id) +} + +fn token_client<'a>(env: &Env, token_id: &Address) -> TokenClient<'a> { + TokenClient::new(env, token_id) +} + +fn set_caller(client: &ScholarshipTreasuryClient, fn_name: &str, caller: &Address, args: T) +where + T: IntoVal>, +{ + client.env.set_auths(&[]); + let invoke = &MockAuthInvoke { + contract: &client.address, + fn_name, + args: args.into_val(&client.env), + sub_invokes: &[], + }; + client.env.mock_auths(&[MockAuth { + address: caller, + invoke, + }]); +} + +#[test] +fn deposits_are_tracked_per_donor() { + let env = Env::default(); + let (client, _governance, donor, _recipient, token_id) = setup(&env); + + env.mock_all_auths(); + client.deposit(&donor, &150); + client.deposit(&donor, &50); + + assert_eq!(client.get_donor_total(&donor), 200); + assert_eq!(client.get_balance(), 200); + assert_eq!(token_client(&env, &token_id).balance(&client.address), 200); + assert_eq!(token_client(&env, &token_id).balance(&donor), 800); +} + +#[test] +fn unauthorized_disburse_is_rejected() { + let env = Env::default(); + let (client, governance, donor, recipient, token_id) = setup(&env); + env.mock_all_auths(); + client.deposit(&donor, &250); + env.set_auths(&[]); + + let attacker = Address::generate(&env); + set_caller(&client, "disburse", &attacker, (&recipient, 100_i128)); + let unauthorized = client.try_disburse(&recipient, &100); + assert!(unauthorized.is_err()); + + set_caller(&client, "disburse", &governance, (&recipient, 100_i128)); + client.disburse(&recipient, &100); + + assert_eq!(client.get_balance(), 150); + assert_eq!(token_client(&env, &token_id).balance(&recipient), 100); + assert_eq!(token_client(&env, &token_id).balance(&client.address), 150); +} + +#[test] +fn disburse_more_than_balance_fails() { + let env = Env::default(); + let (client, governance, donor, recipient, _token_id) = setup(&env); + env.mock_all_auths(); + client.deposit(&donor, &10); + env.set_auths(&[]); + + set_caller(&client, "disburse", &governance, (&recipient, 20_i128)); + let result = client.try_disburse(&recipient, &20); + assert_eq!( + result.err(), + Some(Ok(soroban_sdk::Error::from_contract_error( + Error::InsufficientFunds as u32 + ))) + ); +} From fe53609a3a16cbe88aea31c43c1b7f91914a2f1c Mon Sep 17 00:00:00 2001 From: Lulu <104063177+Luluameh@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:49:07 +0000 Subject: [PATCH 08/81] #46 Add navigation header and sidebar with active route highlighting --- src/App.module.css | 67 +++++++++++++++++++++++++-- src/App.tsx | 76 +++++++------------------------ src/components/Footer.tsx | 40 ++++++++++++++++ src/components/GuessTheNumber.tsx | 37 ++++++++------- src/components/NavBar.tsx | 58 +++++++++++++++++++++++ src/components/WalletButton.tsx | 2 +- src/pages/Dao.tsx | 14 ++++++ src/pages/Leaderboard.tsx | 14 ++++++ src/pages/Learn.tsx | 14 ++++++ src/pages/Profile.tsx | 14 ++++++ 10 files changed, 256 insertions(+), 80 deletions(-) create mode 100644 src/components/Footer.tsx create mode 100644 src/components/NavBar.tsx create mode 100644 src/pages/Dao.tsx create mode 100644 src/pages/Leaderboard.tsx create mode 100644 src/pages/Learn.tsx create mode 100644 src/pages/Profile.tsx diff --git a/src/App.module.css b/src/App.module.css index 42e472f8..5f1b48c7 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -45,9 +45,70 @@ a[target]:has(button)::after { justify-content: start; } -/** FOOTER */ -.AppLayout :global(.Layout__footer__content) { - flex-direction: row-reverse; +/** NAV BAR */ +.NavBar { + border-bottom: 1px solid var(--sds-clr-gray-06); + padding: 1rem 3rem; + background: var(--sds-clr-white); +} + +.NavBarContent { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 1200px; + margin: 0 auto; +} + +.Logo { + text-decoration: none; + color: inherit; +} + +.NavLinks { + display: flex; + gap: 2rem; + align-items: center; +} + +.NavRight { + display: flex; + align-items: center; + gap: 1rem; +} + +.Hamburger { + display: none; +} + +/* Mobile styles */ +@media (max-width: 768px) { + .NavBar { + padding: 1rem; + } + + .NavLinks { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--sds-clr-white); + flex-direction: column; + justify-content: center; + align-items: center; + gap: 2rem; + z-index: 1000; + } + + .NavLinksOpen { + display: flex; + } + + .Hamburger { + display: block; + } } /** SDS FIXES */ diff --git a/src/App.tsx b/src/App.tsx index 7fcb2be0..ca268a46 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,23 @@ -import { Button, Icon, Layout } from "@stellar/design-system" -import { Routes, Route, Outlet, NavLink } from "react-router-dom" +import { Routes, Route, Outlet } from "react-router-dom" import styles from "./App.module.css" -import ConnectAccount from "./components/ConnectAccount" -import { labPrefix } from "./contracts/util" +import Footer from "./components/Footer" +import NavBar from "./components/NavBar" +import Dao from "./pages/Dao" import Debug from "./pages/Debug" import Home from "./pages/Home" +import Leaderboard from "./pages/Leaderboard" +import Learn from "./pages/Learn" +import Profile from "./pages/Profile" function App() { return ( }> } /> + } /> + } /> + } /> + } /> } /> } /> @@ -20,64 +27,13 @@ function App() { const AppLayout: React.FC = () => (
- - - {({ isActive }) => ( - - )} - - - - - - } - contentRight={} - /> - +
- - - - - +
+ +
- - - - +
) diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 00000000..627e6e6f --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,40 @@ +import { Text } from "@stellar/design-system" + +export default function Footer() { + return ( + + ) +} diff --git a/src/components/GuessTheNumber.tsx b/src/components/GuessTheNumber.tsx index 7b24a68d..83b554d2 100644 --- a/src/components/GuessTheNumber.tsx +++ b/src/components/GuessTheNumber.tsx @@ -1,6 +1,6 @@ import { Button, Card, Code, Icon, Input } from "@stellar/design-system" import { useState } from "react" -import game from "../contracts/guess_the_number" +// import game from "../contracts/guess_the_number" import { useWallet } from "../hooks/useWallet" import styles from "./GuessTheNumber.module.css" @@ -26,23 +26,28 @@ export const GuessTheNumber = () => { // Reset any previous success value setResult("loading") - // Create a transaction using the contract client - const tx = await game.guess( - { a_number: BigInt(guess), guesser: address }, - // @ts-expect-error js-stellar-sdk has bad typings; publicKey is, in fact, allowed - { publicKey: address }, - ) + // TODO: Create a transaction using the contract client + // const tx = await game.guess( + // { a_number: BigInt(guess), guesser: address }, + // // @ts-expect-error js-stellar-sdk has bad typings; publicKey is, in fact, allowed + // { publicKey: address }, + // ) - // Send the transaction to the current network - const { result } = await tx.signAndSend({ signTransaction }) + // // Send the transaction to the current network + // const { result } = await tx.signAndSend({ signTransaction }) - // Handle result and update wallet balance - if (result.isErr()) { - console.error(result.unwrapErr()) - } else { - setResult(result.unwrap() ? "success" : "failure") - await updateBalances() - } + // // Handle result and update wallet balance + // if (result.isErr()) { + // console.error(result.unwrapErr()) + // } else { + // setResult(result.unwrap() ? "success" : "failure") + // await updateBalances() + // } + + // Placeholder: simulate success + setTimeout(() => { + setResult(Math.random() > 0.5 ? "success" : "failure") + }, 1000) } const reset = () => setResult("idle") diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx new file mode 100644 index 00000000..44025851 --- /dev/null +++ b/src/components/NavBar.tsx @@ -0,0 +1,58 @@ +import { Button, Icon, Text } from "@stellar/design-system" +import { useState } from "react" +import { NavLink } from "react-router-dom" +import styles from "../App.module.css" +import { WalletButton } from "./WalletButton" + +export default function NavBar() { + const [menuOpen, setMenuOpen] = useState(false) + + const navLinks = [ + { to: "/learn", label: "Learn" }, + { to: "/dao", label: "DAO" }, + { to: "/leaderboard", label: "Leaderboard" }, + { to: "/profile", label: "My Profile" }, + ] + + return ( +
+
+ + + LearnVault + + + + + +
+ + +
+
+
+ ) +} diff --git a/src/components/WalletButton.tsx b/src/components/WalletButton.tsx index fa5c8a64..530fec38 100644 --- a/src/components/WalletButton.tsx +++ b/src/components/WalletButton.tsx @@ -32,7 +32,7 @@ export const WalletButton = () => { }} > - Wallet Balance: {balances?.xlm?.balance ?? "-"} XLM + Wallet Balance: {balances?.lrn?.balance ?? "-"} LRN
diff --git a/src/pages/Dao.tsx b/src/pages/Dao.tsx new file mode 100644 index 00000000..d4e1ad41 --- /dev/null +++ b/src/pages/Dao.tsx @@ -0,0 +1,14 @@ +import { Text } from "@stellar/design-system" + +export default function Dao() { + return ( +
+ + DAO + + + This is the DAO page. + +
+ ) +} diff --git a/src/pages/Leaderboard.tsx b/src/pages/Leaderboard.tsx new file mode 100644 index 00000000..b20e8b53 --- /dev/null +++ b/src/pages/Leaderboard.tsx @@ -0,0 +1,14 @@ +import { Text } from "@stellar/design-system" + +export default function Leaderboard() { + return ( +
+ + Leaderboard + + + This is the Leaderboard page. + +
+ ) +} diff --git a/src/pages/Learn.tsx b/src/pages/Learn.tsx new file mode 100644 index 00000000..8eeb71b4 --- /dev/null +++ b/src/pages/Learn.tsx @@ -0,0 +1,14 @@ +import { Text } from "@stellar/design-system" + +export default function Learn() { + return ( +
+ + Learn + + + This is the Learn page. + +
+ ) +} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx new file mode 100644 index 00000000..ee7d8438 --- /dev/null +++ b/src/pages/Profile.tsx @@ -0,0 +1,14 @@ +import { Text } from "@stellar/design-system" + +export default function Profile() { + return ( +
+ + My Profile + + + This is the My Profile page. + +
+ ) +} From f14ed1385a88e1a7ff9eacf7e885baff0e0b2328 Mon Sep 17 00:00:00 2001 From: Anuoluwapo25 Date: Mon, 23 Mar 2026 18:12:33 +0100 Subject: [PATCH 09/81] feat: cleaning update --- contracts/fungible-allowlist/src/contract.rs | 72 -------- contracts/fungible-allowlist/src/lib.rs | 7 - contracts/fungible-allowlist/src/test.rs | 156 ----------------- .../Cargo.toml | 2 +- contracts/guess-the-number/Cargo.toml | 24 --- contracts/guess-the-number/src/error.rs | 12 -- contracts/guess-the-number/src/lib.rs | 116 ------------- contracts/guess-the-number/src/test.rs | 158 ------------------ contracts/guess-the-number/src/xlm.rs | 60 ------- .../Cargo.toml | 5 +- contracts/learn_token/src/lib.rs | 143 ++++++++++++++++ contracts/learn_token/src/test.rs | 46 +++++ contracts/nft-enumerable/src/contract.rs | 46 ----- contracts/nft-enumerable/src/lib.rs | 6 - contracts/nft-enumerable/src/test.rs | 80 --------- contracts/scholar_nft/Cargo.toml | 19 +++ contracts/scholarship_treasury/Cargo.toml | 17 ++ src/hooks/useSubscription.ts | 18 +- tsconfig.app.tsbuildinfo | 1 + 19 files changed, 240 insertions(+), 748 deletions(-) delete mode 100644 contracts/fungible-allowlist/src/contract.rs delete mode 100644 contracts/fungible-allowlist/src/lib.rs delete mode 100644 contracts/fungible-allowlist/src/test.rs rename contracts/{fungible-allowlist => governance_token}/Cargo.toml (92%) delete mode 100644 contracts/guess-the-number/Cargo.toml delete mode 100644 contracts/guess-the-number/src/error.rs delete mode 100644 contracts/guess-the-number/src/lib.rs delete mode 100644 contracts/guess-the-number/src/test.rs delete mode 100644 contracts/guess-the-number/src/xlm.rs rename contracts/{nft-enumerable => learn_token}/Cargo.toml (86%) create mode 100644 contracts/learn_token/src/lib.rs create mode 100644 contracts/learn_token/src/test.rs delete mode 100644 contracts/nft-enumerable/src/contract.rs delete mode 100644 contracts/nft-enumerable/src/lib.rs delete mode 100644 contracts/nft-enumerable/src/test.rs create mode 100644 contracts/scholar_nft/Cargo.toml create mode 100644 contracts/scholarship_treasury/Cargo.toml create mode 100644 tsconfig.app.tsbuildinfo diff --git a/contracts/fungible-allowlist/src/contract.rs b/contracts/fungible-allowlist/src/contract.rs deleted file mode 100644 index 007ef120..00000000 --- a/contracts/fungible-allowlist/src/contract.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Fungible AllowList Example Contract. - -//! This contract showcases how to integrate the AllowList extension with a -//! SEP-41-compliant fungible token. It includes essential features such as -//! controlled token transfers by an admin who can allow or disallow specific -//! accounts. - -use soroban_sdk::{ - contract, contractimpl, symbol_short, Address, Env, MuxedAddress, String, Symbol, Vec, -}; -use stellar_access::access_control::{self as access_control, AccessControl}; -use stellar_macros::only_role; -use stellar_tokens::fungible::{ - allowlist::{AllowList, FungibleAllowList}, - burnable::FungibleBurnable, - Base, FungibleToken, -}; - -#[contract] -pub struct ExampleContract; - -#[contractimpl] -impl ExampleContract { - pub fn __constructor( - e: &Env, - name: String, - symbol: String, - admin: Address, - manager: Address, - initial_supply: i128, - ) { - Base::set_metadata(e, 18, name, symbol); - - access_control::set_admin(e, &admin); - - // create a role "manager" and grant it to `manager` - access_control::grant_role_no_auth(e, &manager, &symbol_short!("manager"), &admin); - - // Allow the admin to transfer tokens - AllowList::allow_user(e, &admin); - - // Mint initial supply to the admin - Base::mint(e, &admin, initial_supply); - } -} - -#[contractimpl(contracttrait)] -impl FungibleToken for ExampleContract { - type ContractType = AllowList; -} -#[contractimpl] -impl FungibleAllowList for ExampleContract { - fn allowed(e: &Env, account: Address) -> bool { - AllowList::allowed(e, &account) - } - - #[only_role(operator, "manager")] - fn allow_user(e: &Env, user: Address, operator: Address) { - AllowList::allow_user(e, &user) - } - - #[only_role(operator, "manager")] - fn disallow_user(e: &Env, user: Address, operator: Address) { - AllowList::disallow_user(e, &user) - } -} - -#[contractimpl(contracttrait)] -impl AccessControl for ExampleContract {} - -#[contractimpl(contracttrait)] -impl FungibleBurnable for ExampleContract {} diff --git a/contracts/fungible-allowlist/src/lib.rs b/contracts/fungible-allowlist/src/lib.rs deleted file mode 100644 index a3e21cda..00000000 --- a/contracts/fungible-allowlist/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -#![no_std] -#![allow(dead_code)] - -mod contract; - -#[cfg(test)] -mod test; diff --git a/contracts/fungible-allowlist/src/test.rs b/contracts/fungible-allowlist/src/test.rs deleted file mode 100644 index 15b2b200..00000000 --- a/contracts/fungible-allowlist/src/test.rs +++ /dev/null @@ -1,156 +0,0 @@ -extern crate std; - -use soroban_sdk::{testutils::Address as _, Address, Env, String}; - -use crate::contract::{ExampleContract, ExampleContractClient}; - -fn create_client<'a>( - e: &Env, - admin: &Address, - manager: &Address, - initial_supply: &i128, -) -> ExampleContractClient<'a> { - let name = String::from_str(e, "AllowList Token"); - let symbol = String::from_str(e, "ALT"); - let address = e.register(ExampleContract, (name, symbol, admin, manager, initial_supply)); - ExampleContractClient::new(e, &address) -} - -#[test] -#[should_panic(expected = "Error(Contract, #113)")] -fn cannot_transfer_before_allow() { - let e = Env::default(); - let admin = Address::generate(&e); - let manager = Address::generate(&e); - let user1 = Address::generate(&e); - let user2 = Address::generate(&e); - let initial_supply = 1_000_000; - let client = create_client(&e, &admin, &manager, &initial_supply); - let transfer_amount = 1000; - - // Verify initial state - admin is allowed, others are not - assert!(client.allowed(&admin)); - assert!(!client.allowed(&user1)); - assert!(!client.allowed(&user2)); - - // Admin can't transfer to user1 initially (user1 not allowed) - e.mock_all_auths(); - client.transfer(&admin, &user1, &transfer_amount); -} - -#[test] -fn transfer_to_allowed_account_works() { - let e = Env::default(); - let admin = Address::generate(&e); - let manager = Address::generate(&e); - let user1 = Address::generate(&e); - let user2 = Address::generate(&e); - let initial_supply = 1_000_000; - let client = create_client(&e, &admin, &manager, &initial_supply); - let transfer_amount = 1000; - - e.mock_all_auths(); - - // Verify initial state - admin is allowed, others are not - assert!(client.allowed(&admin)); - assert!(!client.allowed(&user1)); - assert!(!client.allowed(&user2)); - - // Allow user1 - client.allow_user(&user1, &manager); - assert!(client.allowed(&user1)); - - // Now admin can transfer to user1 - client.transfer(&admin, &user1, &transfer_amount); - assert_eq!(client.balance(&user1), transfer_amount); -} - -#[test] -#[should_panic(expected = "Error(Contract, #113)")] -fn cannot_transfer_after_disallow() { - let e = Env::default(); - let admin = Address::generate(&e); - let manager = Address::generate(&e); - let user1 = Address::generate(&e); - let user2 = Address::generate(&e); - let initial_supply = 1_000_000; - let client = create_client(&e, &admin, &manager, &initial_supply); - let transfer_amount = 1000; - - e.mock_all_auths(); - - // Verify initial state - admin is allowed, others are not - assert!(client.allowed(&admin)); - assert!(!client.allowed(&user1)); - assert!(!client.allowed(&user2)); - - // Allow user1 - client.allow_user(&user1, &manager); - assert!(client.allowed(&user1)); - - // Now admin can transfer to user1 - client.transfer(&admin, &user1, &transfer_amount); - assert_eq!(client.balance(&user1), transfer_amount); - - // Disallow user1 - client.disallow_user(&user1, &manager); - assert!(!client.allowed(&user1)); - - // Admin can't transfer to user1 after disallowing - client.transfer(&admin, &user1, &100); -} - -#[test] -fn allowlist_transfer_from_override_works() { - let e = Env::default(); - let admin = Address::generate(&e); - let manager = Address::generate(&e); - let user1 = Address::generate(&e); - let user2 = Address::generate(&e); - let initial_supply = 1_000_000; - let client = create_client(&e, &admin, &manager, &initial_supply); - let transfer_amount = 1000; - - e.mock_all_auths(); - - // Verify initial state - admin is allowed, others are not - assert!(client.allowed(&admin)); - assert!(!client.allowed(&user1)); - assert!(!client.allowed(&user2)); - - // Allow user2 - client.allow_user(&user2, &manager); - assert!(client.allowed(&user2)); - - // Now admin can transfer to user1 - client.approve(&admin, &user1, &transfer_amount, &1000); - client.transfer_from(&user1, &admin, &user2, &transfer_amount); - assert_eq!(client.balance(&user2), transfer_amount); -} - -#[test] -fn allowlist_approve_override_works() { - let e = Env::default(); - let admin = Address::generate(&e); - let manager = Address::generate(&e); - let user1 = Address::generate(&e); - let user2 = Address::generate(&e); - let initial_supply = 1_000_000; - let client = create_client(&e, &admin, &manager, &initial_supply); - let transfer_amount = 1000; - - e.mock_all_auths(); - - // Verify initial state - admin is allowed, others are not - assert!(client.allowed(&admin)); - assert!(!client.allowed(&user1)); - assert!(!client.allowed(&user2)); - - // Allow user1 - client.allow_user(&user1, &manager); - assert!(client.allowed(&user1)); - - // Approve user2 to transfer from user1 - client.approve(&user1, &user2, &transfer_amount, &1000); - assert_eq!(client.allowance(&user1, &user2), transfer_amount); -} diff --git a/contracts/fungible-allowlist/Cargo.toml b/contracts/governance_token/Cargo.toml similarity index 92% rename from contracts/fungible-allowlist/Cargo.toml rename to contracts/governance_token/Cargo.toml index 4cf40c51..1c26652e 100644 --- a/contracts/fungible-allowlist/Cargo.toml +++ b/contracts/governance_token/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "fungible-allowlist-example" +name = "governance-token" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/contracts/guess-the-number/Cargo.toml b/contracts/guess-the-number/Cargo.toml deleted file mode 100644 index cecf81b7..00000000 --- a/contracts/guess-the-number/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "guess-the-number" -description = "Admin sets up the pot, anyone can guess to win it" -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false -version.workspace = true - -[package.metadata.stellar] -# Set contract metadata for authors, homepage, and version based on the Cargo.toml package values -cargo_inherit = true - -[lib] -crate-type = ["cdylib"] -doctest = false - -[dependencies] -soroban-sdk = "23.0.3" -stellar-registry = "0.0.4" - -[dev-dependencies] -stellar-xdr = { version = "23.0.0", features = ["curr", "serde"] } -soroban-sdk = { version = "23.0.3", features = ["testutils"] } diff --git a/contracts/guess-the-number/src/error.rs b/contracts/guess-the-number/src/error.rs deleted file mode 100644 index 88544b51..00000000 --- a/contracts/guess-the-number/src/error.rs +++ /dev/null @@ -1,12 +0,0 @@ -#[soroban_sdk::contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum Error { - /// The contract failed to transfer XLM to the guesser - FailedToTransferToGuesser = 1, - /// The guesser failed to transfer XLM to the contract - FailedToTransferFromGuesser = 2, - /// The contract has no balance to transfer to the guesser - NoBalanceToTransfer = 3, - -} diff --git a/contracts/guess-the-number/src/lib.rs b/contracts/guess-the-number/src/lib.rs deleted file mode 100644 index d4558a4a..00000000 --- a/contracts/guess-the-number/src/lib.rs +++ /dev/null @@ -1,116 +0,0 @@ -#![no_std] -use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Symbol}; - -mod error; -mod xlm; - -use error::Error; - -#[contract] -pub struct GuessTheNumber; - -const THE_NUMBER: &Symbol = &symbol_short!("n"); -pub const ADMIN_KEY: &Symbol = &symbol_short!("ADMIN"); - -#[contractimpl] -impl GuessTheNumber { - /// Constructor to initialize the contract with an admin and a random number - pub fn __constructor(env: &Env, admin: Address) { - // Require auth from the admin to make the transfer - admin.require_auth(); - // This is for testing purposes. Ensures that the XLM contract set up for unit testing and local network - xlm::register(env, &admin); - // Send the contract an amount of XLM to play with - xlm::token_client(env).transfer( - &admin, - env.current_contract_address(), - &xlm::to_stroops(1), - ); - // Set the admin in storage - Self::set_admin(env, admin); - // Set a random number between 1 and 10 - Self::reset_number(env); - } - - /// Update the number. Only callable by admin. - pub fn reset(env: &Env) { - Self::require_admin(env); - Self::reset_number(env); - } - - // Private function to reset the number to a new random value - // which doesn't require auth from the admin - fn reset_number(env: &Env) { - let new_number: u64 = env.prng().gen_range(1..=10); - env.storage().instance().set(THE_NUMBER, &new_number); - } - - /// Guess a number between 1 and 10 - pub fn guess(env: &Env, a_number: u64, guesser: Address) -> Result { - let xlm_client = xlm::token_client(env); - let contract_address = env.current_contract_address(); - let guessed_it = a_number == Self::number(env); - if guessed_it { - let balance = xlm_client.balance(&contract_address); - if balance == 0 { - return Err(Error::NoBalanceToTransfer); - } - // Methods `try_*` will return an error if the method fails - // `.map_err` lets us convert the error to our custom Error type - let _ = xlm_client - .try_transfer(&contract_address, &guesser, &balance) - .map_err(|_| Error::FailedToTransferToGuesser)?; - } else { - guesser.require_auth(); - let _ = xlm_client - .try_transfer(&guesser, &contract_address, &xlm::to_stroops(1)) - .map_err(|_| Error::FailedToTransferFromGuesser)?; - } - Ok(guessed_it) - } - - /// Admin can add more funds to the contract - pub fn add_funds(env: &Env, amount: i128) { - Self::require_admin(env); - let contract_address = env.current_contract_address(); - // unwrap here is safe because the admin was set in the constructor - let admin = Self::admin(env).unwrap(); - xlm::token_client(env).transfer(&admin, &contract_address, &amount); - } - - /// Upgrade the contract to new wasm. Only callable by admin. - pub fn upgrade(env: &Env, new_wasm_hash: BytesN<32>) { - Self::require_admin(env); - env.deployer().update_current_contract_wasm(new_wasm_hash); - } - - /// readonly function to get the current number - /// `pub(crate)` makes it accessible in the same crate, but not outside of it - pub(crate) fn number(env: &Env) -> u64 { - // We can unwrap because the number is set in the constructor - // and then only reset by the admin - unsafe { env.storage().instance().get(THE_NUMBER).unwrap_unchecked() } - } - - /// Get current admin - pub fn admin(env: &Env) -> Option
{ - env.storage().instance().get(ADMIN_KEY) - } - - /// Set a new admin. Only callable by admin. - pub fn set_admin(env: &Env, admin: Address) { - // Check if admin is already set - if env.storage().instance().has(ADMIN_KEY) { - panic!("admin already set"); - } - env.storage().instance().set(ADMIN_KEY, &admin); - } - - /// Private helper function to require auth from the admin - fn require_admin(env: &Env) { - let admin = Self::admin(env).expect("admin not set"); - admin.require_auth(); - } -} - -mod test; diff --git a/contracts/guess-the-number/src/test.rs b/contracts/guess-the-number/src/test.rs deleted file mode 100644 index c4f75001..00000000 --- a/contracts/guess-the-number/src/test.rs +++ /dev/null @@ -1,158 +0,0 @@ -#![cfg(test)] -// This lets use reference types in the std library for testing -extern crate std; - -use super::*; -use soroban_sdk::{ - testutils::{Address as _, MockAuth, MockAuthInvoke}, - token::StellarAssetClient, - Address, Env, IntoVal, Val, Vec, -}; - -fn init_test<'a>(env: &'a Env) -> (Address, StellarAssetClient<'a>, GuessTheNumberClient<'a>) { - let admin = Address::generate(env); - let client = generate_client(env, &admin); - // This is needed because we want to call a function from within the context of the contract - // In this case we want to get the address of the XLM contract registered by the constructor - let sac_address = env.as_contract(&client.address, || xlm::contract_id(env)); - (admin, StellarAssetClient::new(env, &sac_address), client) -} - -#[test] -fn constructed_correctly() { - let env = &Env::default(); - let (admin, sac, client) = init_test(env); - // Check that the admin is set correctly - assert_eq!(client.admin(), Some(admin.clone())); - // Check that the contract has a balance of 1 XLM - assert_eq!(sac.balance(&client.address), xlm::to_stroops(1)); - // Need to use `as_contract` to call a function in the context of the contract - // Since the method `number` is not in the client, but is visibile in the crate - let number = env.as_contract(&client.address, || GuessTheNumber::number(env)); - assert_eq!(number, 4); -} - -#[test] -fn only_admin_can_reset() { - let env = &Env::default(); - let (admin, _, client) = init_test(env); - let user = Address::generate(env); - - set_caller(&client, "reset", &user, ()); - assert!(client.try_reset().is_err()); - - set_caller(&client, "reset", &admin, ()); - assert!(client.try_reset().is_ok()); -} - -#[test] -fn guess() { - let env = &Env::default(); - let (_, sac, client) = init_test(env); - // This lets you mock all auth when they become complicated when making cross contract calls. - env.mock_all_auths(); - - // Create a user to guess - let alice = Address::generate(env); - // Mint tokens to the user. On testnet you use friendbot to fund the account. - sac.mint(&alice, &xlm::to_stroops(2)); - // Check that alice has the tokens - assert_eq!(sac.balance(&alice), xlm::to_stroops(2)); - - // Create another user with no funds - let bob = Address::generate(env); - - // In the testing enviroment the random seed is always the same initially. - // This tests a wrong guess so the balance should go down one XLM - assert!(!client.guess(&3, &alice)); - assert_eq!(sac.balance(&alice), xlm::to_stroops(1)); - - // Now we test a wrong guess but the user has no funds so we get an error - assert_eq!( - client.try_guess(&3, &bob).unwrap_err(), - Ok(Error::FailedToTransferFromGuesser) - ); - - // Now we test a correct guess, the balance should go up by the initial 1 XLM + the 1 XLM from the contract - assert!(client.guess(&4, &alice)); - assert_eq!(sac.balance(&alice), xlm::to_stroops(3)); - - assert_eq!( - client.try_guess(&4, &alice).unwrap_err(), - Ok(Error::NoBalanceToTransfer) - ); -} - -#[test] -fn add_funds() { - let env = &Env::default(); - let (_, sac, client) = init_test(env); - // This lets you mock all auth when they become complicated when making cross contract calls. - env.mock_all_auths(); - - // Create a user to guess - let alice = Address::generate(env); - // Mint tokens to the user. On testnet you use friendbot to fund the account. - sac.mint(&alice, &xlm::to_stroops(2)); - // Now we test a correct guess, the balance should go up by the initial 1 XLM + the 1 XLM from the contract - assert!(client.guess(&4, &alice)); - assert_eq!(sac.balance(&alice), xlm::to_stroops(3)); - assert_eq!(sac.balance(&client.address), 0); - - client.add_funds(&xlm::to_stroops(5)); - assert_eq!(sac.balance(&client.address), xlm::to_stroops(5)); - - // Since we didn't reset the number, the guess should still be correct - assert!(client.guess(&4, &alice)); - assert_eq!(sac.balance(&alice), xlm::to_stroops(8)); - assert_eq!(sac.balance(&client.address), 0); -} - -#[test] -fn reset_and_guess() { - let env = &Env::default(); - let (_, sac, client) = init_test(env); - // This lets you mock all auth when they become complicated when making cross contract calls. - env.mock_all_auths(); - - // Create a user to guess - let alice = Address::generate(env); - // Mint tokens to the user. On testnet you use friendbot to fund the account. - sac.mint(&alice, &xlm::to_stroops(2)); - - // Reset the number - client.reset(); - - // Guess again, this should be correct now - assert!(client.guess(&10, &alice)); -} - -fn generate_client<'a>(env: &Env, admin: &Address) -> GuessTheNumberClient<'a> { - let contract_id = Address::generate(env); - env.mock_all_auths(); - let contract_id = env.register_at(&contract_id, GuessTheNumber, (admin,)); - env.set_auths(&[]); // clear auths - GuessTheNumberClient::new(env, &contract_id) -} - -// This lets you mock the auth context for a function call -fn set_caller(client: &GuessTheNumberClient, fn_name: &str, caller: &Address, args: T) -where - T: IntoVal>, -{ - // clear previous auth mocks - client.env.set_auths(&[]); - - let invoke = &MockAuthInvoke { - contract: &client.address, - fn_name, - args: args.into_val(&client.env), - sub_invokes: &[], - }; - - // mock auth as passed-in address - client.env.mock_auths(&[MockAuth { - address: &caller, - invoke, - }]); -} diff --git a/contracts/guess-the-number/src/xlm.rs b/contracts/guess-the-number/src/xlm.rs deleted file mode 100644 index 006693d9..00000000 --- a/contracts/guess-the-number/src/xlm.rs +++ /dev/null @@ -1,60 +0,0 @@ -#[cfg(test)] -mod xlm { - use super::*; - const XLM_KEY: &soroban_sdk::Symbol = &soroban_sdk::symbol_short!("XLM"); - - pub fn contract_id(env: &soroban_sdk::Env) -> soroban_sdk::Address { - env.storage() - .instance() - .get::<_, soroban_sdk::Address>(XLM_KEY) - .expect("XLM contract not initialized. Please deploy the XLM contract first.") - } - - pub fn register( - env: &soroban_sdk::Env, - admin: &soroban_sdk::Address, - ) -> soroban_sdk::testutils::StellarAssetContract { - let sac = env.register_stellar_asset_contract_v2(admin.clone()); - env.storage().instance().set(XLM_KEY, &sac.address()); - stellar_asset_client(env).mint(admin, &to_stroops(10_000)); - sac - } - - #[allow(unused)] - pub fn stellar_asset_client<'a>( - env: &soroban_sdk::Env, - ) -> soroban_sdk::token::StellarAssetClient<'a> { - soroban_sdk::token::StellarAssetClient::new(&env, &contract_id(env)) - } - /// Create a Stellar Asset Client for the asset which provides an admin interface - pub fn token_client<'a>(env: &soroban_sdk::Env) -> soroban_sdk::token::TokenClient<'a> { - soroban_sdk::token::TokenClient::new(&env, &contract_id(env)) - } -} -const ONE_XLM: i128 = 1_000_000_0; // 1 XLM in stroops; - -pub const fn to_stroops(num: u64) -> i128 { - (num as i128) * ONE_XLM -} - -#[cfg(not(test))] -stellar_registry::import_asset!("xlm"); - -#[allow(unused)] -pub const SERIALIZED_ASSET: [u8; 4] = [0, 0, 0, 0]; - -pub use xlm::*; -mod register { - - #[allow(unused)] - #[cfg(not(test))] - pub fn register(env: &soroban_sdk::Env, admin: &soroban_sdk::Address) { - let balance = super::token_client(env).try_balance(&env.current_contract_address()); - if balance.is_err() { - env.deployer().with_stellar_asset(super::SERIALIZED_ASSET).deploy(); - } - } -} - -#[allow(unused_imports)] -pub use register::*; \ No newline at end of file diff --git a/contracts/nft-enumerable/Cargo.toml b/contracts/learn_token/Cargo.toml similarity index 86% rename from contracts/nft-enumerable/Cargo.toml rename to contracts/learn_token/Cargo.toml index 8394aecc..596dc3e7 100644 --- a/contracts/nft-enumerable/Cargo.toml +++ b/contracts/learn_token/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nft-enumerable-example" +name = "learn-token" edition.workspace = true license.workspace = true repository.workspace = true @@ -12,8 +12,9 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } -stellar-tokens = { workspace = true } +stellar-access = { workspace = true } stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/learn_token/src/lib.rs b/contracts/learn_token/src/lib.rs new file mode 100644 index 00000000..4813a650 --- /dev/null +++ b/contracts/learn_token/src/lib.rs @@ -0,0 +1,143 @@ +#![no_std] + +//! # LearnToken (LRN) +//! +//! A **soulbound** (non-transferable) SEP-41 fungible token minted to learners +//! when they complete verified course milestones. +//! +//! - Minting is restricted to the `CourseMilestone` contract (admin role). +//! - Transfer and `transfer_from` always revert — tokens represent proof of +//! effort, not speculative value. +//! - The LRN balance is a learner's on-chain reputation score. +//! +//! ## Relevant issue +//! Implements: https://github.com/bakeronchain/learnvault/issues/5 + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address, + Env, String, Symbol, +}; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum LRNError { + /// Transfers are permanently disabled — LRN is soulbound. + Soulbound = 1, + /// Caller is not the contract admin. + Unauthorized = 2, + /// Mint amount must be greater than zero. + ZeroAmount = 3, + /// Contract has not been initialized. + NotInitialized = 4, +} + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); +const NAME_KEY: Symbol = symbol_short!("NAME"); +const SYMBOL_KEY: Symbol = symbol_short!("SYMBOL"); +const DECIMALS_KEY: Symbol = symbol_short!("DECIMALS"); + +#[contracttype] +pub enum DataKey { + Balance(Address), + TotalSupply, +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct LearnToken; + +#[contractimpl] +impl LearnToken { + /// Initialise the contract. + /// + /// Must be called once by the deployer. `admin` should be set to the + /// `CourseMilestone` contract address once that is deployed. + pub fn initialize(env: Env, admin: Address) { + todo!("set admin, name='LearnToken', symbol='LRN', decimals=7 in instance storage") + } + + // ----------------------------------------------------------------------- + // Admin + // ----------------------------------------------------------------------- + + /// Mint `amount` LRN to `to`. Only callable by the admin. + pub fn mint(env: Env, to: Address, amount: i128) { + todo!("require admin auth, validate amount > 0, update balance + total supply, emit lrn_mint event") + } + + /// Transfer the admin role to a new address (e.g. the CourseMilestone contract). + pub fn set_admin(env: Env, new_admin: Address) { + todo!("require current admin auth, update ADMIN_KEY") + } + + // ----------------------------------------------------------------------- + // SEP-41 read functions + // ----------------------------------------------------------------------- + + pub fn balance(env: Env, account: Address) -> i128 { + todo!("return Balance(account) from persistent storage, default 0") + } + + pub fn total_supply(env: Env) -> i128 { + todo!("return TotalSupply from persistent storage") + } + + pub fn decimals(env: Env) -> u32 { + todo!("return DECIMALS_KEY from instance storage") + } + + pub fn name(env: Env) -> String { + todo!("return NAME_KEY from instance storage") + } + + pub fn symbol(env: Env) -> String { + todo!("return SYMBOL_KEY from instance storage") + } + + // ----------------------------------------------------------------------- + // SEP-41 transfer functions — soulbound: always revert + // ----------------------------------------------------------------------- + + pub fn transfer(env: Env, _from: Address, _to: Address, _amount: i128) { + panic_with_error!(&env, LRNError::Soulbound) + } + + pub fn transfer_from( + env: Env, + _spender: Address, + _from: Address, + _to: Address, + _amount: i128, + ) { + panic_with_error!(&env, LRNError::Soulbound) + } + + pub fn approve( + env: Env, + _from: Address, + _spender: Address, + _amount: i128, + _expiration_ledger: u32, + ) { + panic_with_error!(&env, LRNError::Soulbound) + } + + pub fn allowance(env: Env, _from: Address, _spender: Address) -> i128 { + 0 + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/learn_token/src/test.rs b/contracts/learn_token/src/test.rs new file mode 100644 index 00000000..68cb6d60 --- /dev/null +++ b/contracts/learn_token/src/test.rs @@ -0,0 +1,46 @@ +extern crate std; + +use soroban_sdk::{testutils::Address as _, Address, Env}; + +use crate::{LRNError, LearnToken, LearnTokenClient}; + +fn setup(e: &Env) -> (Address, Address, LearnTokenClient) { + let admin = Address::generate(e); + let id = e.register(LearnToken, ()); + e.mock_all_auths(); + let client = LearnTokenClient::new(e, &id); + client.initialize(&admin); + (id, admin, client) +} + +// TODO: uncomment and complete once `initialize` and `mint` are implemented. + +// #[test] +// fn mint_increases_balance() { +// let e = Env::default(); +// let (_, _admin, client) = setup(&e); +// let learner = Address::generate(&e); +// client.mint(&learner, &100); +// assert_eq!(client.balance(&learner), 100); +// } + +// #[test] +// #[should_panic(expected = "Error(Contract, #1)")] +// fn transfer_is_blocked() { +// let e = Env::default(); +// let (_, _admin, client) = setup(&e); +// let a = Address::generate(&e); +// let b = Address::generate(&e); +// client.mint(&a, &50); +// client.transfer(&a, &b, &10); +// } + +// #[test] +// #[should_panic] +// fn unauthorized_mint_fails() { +// let e = Env::default(); +// let (_, _, client) = setup(&e); +// let stranger = Address::generate(&e); +// // do NOT mock auths — should fail +// client.mint(&stranger, &100); +// } diff --git a/contracts/nft-enumerable/src/contract.rs b/contracts/nft-enumerable/src/contract.rs deleted file mode 100644 index 94b0a6e8..00000000 --- a/contracts/nft-enumerable/src/contract.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Non-Fungible Enumerable Example Contract. -//! -//! Demonstrates an example usage of the Enumerable extension, allowing for -//! enumeration of all the token IDs in the contract as well as all the token -//! IDs owned by each account. - -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; -use stellar_tokens::non_fungible::{ - burnable::NonFungibleBurnable, - enumerable::{Enumerable, NonFungibleEnumerable}, - Base, NonFungibleToken, -}; - -#[contracttype] -pub enum DataKey { - Owner, -} - -#[contract] -pub struct ExampleContract; - -#[contractimpl] -impl ExampleContract { - pub fn __constructor(e: &Env, uri: String, name: String, symbol: String, owner: Address) { - e.storage().instance().set(&DataKey::Owner, &owner); - Base::set_metadata(e, uri, name, symbol); - } - - pub fn mint(e: &Env, to: Address) -> u32 { - let owner: Address = - e.storage().instance().get(&DataKey::Owner).expect("owner should be set"); - owner.require_auth(); - Enumerable::sequential_mint(e, &to) - } -} - -#[contractimpl(contracttrait)] -impl NonFungibleToken for ExampleContract { - type ContractType = Enumerable; -} - -#[contractimpl(contracttrait)] -impl NonFungibleEnumerable for ExampleContract {} - -#[contractimpl(contracttrait)] -impl NonFungibleBurnable for ExampleContract {} diff --git a/contracts/nft-enumerable/src/lib.rs b/contracts/nft-enumerable/src/lib.rs deleted file mode 100644 index f1ec4a1f..00000000 --- a/contracts/nft-enumerable/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -#![no_std] -#![allow(dead_code)] - -mod contract; -#[cfg(test)] -mod test; diff --git a/contracts/nft-enumerable/src/test.rs b/contracts/nft-enumerable/src/test.rs deleted file mode 100644 index afe9298f..00000000 --- a/contracts/nft-enumerable/src/test.rs +++ /dev/null @@ -1,80 +0,0 @@ -extern crate std; - -use soroban_sdk::{testutils::Address as _, Address, Env, String}; - -use crate::contract::{ExampleContract, ExampleContractClient}; - -fn create_client<'a>(e: &Env, owner: &Address) -> ExampleContractClient<'a> { - let uri = String::from_str(e, "www.mytoken.com"); - let name = String::from_str(e, "My Token"); - let symbol = String::from_str(e, "TKN"); - let address = e.register(ExampleContract, (uri, name, symbol, owner)); - ExampleContractClient::new(e, &address) -} - -#[test] -fn enumerable_transfer_override_works() { - let e = Env::default(); - - let owner = Address::generate(&e); - - let recipient = Address::generate(&e); - - let client = create_client(&e, &owner); - - e.mock_all_auths(); - client.mint(&owner); - client.transfer(&owner, &recipient, &0); - assert_eq!(client.balance(&owner), 0); - assert_eq!(client.balance(&recipient), 1); - assert_eq!(client.get_owner_token_id(&recipient, &0), 0); -} - -#[test] -fn enumerable_transfer_from_override_works() { - let e = Env::default(); - - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let recipient = Address::generate(&e); - - let client = create_client(&e, &owner); - - e.mock_all_auths(); - client.mint(&owner); - client.approve(&owner, &spender, &0, &1000); - client.transfer_from(&spender, &owner, &recipient, &0); - assert_eq!(client.balance(&owner), 0); - assert_eq!(client.balance(&recipient), 1); - assert_eq!(client.get_owner_token_id(&recipient, &0), 0); -} - -#[test] -fn enumerable_burn_override_works() { - let e = Env::default(); - let owner = Address::generate(&e); - let client = create_client(&e, &owner); - e.mock_all_auths(); - client.mint(&owner); - client.burn(&owner, &0); - assert_eq!(client.balance(&owner), 0); - client.mint(&owner); - assert_eq!(client.balance(&owner), 1); - assert_eq!(client.get_owner_token_id(&owner, &0), 1); -} - -#[test] -fn enumerable_burn_from_override_works() { - let e = Env::default(); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let client = create_client(&e, &owner); - e.mock_all_auths(); - client.mint(&owner); - client.approve(&owner, &spender, &0, &1000); - client.burn_from(&spender, &owner, &0); - assert_eq!(client.balance(&owner), 0); - client.mint(&owner); - assert_eq!(client.balance(&owner), 1); - assert_eq!(client.get_owner_token_id(&owner, &0), 1); -} diff --git a/contracts/scholar_nft/Cargo.toml b/contracts/scholar_nft/Cargo.toml new file mode 100644 index 00000000..e533e894 --- /dev/null +++ b/contracts/scholar_nft/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "scholar-nft" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/scholarship_treasury/Cargo.toml b/contracts/scholarship_treasury/Cargo.toml new file mode 100644 index 00000000..1d5ff51b --- /dev/null +++ b/contracts/scholarship_treasury/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "scholarship-treasury" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/src/hooks/useSubscription.ts b/src/hooks/useSubscription.ts index 98d52932..4582814d 100644 --- a/src/hooks/useSubscription.ts +++ b/src/hooks/useSubscription.ts @@ -43,19 +43,21 @@ export function useSubscription( let stop = false async function pollEvents(): Promise { + const entry = paging[id] + if (!entry) return try { - if (!paging[id].lastLedgerStart) { + if (!entry.lastLedgerStart) { const latestLedgerState = await server.getLatestLedger() - paging[id].lastLedgerStart = latestLedgerState.sequence + entry.lastLedgerStart = latestLedgerState.sequence } // lastLedgerStart is now guaranteed to be a number - const lastLedger = paging[id].lastLedgerStart + const lastLedger = entry.lastLedgerStart const response = await server.getEvents( - paging[id].pagingToken + entry.pagingToken ? { - cursor: paging[id].pagingToken, + cursor: entry.pagingToken, filters: [ { contractIds: [contractId], @@ -79,9 +81,9 @@ export function useSubscription( }, ) - paging[id].pagingToken = undefined + entry.pagingToken = undefined if (response.latestLedger) { - paging[id].lastLedgerStart = response.latestLedger + entry.lastLedgerStart = response.latestLedger } if (response.events && response.events.length > 0) { response.events.forEach((event) => { @@ -96,7 +98,7 @@ export function useSubscription( }) // Store the cursor from the response for pagination if (response.cursor) { - paging[id].pagingToken = response.cursor + entry.pagingToken = response.cursor } } } catch (error) { diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo new file mode 100644 index 00000000..672e1c6d --- /dev/null +++ b/tsconfig.app.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/connectaccount.tsx","./src/components/fundaccountbutton.tsx","./src/components/guessthenumber.tsx","./src/components/networkpill.tsx","./src/components/walletbutton.tsx","./src/contracts/util.ts","./src/hooks/usenotification.ts","./src/hooks/usesubscription.ts","./src/hooks/usewallet.ts","./src/pages/debug.tsx","./src/pages/home.tsx","./src/providers/notificationprovider.tsx","./src/providers/walletprovider.tsx","./src/util/contract.ts","./src/util/friendbot.ts","./src/util/storage.ts","./src/util/wallet.ts","./reset.d.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file From 8310ac0fcd5a8f733e78b7e35bf14b54c89fe431 Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Mon, 23 Mar 2026 19:21:48 +0100 Subject: [PATCH 10/81] feat: add public Profile page with share, edit, and skeletons; wire event/contract data; fix build shims --- src/App.tsx | 11 ++ src/components/GuessTheNumber.tsx | 1 - src/hooks/useSubscription.ts | 23 +-- src/pages/Profile.module.css | 131 +++++++++++++++ src/pages/Profile.tsx | 262 ++++++++++++++++++++++++++++++ src/util/profileData.ts | 235 +++++++++++++++++++++++++++ 6 files changed, 652 insertions(+), 11 deletions(-) create mode 100644 src/pages/Profile.module.css create mode 100644 src/pages/Profile.tsx create mode 100644 src/util/profileData.ts diff --git a/src/App.tsx b/src/App.tsx index 7fcb2be0..c716a051 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,12 +5,15 @@ import ConnectAccount from "./components/ConnectAccount" import { labPrefix } from "./contracts/util" import Debug from "./pages/Debug" import Home from "./pages/Home" +import Profile from "./pages/Profile" function App() { return ( }> } /> + } /> + } /> } /> } /> @@ -34,6 +37,14 @@ const AppLayout: React.FC = () => ( )} + + {({ isActive }) => ( + + )} + + {isOwnProfile && !editing && ( + + )} +
+
+ +
+
+

Identity

+ {editing ? ( + <> + +