Crypto-Chama: The SavingsPool Smart Contract

Ibrahim Aziz
Coinmonks
9 min readSep 28, 2023

--

The main smart contract in the Crypto-Chama Project

Welcome back to the Crypto-Chama series, where we continue our exploration of blockchain-based savings pools. In this article, we dive into the heart of our project by examining the SavingsPool contract, a vital piece of the Crypto-Chama puzzle.

Overview of the Project Here

Part 1 of the project (The Management Smart Contract) Here

Introduction

The SavingsPool contract is the beating heart of Crypto-Chama, enabling users to participate in decentralized savings and investment pools. Let’s unravel its functionalities and inner workings.

Functionalities of the Smart Contract

The SavingsPool contract is a robust piece of technology, offering a wide array of features:

1. Creating Pools: Users can create savings pools by specifying parameters such as the maximum number of participants, contribution amount, turn duration, and whether the pool is restricted. This flexibility allows for a variety of savings pool configurations.

2. Joining Pools: Individuals can join existing savings pools, provided there is available space and they are not blacklisted. In restricted pools, only addresses approved by the pool owner can participate.

3. Contributing to Pools: Participants can contribute to the pool during their turn. Contributions are held in the pool until it’s time to claim.

4. Claiming Turns: Participants can claim their turns in the pool, receiving their share of the accumulated funds. If a participant’s deposit is insufficient, it is replenished from their claim.

5. Pool Management: The contract includes functions for pool owners to destroy pools if they are not yet active. When pools are closed or ended, deposits are returned to participants.

Structs, Mappings, and Variables

Before we dive into the internal and external functions, let’s take a closer look at the foundational elements of the SavingsPool contract:

    ManagementContract mgmt; //We will be calling functions from the Management Contract
address public owner;
uint public poolCounter;

//Struct storing the pool Details
struct PoolDetails {
address owner;
address token;
uint maxParticipants;
uint contributionPerParticipant;
uint durationPerTurn;
uint startTime;
uint currentTurn;
address [] participants;
mapping (address => bool) hasReceived;
bool isActive;
bool isRestrictedPool;
}

//Struct storing the turn details within a pool
struct TurnDetails {
uint turnBal;
uint endTime;
address currentClaimant;
bool active;
bool claimable;
mapping (address => uint) turnContributions;
mapping (address => bool) hasContributed;
}

//poolID => Turn ID => TurnDetails
mapping (uint =>mapping(uint => TurnDetails)) public turn;

mapping (uint => PoolDetails) public pool;

//This Stores the deposit amounts of each user in the pool
mapping (uint => mapping(address=> uint)) public depositAmounts;

Structs:

  • PoolDetails: This struct stores critical information about each savings pool, including the owner, token address, maximum participants, contribution per participant, turn duration, start time, current turn, participants array, a mapping to track whether participants have received funds, and flags to indicate pool activity and restriction status.
  • TurnDetails: Within each pool, this struct manages turn-related details such as the turn balance, end time, the current claimant, and flags for activity and claimability. It also includes mappings to track turn-specific contributions and whether participants have contributed.

Mappings:

  • turn: This mapping associates each pool with turn details, allowing efficient tracking of multiple turns within a pool.
  • pool: Maps pool IDs to their respective PoolDetails, facilitating easy access to pool information.
  • depositAmounts: Stores deposit amounts for each user in each pool, ensuring accurate accounting of deposits.

Variables:

  • mgmt: An instance of the ManagementContract, facilitating interaction between the SavingsPool contract and the management component.
  • owner: The contract owner’s address.
  • poolCounter: A counter to keep track of the number of pools created.

Now that we have a clear understanding of these foundational elements, let’s explore the internal and external functions that leverage them to provide the contract’s powerful functionalities.

Internal & Public Functions

Let’s delve into the internal functions that power the SavingsPool contract:

  1. _checkParticipantCount(uint _id): This function provides the count of participants in a pool, allowing the contract to track pool capacity.

It is used within the Contract, but can also be used by users to check how many participants are in a given pool; hence set as public

