Building an On-Chain Scholarship and Grant Distribution System on Conflux eSpace

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:

  • :white_check_mark: Full Transparency: All applications and approvals are on-chain and verifiable
  • :white_check_mark: Trustless System: No need to trust intermediaries
  • :white_check_mark: Automated Distribution: Funds are transferred automatically upon approval
  • :white_check_mark: Cost-Effective: Minimal gas fees compared to traditional systems
  • :white_check_mark: 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:

  1. Scholarships: Created by admin with funding
  2. Applications: Submitted by applicants for specific scholarships
  3. 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:

  • ApplicationStatus enum ensures type safety and prevents invalid states
  • metadataURI allows storing application details off-chain (IPFS/Arweave) while keeping contract gas-efficient
  • isActive flag 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
  • onlyAdmin modifier ensures critical functions are protected
  • scholarshipCount auto-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:

  • payable modifier: 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 calldata for 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:

  1. Checks: Validate application status and fund availability
  2. Effects: Update state BEFORE external call (prevents reentrancy)
  3. Interactions: Transfer funds to applicant

Why This Matters:

  • Setting amount = 0 before transfer prevents reentrancy attacks
  • Using call instead of transfer is more gas-efficient and future-proof
  • Status update to Claimed prevents 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.20 or 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/

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 admin address matches your wallet
  • Confirm scholarshipCount is 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
  • scholarshipCount becomes 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 uint128 instead of uint256 if 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

  1. University Scholarships: Transparent distribution of academic grants
  2. Research Grants: On-chain tracking of research funding
  3. Community Grants: Decentralized community funding programs
  4. Startup Grants: Transparent startup funding distribution

Potential Extensions

  1. Multi-Token Support: Accept ERC-20 tokens in addition to native CFX
  2. Partial Payments: Allow splitting scholarships among multiple recipients
  3. Voting Mechanism: Decentralized approval through DAO voting
  4. Time-Locked Claims: Add vesting schedules for large grants
  5. Application Fees: Optional fees to prevent spam applications
  6. Merit Scoring: On-chain scoring system for applications
  7. 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

:x: Don’t Transfer Before State Update

// ❌ WRONG - Vulnerable to reentrancy
(bool success, ) = payable(msg.sender).call{value: payout}("");
scholarship.amount = 0; // Too late!

:x: Don’t Allow State Transitions from Any State

// ❌ WRONG - Allows invalid transitions
app.status = ApplicationStatus.Approved; // Even if already claimed!

:x: Don’t Skip Input Validation

// ❌ WRONG - No validation
scholarships[scholarshipCount] = Scholarship({...}); // Could be empty!

Resources


Built with :heart: on Conflux eSpace

  • Always audit your smart contracts before deploying to mainnet.*