Build a Revolving Group Savings Smart Contract on Conflux

Build a Revolving Group Savings Smart Contract on Conflux

Learn how to create and deploy an on-chain group savings (ROSCA) contract where members deposit CFX every round, and a random unused winner receives the full pool — until everyone has won once.

Introduction

Traditional group savings circles work like this: a fixed set of people contribute money every month, and each month one person receives the full collected amount. By the end of the cycle, everyone has contributed the same total and everyone has received the pool once.

This project brings that model on-chain using Solidity and Conflux. The contract manages membership, deposits, round timing, winner selection, and payouts without needing a trusted middleman to hold funds.

In a short demo, you can:

  • Deploy the contract with a contribution amount and round duration
  • Join with multiple wallets
  • Deposit CFX into a shared pool
  • Select a random winner who receives the full pool
  • Repeat until every member has won once

How the System Works

The flow is simple:

  1. Deploy — Organizer deploys GrpSaving with contribution amount and duration
  2. Join — Members call join() until the group is full
  3. Start — Organizer calls startGroup()
  4. Deposit — Every member deposits the fixed CFX amount for the current round
  5. Select Winner — After deposits are complete and the round time has passed, anyone can call selectWinner()
  6. Repeat — The next round starts automatically until all members have won once

Because the winner can only be a member who has not won before, the system guarantees that every participant eventually receives the pool.

Core State Variables

These variables store the group configuration and runtime progress:

uint256 public constant MAX_MEMBERS = 2;

address public immutable organizer;
uint256 public immutable contributionAmount;
uint256 public immutable roundDuration;

address[] public members;
mapping(address => bool) public isMember;
mapping(address => bool) public hasWon;

uint256 public currentRound;
uint256 public roundStartTime;
uint256 public poolBalance;
uint256 public winnersCount;

bool public started;
bool public completed;

mapping(uint256 => mapping(address => bool)) public hasDeposited;

Why this matters:

  • MAX_MEMBERS defines group size (set to 2 for easy demos; raise it for production-style groups)
  • organizer, contributionAmount, and roundDuration are immutable so the rules cannot change after deployment
  • isMember and hasWon give fast lookups for access control and eligibility
  • hasDeposited[round][member] tracks who paid in each round
  • poolBalance stores the amount ready to pay the winner
  • started / completed prevent invalid actions outside the active lifecycle

Constructor: Creating the Group

Deploying the contract is what creates a new savings group.

constructor(uint256 _contributionAmount, uint256 _roundDurationSeconds) {
    require(_contributionAmount > 0, "invalid contribution");
    require(_roundDurationSeconds > 0, "invalid duration");
    organizer = msg.sender;
    contributionAmount = _contributionAmount;
    roundDuration = _roundDurationSeconds;
}

Explanation:

  • _contributionAmount is the exact CFX amount (in wei) each member must deposit every round
  • _roundDurationSeconds is how long each round lasts (for demos, use 60; for monthly rounds, use 2592000)
  • msg.sender becomes the organizer and is later allowed to call startGroup()

Example Remix deploy values for a quick test:

1000000000000000000, 60

That means 1 CFX contribution and a 60-second round.

Join: Adding Members

function join() external {
    if (started) revert GroupAlreadyStarted();
    if (isMember[msg.sender]) revert AlreadyMember();
    if (members.length >= MAX_MEMBERS) revert GroupFull();

    isMember[msg.sender] = true;
    members.push(msg.sender);

    emit MemberJoined(msg.sender, members.length);
}

Explanation:

  • Anyone can join before the group starts
  • Duplicate joins are blocked
  • Once the member list reaches MAX_MEMBERS, new joins are rejected
  • A MemberJoined event helps you verify joins in Remix or a block explorer

Start Group: Opening Round 1

function startGroup() external onlyOrganizer {
    if (started) revert GroupAlreadyStarted();
    require(members.length == MAX_MEMBERS, "need 10 members");

    started = true;
    currentRound = 1;
    roundStartTime = block.timestamp;

    emit RoundStarted(currentRound, roundStartTime);
}

Explanation:

  • Only the deployer (organizer) can start the group
  • The group must be full before Round 1 begins
  • roundStartTime becomes the timestamp used to enforce waiting until the round duration ends

The onlyOrganizer modifier is a small but important access-control pattern:

modifier onlyOrganizer() {
    if (msg.sender != organizer) revert OnlyOrganizer();
    _;
}

Deposit: Filling the Shared Pool

function deposit() external payable {
    if (!started) revert GroupNotStarted();
    if (completed) revert GroupAlreadyCompleted();
    if (!isMember[msg.sender]) revert NotMember();
    if (hasDeposited[currentRound][msg.sender]) revert AlreadyDeposited();
    if (msg.value != contributionAmount) revert IncorrectAmount();

    hasDeposited[currentRound][msg.sender] = true;
    poolBalance += msg.value;

    emit Deposited(msg.sender, currentRound, msg.value);
}