function _checkParticipantCount(uint _id) public view returns (uint) {

uint count = pool[_id].participants.length;

return count;
}

2. _isParticipant(uint _poolID, address _address): It verifies whether an address is a participant in a specific pool, essential for various contract operations. Also set as public.

function _isParticipant(uint _poolID, address _address) public view returns(bool){
PoolDetails storage _pooldetails = pool[_poolID];
uint participants = _checkParticipantCount(_poolID);

for(uint i = 0; i<participants;){
address participant = _pooldetails.participants[i];
if(participant == _address){
return true;
} unchecked {
i++;
}

}
return false;

}

3. _contribute(uint _poolId, uint _turnId, uint _amount): Handles user contributions, ensuring that the correct amount is contributed and recorded for the turn.

function _contribute( uint _poolId,uint _turnId,uint _amount) internal {
address tknAddress = pool[_poolId].token;
IERC20 token = IERC20(tknAddress);
if(token.balanceOf(msg.sender)<_amount) revert("Not Enough Tokens For the Deposit");
if (token.allowance(msg.sender, address(this)) < _amount) {
require(token.approve(address(this), _amount), "Token approval failed");
}
token.transferFrom(msg.sender, address(this),_amount);

turn[_poolId][_turnId].turnBal+=_amount;
turn[_poolId][_turnId].turnContributions[msg.sender]=_amount;
turn[_poolId][_turnId].hasContributed[msg.sender] = true;
}

4. _setTurnDetails(uint _poolId): Sets the details for each turn within a pool, including the current claimant, end time, and activation status.

function _setTurnDetails(uint _poolId) internal {
//set the address to receive, endtime and activity
PoolDetails storage thisPool = pool[_poolId];
uint turnId = thisPool.currentTurn ;
uint timePerTurn = thisPool.durationPerTurn;
address turnBenefactor = thisPool.participants[turnId-1];

TurnDetails storage thisTurn = turn[_poolId][turnId];
thisTurn.currentClaimant = turnBenefactor;
thisTurn.endTime = block.timestamp+timePerTurn;
thisTurn.active = true;


}

5. _updateTurn(uint _poolId): Manages the progression of turns, updating the current turn and initiating a new one when necessary.

function _updateTurn(uint _poolId) internal {

PoolDetails storage thisPool = pool[_poolId];
address [] memory _addresses = thisPool.participants;
uint participantNo = _addresses.length;
uint turnId = thisPool.currentTurn;
if(turn[_poolId][turnId].endTime < block.timestamp){
for (uint i = 0; i < participantNo;) {
address current = _addresses[i];

//use deposit if they have the deposits
if(!turn[_poolId][turnId].hasContributed[current] &&
depositAmounts[_poolId][current]>= thisPool.contributionPerParticipant ){
_useDeposit(_poolId, turnId, current);
}else if(!turn[_poolId][turnId].hasContributed[current] &&
depositAmounts[_poolId][current] < thisPool.contributionPerParticipant){
//If both deposits are used, address is blacklisted from participating again
mgmt.blacklistAddress(current);
}
}
thisPool.currentTurn++;
_setTurnDetails(_poolId);
}

}

6. _useDeposit(uint _poolId, uint _turnId, address _address): Utilizes a participant's deposit as their contribution for a turn.

function _useDeposit(uint _poolId,uint _turnId, address _address) internal{
//A deposit is used as the contribution amount
uint _contributionAmt = pool[_poolId].contributionPerParticipant;
depositAmounts[_poolId][_address]-=_contributionAmt;
turn[_poolId][_turnId].turnBal+=_contributionAmt;

}

7. _returnDeposits(uint _poolID): Returns deposits to participants when a pool is closed or ended, ensuring fair distribution of funds.

function _returnDeposits(uint _poolID) internal {
uint _recipients = _checkParticipantCount(_poolID);
address tkn = pool[_poolID].token;
IERC20 token = IERC20(tkn);

for(uint i = 0; i < _recipients-1;){
address receiver = pool[_poolID].participants[i];
uint depositBal = depositAmounts[_poolID][receiver];
if(depositBal!=0){
token.transferFrom(address(this), receiver, depositBal);
}
unchecked {
i++;
}
}
}

