OnChain Scholarship

A decentralized scholarship platform on Conflux eSpace where admins create and fund scholarships, students apply on-chain, and approved applicants claim funds directly to their wallets. This post walks through the architecture and the important code that makes it work.


Tutorial; https://youtu.be/BnAlIeVSs6Q

Code;https://github.com/Vikash-8090-Yadav/cfxscholarshipandgrants

Overview & Features

  • Admin: Create scholarships by sending CFX (or ETH) to the contract, review applications, and approve or reject them.
  • Students: Apply for a scholarship (metadata stored on-chain or as data URI), see status (Pending → Approved/Rejected), and claim funds after approval.
  • Trust: Funds are held by the contract; only an approved applicant can claim, and only once per scholarship.

All of this runs on Conflux eSpace Testnet (EVM-compatible), so you use standard Ethereum tooling (Hardhat, wagmi, viem).


Tech Stack

Layer Tech
Contract Solidity 0.8.20, Hardhat
Chain Conflux eSpace (Testnet chain ID 71)
Frontend Next.js 16, React 19
Web3 wagmi, viem, RainbowKit
UI Tailwind, Radix UI, shadcn-style components

Architecture

┌─────────────────────────────────────────────────────────────────┐
│  Next.js App (React)                                             │
│  ├── Admin: create scholarship (payable), list applications,     │
│  │          approve / reject                                     │
│  └── Student: browse → apply → (after approval) claim            │
└────────────────────────────┬────────────────────────────────────┘
                             │ wagmi + viem (read/write contract)
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  OnChainScholarship (Solidity) on Conflux eSpace                 │
│  • createScholarship(title, description) payable                 │
│  • submitApplication(scholarshipId, metadataURI)                 │
│  • approveApplication / rejectApplication (admin only)          │
│  • claimFunds(scholarshipId)                                     │
└─────────────────────────────────────────────────────────────────┘

Smart Contract Deep Dive

File: contracts/scholarship.sol

State & Access Control

The contract stores a single admin (set in the constructor) and uses an onlyAdmin modifier so only that address can create scholarships, approve/reject, or close a scholarship.

address public admin;
uint256 public scholarshipCount;

constructor() {
    admin = msg.sender;
}

modifier onlyAdmin() {
    require(msg.sender == admin, "Not admin");
    _;
}

Data Structures

  • ApplicationStatus: PendingApproved / Rejected → (if approved) Claimed.
  • Scholarship: id, title, description, amount (wei), isActive.
  • Application: applicant address, metadataURI (e.g. IPFS or data URI), status.

Applications are stored per scholarship and per applicant:
mapping(uint256 => mapping(address => Application)) public applications.

Creating a Scholarship (Payable)

The admin funds the scholarship in the same transaction as creating it. msg.value is the award amount and is stored in the contract until someone claims it.

function createScholarship(
    string calldata _title,
    string calldata _description
) external payable onlyAdmin {
    require(msg.value > 0, "Funding required");

    scholarships[scholarshipCount] = Scholarship({
        id: scholarshipCount,
        title: _title,
        description: _description,
        amount: msg.value,
        isActive: true
    });

    emit ScholarshipCreated(scholarshipCount, _title, msg.value);
    scholarshipCount++;
}

Submitting an Application

Anyone can call this for an active scholarship. The contract ensures the scholarship is active, the metadata URI is non-empty, and the sender hasn’t already applied. Status is set to Pending.

function submitApplication(
    uint256 _scholarshipId,
    string calldata _metadataURI
) external {
    Scholarship memory scholarship = scholarships[_scholarshipId];
    require(scholarship.isActive, "Scholarship closed");
    require(bytes(_metadataURI).length > 0, "Metadata required");

    Application storage existing = applications[_scholarshipId][msg.sender];
    require(existing.applicant == address(0), "Already applied");

    applications[_scholarshipId][msg.sender] = Application({
        applicant: msg.sender,
        metadataURI: _metadataURI,
        status: ApplicationStatus.Pending
    });

    emit ApplicationSubmitted(_scholarshipId, msg.sender, _metadataURI);
}

Claiming Funds (Effects-Then-Interaction)

