A comprehensive guide to building a transparent, trustless scholarship and grant distribution system using Solidity smart contracts.
Video Tutorial: Watch the full tutorial here
GitHub Repository: View the source code
Project Overview
The On-Chain Scholarship System is a decentralized application that enables transparent and automated distribution of scholarships and grants. Built on Conflux eSpace, it eliminates intermediaries and ensures that funds are distributed fairly and securely through smart contract logic.
Why On-Chain Scholarships?
Traditional scholarship distribution systems face several challenges:
- Lack of Transparency: Applicants can’t verify the fairness of the selection process
- Centralized Control: Single points of failure and potential for manipulation
- Delayed Payments: Manual processing leads to delays
- High Operational Costs: Administrative overhead reduces available funds
Our smart contract solution addresses these issues by:
-
Full Transparency: All applications and approvals are on-chain and verifiable -
Trustless System: No need to trust intermediaries -
Automated Distribution: Funds are transferred automatically upon approval -
Cost-Effective: Minimal gas fees compared to traditional systems -
Immutable Records: All transactions are permanently recorded on the blockchain
Architecture & Design
System Flow
┌─────────────┐
│ Admin │ Creates Scholarship & Funds Contract
└──────┬──────┘
│
▼
┌─────────────────┐
│ Smart Contract │ Stores Scholarship Details
└──────┬──────────┘
│
▼
┌─────────────┐
│ Applicants │ Submit Applications
└──────┬──────┘
│
▼
┌─────────────┐
│ Admin │ Reviews & Approves Applications
└──────┬──────┘
│
▼
┌─────────────┐
│ Applicants │ Claim Approved Funds
└──────┬──────┘
│
▼
┌─────────────┐
│ Funds │ Automatically Transferred
└─────────────┘
State Management
The contract manages three main entities:
- Scholarships: Created by admin with funding
- Applications: Submitted by applicants for specific scholarships
- Application Status: Tracks the lifecycle (Pending → Approved/Rejected → Claimed)
Smart Contract Deep Dive
Core Data Structures
The contract uses well-defined structures to manage state:
enum ApplicationStatus {
Pending,
Approved,
Rejected,
Claimed
}
struct Scholarship {
uint256 id;
string title;
string description;
uint256 amount;
bool isActive;
}
struct Application {
address applicant;
string metadataURI; // IPFS / Arweave
ApplicationStatus status;
}
Key Design Decisions:
-
ApplicationStatusenum ensures type safety and prevents invalid states -
metadataURIallows storing application details off-chain (IPFS/Arweave) while keeping contract gas-efficient -
isActiveflag enables closing scholarships without deleting data
Storage Mappings
// scholarshipId => Scholarship
mapping(uint256 => Scholarship) public scholarships;
// scholarshipId => applicant => Application
mapping(uint256 => mapping(address => Application)) public applications;
The nested mapping structure allows efficient lookup of applications by both scholarship ID and applicant address.
Key Features
1. Admin-Controlled Scholarship Creation
Only the contract admin can create scholarships and fund them in a single transaction.
2. One Application Per User
Each applicant can only submit one application per scholarship, preventing spam.
3. State-Based Application Management
Applications can only transition from Pending to Approved/Rejected, ensuring data integrity.
4. Secure Fund Distribution
Only approved applicants can claim funds, and each scholarship can only be claimed once.
5. Transparent On-Chain Records
All events are emitted and permanently stored on the blockchain.
Code Walkthrough
1. Contract Initialization & Access Control
address public admin;
uint256 public scholarshipCount;
constructor() {
admin = msg.sender;
}
modifier onlyAdmin() {
require(msg.sender == admin, "Not admin");
_;
}
Why This Matters:
- The deployer automatically becomes the admin
-
onlyAdminmodifier ensures critical functions are protected -
scholarshipCountauto-increments, providing unique IDs
2. Creating Scholarships
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++;
}
Key Features:
-
payablemodifier: Allows receiving CFX along with the transaction - Funding validation: Ensures scholarship is funded at creation
- Event emission: Provides transparency for off-chain indexing
- Atomic operation: Creation and funding happen in one transaction
3. Submitting Applications
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);
}
Security Checks:
- Verifies scholarship is active (prevents applications to closed scholarships)
- Validates metadata URI is provided
- Prevents duplicate applications (checks if applicant address exists)
- Uses
calldatafor string parameters to save gas
Why metadataURI?
Instead of storing full application details on-chain (expensive), we store a URI pointing to IPFS or Arweave where the actual application data resides. This keeps gas costs low while maintaining data availability.
4. Approving Applications
function approveApplication(
uint256 _scholarshipId,
address _applicant
) external onlyAdmin {
Application storage app = applications[_scholarshipId][_applicant];
require(app.applicant != address(0), "No application");
require(app.status == ApplicationStatus.Pending, "Invalid state");
app.status = ApplicationStatus.Approved;
emit ApplicationApproved(_scholarshipId, _applicant);
}
State Transition Logic:
- Only pending applications can be approved
- Prevents invalid state transitions (e.g., approving already approved applications)
- Admin-only function ensures controlled approval process
5. Claiming Funds (The Critical Function)
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
scholarship.amount = 0;
app.status = ApplicationStatus.Claimed;
// Interaction (safe & future-proof)
(bool success, ) = payable(msg.sender).call{value: payout}("");
require(success, "ETH transfer failed");
emit FundsClaimed(_scholarshipId, msg.sender, payout);
}
Security Pattern: Checks-Effects-Interactions
This function follows the critical security pattern:
- Checks: Validate application status and fund availability
- Effects: Update state BEFORE external call (prevents reentrancy)
- Interactions: Transfer funds to applicant
Why This Matters:
- Setting
amount = 0before transfer prevents reentrancy attacks - Using
callinstead oftransferis more gas-efficient and future-proof - Status update to
Claimedprevents double-claiming - Only the approved applicant can call this function (enforced by
msg.sender)
6. View Functions
function getApplicationStatus(
uint256 _scholarshipId,
address _applicant
) external view returns (ApplicationStatus) {
return applications[_scholarshipId][_applicant].status;
}
function getScholarship(
uint256 _scholarshipId
) external view returns (Scholarship memory) {
return scholarships[_scholarshipId];
}
These read-only functions allow anyone to query application status and scholarship details, ensuring full transparency.
Security Considerations
1. Reentrancy Protection
The claimFunds function uses the Checks-Effects-Interactions pattern:
// ✅ CORRECT: Update state before external call
scholarship.amount = 0;
app.status = ApplicationStatus.Claimed;
(bool success, ) = payable(msg.sender).call{value: payout}("");
This prevents reentrancy attacks where a malicious contract could call claimFunds multiple times before the state is updated.
2. Access Control
Critical functions are protected by the onlyAdmin modifier:
modifier onlyAdmin() {
require(msg.sender == admin, "Not admin");
_;
}
3. State Validation
Multiple require statements ensure valid state transitions:
require(app.status == ApplicationStatus.Pending, "Invalid state");
require(scholarship.amount > 0, "Funds already claimed");
4. Input Validation
All inputs are validated before processing:
require(msg.value > 0, "Funding required");
require(bytes(_metadataURI).length > 0, "Metadata required");
require(existing.applicant == address(0), "Already applied");
5. Safe Transfer Pattern
Using call with proper error handling:
(bool success, ) = payable(msg.sender).call{value: payout}("");
require(success, "ETH transfer failed");
Deployment Guide
Prerequisites
- MetaMask or compatible Web3 wallet
- Conflux eSpace Testnet CFX (get from faucet)
- Remix IDE (or Hardhat/Truffle)
Step-by-Step Deployment
1. Prepare the Contract
Copy the contract code into Remix IDE or your preferred development environment.
2. Compile
- Select Solidity compiler version
0.8.20or compatible - Enable optimization (200 runs recommended)
- Compile the contract
3. Configure Network
In MetaMask:
- Add Conflux eSpace Testnet:
- Network Name:
Conflux eSpace Testnet - RPC URL:
https://evmtestnet.confluxrpc.com - Chain ID:
71 - Currency Symbol:
CFX - Block Explorer:
https://evmtestnet.confluxscan.net/
- Network Name:
4. Deploy
- In Remix, select “Injected Provider - MetaMask”
- Click “Deploy”
- Confirm transaction in MetaMask
- Copy the deployed contract address
5. Verify Deployment
- Check contract balance (should be 0 initially)
- Verify
adminaddress matches your wallet - Confirm
scholarshipCountis 0
Testing the Contract
Create a Scholarship
// In Remix, call createScholarship with:
// _title: "Conflux Grant"
// _description: "Testing scholarship"
// Value: 4 CFX (or any amount)
Expected Result:
- Contract balance increases by the sent amount
-
scholarshipCountbecomes 1 - Scholarship details are stored
Submit an Application
// Call submitApplication with:
// _scholarshipId: 0
// _metadataURI: "ipfs://QmYourHashHere" or any string
Expected Result:
- Application is stored with status
Pending - Cannot submit duplicate application
Approve Application
// As admin, call approveApplication with:
// _scholarshipId: 0
// _applicant: <applicant address>
Expected Result:
- Application status changes to
Approved - Event is emitted
Claim Funds
// As approved applicant, call claimFunds with:
// _scholarshipId: 0
Expected Result:
- Funds are transferred to applicant
- Contract balance decreases
- Application status becomes
Claimed - Cannot claim again
Event System
The contract emits events for all major actions, enabling off-chain indexing and monitoring:
event ScholarshipCreated(
uint256 indexed scholarshipId,
string title,
uint256 amount
);
event ApplicationSubmitted(
uint256 indexed scholarshipId,
address indexed applicant,
string metadataURI
);
event ApplicationApproved(
uint256 indexed scholarshipId,
address indexed applicant
);
event FundsClaimed(
uint256 indexed scholarshipId,
address indexed applicant,
uint256 amount
);
Benefits:
- Indexing: Frontend applications can listen to events
- Transparency: All actions are publicly verifiable
- Analytics: Track scholarship distribution patterns
- Notifications: Alert users of status changes
Gas Optimization Tips
1. Use calldata for String Parameters
function submitApplication(
uint256 _scholarshipId,
string calldata _metadataURI // ✅ calldata instead of memory
) external
Saves gas by avoiding copying data to memory.
2. Pack Structs Efficiently
The current struct layout is already optimized, but consider:
- Using
uint128instead ofuint256if amounts are small - Packing boolean flags with other small types
3. Batch Operations
Consider adding batch functions for admin operations:
function approveMultipleApplications(
uint256 _scholarshipId,
address[] calldata _applicants
) external onlyAdmin {
for (uint i = 0; i < _applicants.length; i++) {
// Approve logic
}
}
Use Cases & Extensions
Current Use Cases
- University Scholarships: Transparent distribution of academic grants
- Research Grants: On-chain tracking of research funding
- Community Grants: Decentralized community funding programs
- Startup Grants: Transparent startup funding distribution
Potential Extensions
- Multi-Token Support: Accept ERC-20 tokens in addition to native CFX
- Partial Payments: Allow splitting scholarships among multiple recipients
- Voting Mechanism: Decentralized approval through DAO voting
- Time-Locked Claims: Add vesting schedules for large grants
- Application Fees: Optional fees to prevent spam applications
- Merit Scoring: On-chain scoring system for applications
- Multi-Admin Support: Role-based access control for multiple admins
Best Practices Demonstrated
1. Clear State Management
Using enums and structs makes the contract state explicit and type-safe.
2. Comprehensive Events
All state changes emit events for transparency and off-chain integration.
3. Input Validation
Every function validates inputs before processing.
4. Access Control
Critical functions are protected with modifiers.
5. Reentrancy Protection
Following Checks-Effects-Interactions pattern prevents reentrancy attacks.
6. Gas Efficiency
Using calldata, efficient storage patterns, and minimal external calls.
Common Pitfalls to Avoid
Don’t Transfer Before State Update
// ❌ WRONG - Vulnerable to reentrancy
(bool success, ) = payable(msg.sender).call{value: payout}("");
scholarship.amount = 0; // Too late!
Don’t Allow State Transitions from Any State
// ❌ WRONG - Allows invalid transitions
app.status = ApplicationStatus.Approved; // Even if already claimed!
Don’t Skip Input Validation
// ❌ WRONG - No validation
scholarships[scholarshipCount] = Scholarship({...}); // Could be empty!
Resources
- Video Tutorial: Watch the full tutorial here
- GitHub Repository: View the source code
- Conflux Documentation: https://docs.confluxnetwork.org/
- Remix IDE: https://remix.ethereum.org/
- Conflux Faucet: https://faucet.confluxnetwork.org/
Built with
on Conflux eSpace
- Always audit your smart contracts before deploying to mainnet.*
Full Transparency: All applications and approvals are on-chain and verifiable
Don’t Transfer Before State Update