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:
-
Deploy — Organizer deploys
GrpSavingwith contribution amount and duration -
Join — Members call
join()until the group is full -
Start — Organizer calls
startGroup() - Deposit — Every member deposits the fixed CFX amount for the current round
-
Select Winner — After deposits are complete and the round time has passed, anyone can call
selectWinner() - 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_MEMBERSdefines group size (set to2for easy demos; raise it for production-style groups) -
organizer,contributionAmount, androundDurationare immutable so the rules cannot change after deployment -
isMemberandhasWongive fast lookups for access control and eligibility -
hasDeposited[round][member]tracks who paid in each round -
poolBalancestores the amount ready to pay the winner -
started/completedprevent 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:
-
_contributionAmountis the exact CFX amount (in wei) each member must deposit every round -
_roundDurationSecondsis how long each round lasts (for demos, use60; for monthly rounds, use2592000) -
msg.senderbecomes the organizer and is later allowed to callstartGroup()
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
MemberJoinedevent 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
-
roundStartTimebecomes 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:
- Compile
grpsaving.solwith Solidity0.8.20+ - Deploy with
1 CFXand60seconds - Call
join()from Wallet A - Call
join()from Wallet B - Switch to the deployer and call
startGroup() - Deposit
1 CFXfrom each member - Wait for the round duration
- Call
selectWinner() - Verify the
WinnerSelectedevent 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.