Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 22 additions & 19 deletions contracts/Main.sol
Original file line number Diff line number Diff line change
@@ -1,37 +1,40 @@
// SPDX-License-Identifier: BSD 3-Clause License
pragma solidity ^0.8.28;

import "./SavingGroups.sol";
import "./SavingsGroupsWithRewards.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Main {

address public immutable devFund;
uint256 public fee = 5;

event RoundCreated(SavingGroups childRound);
event RoundCreated(SavingsGroupsWithRewards childRound);

constructor(address _devFund) public {
require(_devFund != address(0), "Invalid dev fund address");
devFund = _devFund;
}

function createRound( uint256 _warranty,
uint256 _saving,
uint256 _groupSize,
uint256 _adminFee,
uint256 _payTime,
ERC20 _token
) external payable returns(address) {
SavingGroups newRound = new SavingGroups( _warranty,
_saving,
_groupSize,
msg.sender,
_adminFee,
_payTime,
_token,
devFund,
fee
);
function createRound(
uint256 _warranty,
uint256 _saving,
uint256 _groupSize,
uint256 _adminFee,
uint256 _payTime,
ERC20 _token
) external payable returns(address) {
SavingsGroupsWithRewards newRound = new SavingsGroupsWithRewards(
_warranty,
_saving,
_groupSize,
msg.sender,
_adminFee,
_payTime,
_token,
devFund,
fee
);
emit RoundCreated(newRound);
return address(newRound);
}
Expand Down
77 changes: 77 additions & 0 deletions contracts/RewardsVault.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "hardhat/console.sol";
/**
* @title RewardsVault
* @notice This vault accepts deposits of any ERC20 token (e.g. $OP, WETH, or in our case $XOC)
* and allows an authorized caller (the vault admin) to distribute all tokens held in the vault
* to a list of participants. Distribution is based on a weight that increases with the participant’s
* position in the payout order.
*/
contract RewardsVault {
using SafeERC20 for IERC20;

// Mapping to track the total tokens held in the vault per token address.
mapping(address => uint256) public vaultBalances;

// The admin address that is allowed to trigger distribution.
// In our use case, the SavingsGroupWithRewards contract becomes the vault admin.
address public admin;

event Deposited(address indexed token, address indexed from, uint256 amount);
event RewardsDistributed(address indexed token, uint256 totalReward);

/**
* @notice Constructor sets the vault admin to the contract that deploys this vault.
*/
constructor() {
console.log("RewardsVault constructor", msg.sender);
console.log("RewardsVault Contract", address(this));
admin = msg.sender;
}

/**
* @notice Deposit tokens into the vault.
* @param token The ERC20 token address.
* @param amount The amount of tokens to deposit.
*/
function deposit(address token, uint256 amount) external {
require(amount > 0, "Amount must be > 0");
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
vaultBalances[token] += amount;
emit Deposited(token, msg.sender, amount);
}

/**
* @notice Distribute all tokens held in the vault for a given token to participants.
* Each participant’s weight is their 1-indexed position in the array (so later positions earn more).
* @param token The ERC20 token address for rewards.
* @param participants An array of participant addresses in the order they received their payout.
*/
function distributeRewards(address token, address[] calldata participants) external {
require(msg.sender == admin, "Only admin can distribute rewards");
uint256 totalReward = vaultBalances[token];
require(totalReward > 0, "No rewards available for this token");
require(participants.length > 0, "No participants provided");

// Calculate the total weight (sum of 1, 2, ... n).
uint256 totalWeight = 0;
uint256 len = participants.length;
for (uint256 i = 0; i < len; i++) {
totalWeight += (i + 1);
}

// Distribute rewards proportionally based on each participant's weight.
for (uint256 i = 0; i < len; i++) {
uint256 weight = i + 1;
uint256 share = (totalReward * weight) / totalWeight;
IERC20(token).safeTransfer(participants[i], share);
}

// Reset the vault balance for this token.
vaultBalances[token] = 0;
emit RewardsDistributed(token, totalReward);
}
}
6 changes: 5 additions & 1 deletion contracts/SavingGroups.sol
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,10 @@ contract SavingGroups is Modifiers {
turn++;
}

function _completeSavingsAndAdvanceTurn(uint8 turno) internal {
completeSavingsAndAdvanceTurn(turno);
}

function payLateFromSavings(address _userAddress) internal {
if (users[_userAddress].availableSavings >= users[_userAddress].owedTotalCashIn){
users[_userAddress].availableSavings -= users[_userAddress].owedTotalCashIn;
Expand Down Expand Up @@ -375,7 +379,7 @@ contract SavingGroups is Modifiers {
emit EmergencyWithdraw(address(this), saldoAtorado);
}

function endRound() public atStage(Stages.Save) {
function endRound() public virtual atStage(Stages.Save) {
require(getRealTurn() > groupSize, "No ha terminado la ronda");
for (uint8 turno = turn; turno <= groupSize; turno++) {
completeSavingsAndAdvanceTurn(turno);
Expand Down
119 changes: 119 additions & 0 deletions contracts/SavingsGroupsWithRewards.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./SavingsGroup.sol"; // The base contract (which includes all saving logic)
import "./RewardsVault.sol"; // The vault contract for rewards distribution
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";

/**
* @title SavingsGroupsWithRewards
* @notice Extends the base SavingsGroup contract and integrates an automatically deployed
* RewardsVault instance. This vault holds reward tokens (in this case $XOC) and, when the round
* ends, distributes its entire balance among participants according to their payout order.
*
* Note: Since we no longer use the BLX token, we pass a dummy value for that parameter in the base constructor.
*/
contract SavingsGroupsWithRewards is SavingGroups {
RewardsVault public rewardsVault;
// The token used in the group (in this case, $XOC) is also the reward token.
address public rewardToken;

/**
* @notice Constructor.
* @param _cashIn Amount required to join the group.
* @param _saveAmount Payment per round.
* @param _groupSize Total number of participants.
* @param _admin Admin address.
* @param _adminFee Fee charged by the admin (in percentage).
* @param _payTime Payment period in days.
* @param _token The ERC20 token used in the group (here, $XOC).
* @param _devFund Address for the developer fund.
* @param _fee Additional fee value.
*/
constructor(
uint256 _cashIn,
uint256 _saveAmount,
uint256 _groupSize,
address _admin,
uint256 _adminFee,
uint256 _payTime,
IERC20Metadata _token,
address _devFund,
uint256 _fee
)
SavingGroups(
_cashIn,
_saveAmount,
_groupSize,
_admin,
_adminFee,
_payTime,
_token,
_devFund,
_fee
)
{
rewardToken = address(_token);
// Deploy a new vault for this group.
rewardsVault = new RewardsVault();
}

/**
* @notice Override endRound to finalize the round without BLX rewards and automatically trigger
* reward distribution from the group's dedicated vault.
*/
function endRound() public override atStage(Stages.Save) {
require(getRealTurn() > groupSize, "Round not yet completed");

// Finalize the round by advancing remaining turns.
for (uint8 turno = turn; turno <= groupSize; turno++) {
_completeSavingsAndAdvanceTurn(turno);
}

uint256 sumAvailableCashIn = 0;
for (uint8 i = 0; i < groupSize; i++) {
address userAddr = addressOrderList[i];
if (users[userAddr].availableSavings >= users[userAddr].owedTotalCashIn) {
payLateFromSavings(userAddr);
}
sumAvailableCashIn += users[userAddr].availableCashIn;
}

if (!outOfFunds) {
uint256 totalAdminFee = 0;
for (uint8 i = 0; i < groupSize; i++) {
address userAddr = addressOrderList[i];
uint256 cashInReturn = (users[userAddr].availableCashIn * totalCashIn) / sumAvailableCashIn;
// Reset available cash and mark user as inactive.
users[userAddr].availableCashIn = 0;
users[userAddr].isActive = false;
uint256 amountTempAdmin = (cashInReturn * adminFee) / 100;
totalAdminFee += amountTempAdmin;
// Calculate what the user receives.
uint256 amountTempUsr = cashInReturn - amountTempAdmin + users[userAddr].availableSavings;
users[userAddr].availableSavings = 0;
transferTo(userAddr, amountTempUsr);
emit EndRound(address(this), startTime, block.timestamp);
}
transferTo(admin, totalAdminFee);
stage = Stages.Finished;
} else {
for (uint8 i = 0; i < groupSize; i++) {
address userAddr = addressOrderList[i];
uint256 amountTemp = users[userAddr].availableSavings + ((users[userAddr].availableCashIn * totalCashIn) / sumAvailableCashIn);
users[userAddr].availableSavings = 0;
users[userAddr].availableCashIn = 0;
users[userAddr].isActive = false;
amountTemp = 0;
}
stage = Stages.Emergency;
}

// Once the round is properly finished, trigger reward distribution from this group's vault.
if (stage == Stages.Finished) {
// The vault will distribute all tokens (of type rewardToken, i.e. $XOC) it holds among participants.
rewardsVault.distributeRewards(rewardToken, addressOrderList);
}
}

}