Only an approved applicant can claim. The contract uses the usual pattern: update state first (set amount to 0, status to Claimed), then perform the transfer. This guards against reentrancy.

function claimFunds(uint256 _scholarshipId) external {
    Scholarship storage scholarship = scholarships[_scholarshipId];
    Application storage app = applications[_scholarshipId][msg.sender];

    require(app.status == ApplicationStatus.Approved, "Not approved");
    require(scholarship.amount > 0, "Funds already claimed");

    uint256 payout = scholarship.amount;

    // Effects first
    scholarship.amount = 0;
    app.status = ApplicationStatus.Claimed;

    // Then interaction
    (bool success, ) = payable(msg.sender).call{value: payout}("");
    require(success, "ETH transfer failed");

    emit FundsClaimed(_scholarshipId, msg.sender, payout);
}

Events (ScholarshipCreated, ApplicationSubmitted, ApplicationApproved, ApplicationRejected, FundsClaimed) are used by the frontend to list applications and show history.


Deployment (Conflux eSpace)

File: scripts/deploy-conflux.js

Deployment uses Hardhat and targets the Conflux eSpace Testnet network defined in hardhat.config.js. The deployer is taken from PRIVATE_KEY in .env.

async function main() {
  const [deployer] = await ethers.getSigners();
  // ...
  const OnChainScholarship = await ethers.getContractFactory('OnChainScholarship');
  const contract = await OnChainScholarship.deploy();
  await contract.waitForDeployment();

  const contractAddress = await contract.getAddress();
  console.log('Contract address:', contractAddress);
  // Add NEXT_PUBLIC_CONTRACT_ADDRESS=<address> to .env
}

Commands:

  • Compile: npm run compile (or npx hardhat compile)
  • Deploy: npm run deploy:conflux (or npx hardhat run scripts/deploy-conflux.js --network confluxEspaceTestnet)

After deployment, set NEXT_PUBLIC_CONTRACT_ADDRESS in .env so the app uses the new contract.


Frontend: Chain & Contract Setup

File: lib/wagmi.ts

The app connects to Conflux eSpace Testnet via wagmi. RainbowKit uses this config for wallet connection and chain switching.

import { getDefaultConfig } from '@rainbow-me/rainbowkit'
import { confluxESpaceTestnet } from 'wagmi/chains'
import { http } from 'wagmi'

export const config = getDefaultConfig({
  appName: 'OnChain Scholarship',
  projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '...',
  chains: [confluxESpaceTestnet],
  transports: {
    [confluxESpaceTestnet.id]: http(process.env.NEXT_PUBLIC_CONFLUX_ESPACE_RPC_URL),
  },
  ssr: false,
})

File: lib/contract.ts

A single place for the contract address (from env) and the ABI. All useReadContract / useWriteContract calls use CONTRACT_ADDRESS and CONTRACT_ABI from here.


Key Frontend Flows & Code

1. Admin: Create Scholarship (Payable)

File: app/admin/create/page.tsx

The form collects title, description, and amount. The amount is converted to wei and sent as value in the same transaction as createScholarship. That’s how the scholarship gets funded.

const amountInWei = parseEther(amount)

writeContract({
  address: CONTRACT_ADDRESS,
  abi: CONTRACT_ABI,
  functionName: 'createScholarship',
  args: [title, description],
  value: amountInWei,  // payable: this funds the scholarship
})

2. Student: Submit Application

File: app/scholarships/[id]/apply/page.tsx

The app builds a metadata object (e.g. name, email, essay), encodes it as a data URI (or you could use an IPFS URI), and calls submitApplication. The contract stores the URI and sets status to Pending.

const metadata = { applicantName: name, email, essay, timestamp: Date.now() }
const metadataURI = `data:application/json,${encodeURIComponent(JSON.stringify(metadata))}`

writeContract({
  address: CONTRACT_ADDRESS,
  abi: CONTRACT_ABI,
  functionName: 'submitApplication',
  args: [BigInt(scholarshipId), metadataURI],
})

3. Admin: List Applications (Events + Status)

File: app/admin/applications/[id]/page.tsx

Applications are discovered by querying ApplicationSubmitted events for the given scholarship, then the current status is read from the contract for each applicant. This combines event history with up-to-date state.