8. _calculateDeposit(uint _amount): Calculates the required deposit amount based on the contribution amount.

function _calculateDeposit(uint _amount) internal pure returns (uint){
return _amount*2;
}

Internal functions in Solidity offer benefits like code organization and reusability, enhancing code readability, and ensuring secure, contract-only access. While they don’t inherently reduce gas spend, they indirectly optimize gas by preventing code duplication when used in various functions, which can lead to minor gas savings.

External Functions

External Functions External functions accessible by users:

  1. createPool(...): Allows users to create savings pools with customizable parameters.
  • User specifies essential pool parameters such as the token address, maximum number of participants, contribution amount, turn duration, and whether the pool is restricted.
  • The function calculates the required deposit based on the contribution amount and transfers it from the user’s wallet to the contract.
  • Pool details, including owner, contribution amount, max participants, duration per turn, token address, and others, are recorded.
  • The pool is initialized as inactive until the maximum number of participants is reached, triggering pool activation.
function createPool(address _tokenAddress, uint _maxParticipants, uint _contributionAmt,uint _durationPerTurn, bool _isRestricted) external {
require(_tokenAddress!=address(0), "Invalid token");
require (_maxParticipants!= 0,"Pool Needs a Valid number of Participants");
require (_contributionAmt!= 0, "Enter a valid Contribution Amount");
require (_durationPerTurn!= 0, "Enter a valid Duration");

uint poolID = ++poolCounter;
uint contributionInEth = _contributionAmt*10**18;
//Owner must send deposit equivalent to 2 contributions
IERC20 token = IERC20(_tokenAddress);
uint deposit = _calculateDeposit(contributionInEth);

if(token.balanceOf(msg.sender)<deposit) revert("Not Enough Tokens For the Deposit");
if (token.allowance(msg.sender, address(this)) < deposit) {
require(token.approve(address(this), deposit), "Token approval failed");
}
token.transferFrom(msg.sender, address(this),deposit);
depositAmounts[poolID][msg.sender] = deposit;

PoolDetails storage startPool = pool[poolID];
startPool.owner = msg.sender;
startPool.contributionPerParticipant = _contributionAmt *10**18;
startPool.maxParticipants = _maxParticipants;
startPool.durationPerTurn = _durationPerTurn;
startPool.token = _tokenAddress;
startPool.participants.push(msg.sender);
startPool.isRestrictedPool = _isRestricted;
startPool.isActive = false;
startPool.currentTurn = 0;

emit PoolStarted(poolID, msg.sender, _maxParticipants, _contributionAmt);
}

2. joinPool(uint _id): Enables users to join existing pools, subject to available space and blacklist status.

  • The function checks if the pool has reached its maximum participant count and if the user is blacklisted.
  • If the pool is restricted, it verifies if the user is approved by the pool owner (a “friendly”). This is set in the management contract previously.
  • Users must send the required deposit to join the pool, which is then recorded.
  • The user’s address is added to the pool’s list of participants.
  • If joining completes the participant count, the pool becomes active, and the first turn is initialized.
function joinPool(uint _id) external {
//Check that the pool is not full
uint maxPPL = pool[_id].maxParticipants;
require(_checkParticipantCount(_id)!= maxPPL, "Pool Filled");

//check if the address joining is blacklisted
require(!mgmt.isBlacklisted(msg.sender), "You are blacklisted from the pool for defaulting");

PoolDetails storage _joinpool =pool[_id];
uint deposit = _calculateDeposit(_joinpool.contributionPerParticipant);
address tknAddress = _joinpool.token;

//If the pool is restricted, Only addresses that are Friendlies of the owner can join the pool
if(_joinpool.isRestrictedPool){
address pOwner = _joinpool.owner;
bool status = mgmt._checkStatus(pOwner,msg.sender);
require(status == true, "You are not allowed in this pool");
}

IERC20 token = IERC20(tknAddress);

//Send the deposit
if(token.balanceOf(msg.sender)<deposit) revert("Not Enough Tokens For the Deposit");
if (token.allowance(msg.sender, address(this)) < deposit) {
require(token.approve(address(this), deposit), "Token approval failed");
}
token.transferFrom(msg.sender, address(this),deposit);
depositAmounts[_id][msg.sender] = deposit;


//Once Deposit is sent, add address to the array of participants
_joinpool.participants.push(msg.sender);

//Check if with this addition, pool is full. Start Pool if it is.
if(_joinpool.participants.length == maxPPL){
_joinpool.isActive = true;
_joinpool.startTime =block.timestamp;
_joinpool.currentTurn++;
_setTurnDetails(_id);

}

emit JoinedPool(_id, msg.sender);
}