Explanation:

  • The function is payable, so CFX can be attached to the call
  • Only joined members can deposit
  • Each member can deposit only once per round
  • The sent value must exactly match contributionAmount
  • Successful deposits increase poolBalance

In Remix, remember to set the Value field to the contribution amount before calling deposit().

Select Winner: Paying Out the Pool

This is the most important function in the contract.

function selectWinner() external {
    if (!started) revert GroupNotStarted();
    if (completed) revert GroupAlreadyCompleted();
    if (!_allDeposited()) revert NotAllDeposited();
    if (block.timestamp < roundStartTime + roundDuration) revert RoundNotOver();

    address winner = _pickRandomEligibleWinner();
    uint256 payout = poolBalance;
    poolBalance = 0;
    hasWon[winner] = true;
    winnersCount += 1;

    (bool ok, ) = payable(winner).call{value: payout}("");
    if (!ok) revert TransferFailed();

    emit WinnerSelected(winner, currentRound, payout);

    if (winnersCount == MAX_MEMBERS) {
        completed = true;
        emit GroupCompleted(currentRound);
        return;
    }

    currentRound += 1;
    roundStartTime = block.timestamp;
    emit RoundStarted(currentRound, roundStartTime);
}

Explanation:

  • The call fails unless every member has deposited
  • The round duration must also have passed
  • An eligible winner is chosen randomly from members who have never won
  • The full pool is paid out in one transfer
  • If all members have already won once, the group is marked completed
  • Otherwise, a new round starts automatically

Setting poolBalance = 0 before the transfer is a safe pattern that avoids reentrancy issues during payout.

Random Winner Selection

Eligible members are those who have not won yet:

function eligibleWinners() public view returns (address[] memory) {
    uint256 n;
    for (uint256 i = 0; i < members.length; i++) {
        if (!hasWon[members[i]]) n++;
    }

    address[] memory list = new address[](n);
    uint256 j;
    for (uint256 i = 0; i < members.length; i++) {
        if (!hasWon[members[i]]) {
            list[j] = members[i];
            j++;
        }
    }
    return list;
}

Then one of them is selected using on-chain entropy:

function _pickRandomEligibleWinner() internal view returns (address) {
    address[] memory eligible = eligibleWinners();
    require(eligible.length > 0, "no eligible winners");

    uint256 entropy = uint256(
        keccak256(
            abi.encodePacked(
                block.prevrandao,
                block.timestamp,
                block.number,
                currentRound,
                poolBalance,
                msg.sender
            )
        )
    );
    return eligible[entropy % eligible.length];
}

Explanation:

  • Previous winners are excluded, so the same address cannot win twice
  • Entropy is built from block data and round state
  • This is good enough for demos and learning projects
  • For production use with real money, prefer a VRF oracle for stronger randomness

Events and Error Handling

Events make the contract easy to debug and demo:

event MemberJoined(address indexed member, uint256 memberCount);
event RoundStarted(uint256 indexed round, uint256 startTime);
event Deposited(address indexed member, uint256 indexed round, uint256 amount);
event WinnerSelected(address indexed winner, uint256 indexed round, uint256 amount);
event GroupCompleted(uint256 totalRounds);

Custom errors keep failure reasons clear and gas-efficient:

error AlreadyMember();
error GroupFull();
error NotAllDeposited();
error RoundNotOver();
error OnlyOrganizer();

When something fails in Remix, these names help you quickly understand whether the issue is membership, timing, deposit status, or permissions.

Deploy and Demo Checklist

Use this flow when recording or testing:

  1. Compile grpsaving.sol with Solidity 0.8.20+
  2. Deploy with 1 CFX and 60 seconds
  3. Call join() from Wallet A
  4. Call join() from Wallet B
  5. Switch to the deployer and call startGroup()
  6. Deposit 1 CFX from each member
  7. Wait for the round duration
  8. Call selectWinner()
  9. Verify the WinnerSelected event and the winner’s balance increase

Helpful read-only checks:

  • memberCount()
  • getMembers()
  • poolBalance()
  • currentRound()
  • hasWon(address)
  • completed()

Resources

Demo Video: https://youtu.be/FVQWH31n8CA
GitHub repo: https://github.com/Vikash-8090-Yadav/cfxGroupSaving

Conclusion

GrpSaving is a practical example of an on-chain revolving savings group. It shows how Solidity can enforce membership rules, collect deposits into a shared pool, randomly pay one unused winner each round, and continue until every participant has received funds once.

By deploying this on Conflux with Remix and MetaMask, you get a complete beginner-friendly path from contract idea to live demo: deploy, join, deposit, and payout.

If you extend this project further, good next steps are a frontend UI, multi-group support, and production-grade randomness.