const logs = await publicClient.getLogs({
  address: CONTRACT_ADDRESS,
  event: {
    type: 'event',
    name: 'ApplicationSubmitted',
    inputs: [
      { type: 'uint256', indexed: true, name: 'scholarshipId' },
      { type: 'address', indexed: true, name: 'applicant' },
      { type: 'string', indexed: false, name: 'metadataURI' },
    ],
  },
  args: { scholarshipId: BigInt(scholarshipId) },
  fromBlock: 0n,
  toBlock: 'latest',
})

const applicationsWithStatus = await Promise.all(
  logs.map(async (log) => {
    const applicant = log.args.applicant
    const metadataURI = log.args.metadataURI
    const status = await publicClient.readContract({
      address: CONTRACT_ADDRESS,
      abi: CONTRACT_ABI,
      functionName: 'getApplicationStatus',
      args: [BigInt(scholarshipId), applicant],
    })
    return { applicant, metadataURI, status }
  })
)

4. Admin: Approve or Reject

File: app/admin/applications/[id]/page.tsx (ApplicationItem)

For each pending application, the admin can call approveApplication or rejectApplication with the scholarship id and applicant address.

const handleApprove = () => {
  approve({
    address: CONTRACT_ADDRESS,
    abi: CONTRACT_ABI,
    functionName: 'approveApplication',
    args: [BigInt(scholarshipId), application.applicant],
  })
}

const handleReject = () => {
  reject({
    address: CONTRACT_ADDRESS,
    abi: CONTRACT_ABI,
    functionName: 'rejectApplication',
    args: [BigInt(scholarshipId), application.applicant],
  })
}

5. Student: Claim Funds

File: app/scholarships/[id]/claim/page.tsx

Once the application is Approved and the scholarship still has an amount, the applicant calls claimFunds. The contract sends the stored amount to msg.sender and marks the application as Claimed.

writeContract({
  address: CONTRACT_ADDRESS,
  abi: CONTRACT_ABI,
  functionName: 'claimFunds',
  args: [BigInt(scholarshipId)],
})

Admin Access Control

File: lib/hooks/use-is-admin.ts

The app treats the connected wallet as admin only if it matches the contract’s admin() address. That’s the deployer address after running deploy-conflux.js.

const { data: adminAddress } = useReadContract({
  address: CONTRACT_ADDRESS,
  abi: CONTRACT_ABI,
  functionName: 'admin',
})

const isAdmin = address && adminAddress &&
  address.toLowerCase() === adminAddress.toLowerCase()

File: components/admin-guard.tsx

Admin-only pages (e.g. dashboard, create scholarship, applications list) are wrapped in AdminGuard, which shows “Connect Wallet” or “Access Denied” when the user is not the admin. Only then are the admin UI and approve/reject actions available.


How to Run & Deploy

  1. Install: npm install (or pnpm install)
  2. Env: Copy .env.example to .env, set PRIVATE_KEY (and optionally RPC URL). After deployment, set NEXT_PUBLIC_CONTRACT_ADDRESS.
  3. Compile: npm run compile
  4. Deploy: npm run deploy:conflux (ensure the deployer wallet has testnet CFX, e.g. from Conflux eSpace Faucet)
  5. App: npm run dev — open the app, connect wallet on Conflux eSpace Testnet, use Admin (deployer) or Student flows as above.

Summary

  • Contract (contracts/scholarship.sol): Holds funds, tracks scholarships and applications, enforces admin-only actions and approve-then-claim flow. Uses events for indexing and effects-then-interaction for claimFunds.
  • Deploy (scripts/deploy-conflux.js): One-command deploy to Conflux eSpace Testnet; set the printed address in .env.
  • Frontend: Wagmi + Conflux eSpace in lib/wagmi.ts, shared contract config in lib/contract.ts. Admin creates (payable) and approves/rejects; students apply and claim. Applications list is built from ApplicationSubmitted events plus getApplicationStatus reads. Admin UI is protected by useIsAdmin and AdminGuard.

Together, this gives a full on-chain scholarship flow on Conflux eSpace with clear separation between contract logic, deployment, and frontend integration—suitable for a demo or a portfolio walkthrough.