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:
Pending→Approved/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(ornpx hardhat compile) - Deploy:
npm run deploy:conflux(ornpx 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
-
Install:
npm install(orpnpm install) -
Env: Copy
.env.exampleto.env, setPRIVATE_KEY(and optionally RPC URL). After deployment, setNEXT_PUBLIC_CONTRACT_ADDRESS. -
Compile:
npm run compile -
Deploy:
npm run deploy:conflux(ensure the deployer wallet has testnet CFX, e.g. from Conflux eSpace Faucet) -
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 forclaimFunds. -
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 inlib/contract.ts. Admin creates (payable) and approves/rejects; students apply and claim. Applications list is built fromApplicationSubmittedevents plusgetApplicationStatusreads. Admin UI is protected byuseIsAdminandAdminGuard.
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.