3. contributeToPool(uint _poolID): Allows participants to contribute to the pool during their turn.

  • The function checks if the user is a participant in the pool and if they have already contributed to the current turn.
  • Users must send the specified contribution amount to participate.
  • The contribution is recorded for the current turn.
function contributeToPool(uint _poolID)  external {
require(_isParticipant(_poolID,msg.sender), "Not a participant in this pool");

uint _amount = pool[_poolID].contributionPerParticipant;

uint turnId = pool[_poolID].currentTurn;

require(turn[_poolID][turnId].hasContributed[msg.sender] == false, "User has already contributed to this turn");

_contribute(_poolID, turnId, _amount);

_updateTurn(_poolID);
}

4. claimTurn(uint _poolID): Permits participants to claim their turns and receive their share of the accumulated funds.

  • The function verifies that the user is a participant in the pool and that it’s their turn to claim.
  • If the user’s deposit is insufficient, it is replenished from their claim, ensuring they receive a full payout.
  • Any remaining tokens in the turn are transferred to the claimant, and the turn is marked as claimed.
  • If the claimant is the last recipient, the pool is set as inactive, and deposits are returned to participants.
function claimTurn(uint _poolID) external {
// Check that the msg sender is part of the pool
require(_isParticipant(_poolID, msg.sender), "Not a participant in this pool");

// Get the current turn in the pool
uint currentTurn = pool[_poolID].currentTurn;

// Check if the beneficiary of the turn is the msg sender
address beneficiary = turn[_poolID][currentTurn].currentClaimant;
require(beneficiary == msg.sender, "It's not your turn to claim");

// Check if there is a balance deposit, and refill it
uint deposit = depositAmounts[_poolID][msg.sender];
uint contributionAmt = pool[_poolID].contributionPerParticipant;
uint expectedDep = contributionAmt*2;
uint bal;

if(deposit<expectedDep){
// Refill the deposit with the claimant's amount
bal = expectedDep-deposit;
depositAmounts[_poolID][msg.sender] += bal;
turn[_poolID][currentTurn].turnBal -= bal;
}

// Send remaining tokens to msg sender
address tokenAddress = pool[_poolID].token;
IERC20 token = IERC20(tokenAddress);
uint remainingTokens = turn[_poolID][currentTurn].turnBal;

// Transfer remaining tokens to the claimant
if (remainingTokens > 0) {
token.transfer(msg.sender, remainingTokens);
}

// Mark the turn as claimed
turn[_poolID][currentTurn].active = false;
emit UserClaim(_poolID,currentTurn,msg.sender, bal );

// If the claimant is the last recipient, set the pool to not active
if (currentTurn == pool[_poolID].participants.length) {
pool[_poolID].isActive = false;
// Return deposits to participants
_returnDeposits(_poolID);
}

_updateTurn(_poolID);
}

Try it out on Mara Testnet: To experiment with the Contract and its functions on the Mara Testnet, you can access the contract’s address on the testnet explorer:

SavingsPool Contract on Mara Testnet Explorer

and the Github Repo for the code

Feel free to explore, test, and learn about the Crypto-Chama SavingsPool Contract. Play around, add features, Innovate!

Stay tuned for more insights into the world of decentralized savings and investment!

--

--

Ibrahim Aziz
Coinmonks

Solidity, Web3 Development| Digital Transformation and Tech Trends