Pablo Cibraro

My notes about software development, security and other stuff.

Implementing a basic ETH Pool - Exactly Finance's challenge explained

I am still in the process of learning Solidity and Smart contract development, and there is no better way to learn than building new stuff on it. Yesterday, I came across a code challenge from Exactly to build a basic pool in Ethereum. I thought it was going to be a great exercise to put some ideas into practice, so I decide to ahead and do it. What I am going to show here is a summary of all the design decisions and how I ended up implementing that solution.

The Challenge

You can find the full description of the challenge here. In short, it requires implementing a pool where one or more participants can deposit funds. There is also a team that owns the pool and can contribute to it with rewards. Those rewards are distributed across all the participants in the pool according to their % of the stake. For example, if Participant A deposits 100 tokens and B 300 tokens, A owns 25% and B 75%. When A or B withdraws the funds from the pool, they should get their funds and also the corresponding % of the rewards sent to the pool until that point in time. Once a participant has left the pool, it won't be able to claim rewards in that pool anymore. Time is not mentioned anywhere in the description, so we won't consider it as a variable for the implementation.

The implementation

The first thing is to define the Smart Contract structure. For this implementation, we will require at least three methods,

  • Deposit: for making deposits in the pool
  • Withdraw: for withdraw funds from the pool
  • Deposit Rewards: for teams members to distribute rewards through the members in the pool.
contract ETHPool {
  receive() external payable {}
  function depositRewards() public payable {}
  function withdraw() public {}
}

We could implement deposits in two ways, a traditional method or a receive method (previously known as the fallback method). I decided to use the latter to receive funds directly with a regular transaction without a data payload. Some wallets or exchanges don't support data payload.

The Receive method

uint256 public total;

address[] public users;

struct DepositValue {
  uint256 value;
  bool hasValue;
}

mapping(address => DepositValue) public deposits;

receive() external payable {

   if(!deposits[msg.sender].hasValue) // only pushes new users
      users.push(msg.sender);

    deposits[msg.sender].value += msg.value;
    deposits[msg.sender].hasValue = true;

    total += msg.value;
}

The deposit implementation uses two data structures, a mapping for storing the funds associated with a user (deposits), and a list for tracking all the users in the pool (users). In the way the EVM works, there is no way to determine if an entry exists in a mapping, so we can not rely on the mapping to determine if the user is part of the list of users. I did not want to go through all the elements in the list either; that could be expensive for a long list. I decided to use a known workaround with a struct that contains a flag to determine if the entry was defined or not. Once a user makes a deposit, I store the funds in the mapping and add the user to the list if it was not added previously. Finally, I increase the total value in the pool. I decided to use a variable to store the contract's total and not use the balance. The latter is prone to attacks

The DepositRewards method

function depositRewards() public payable  {
  require(total > 0); // No rewards to distribute if the pool is empty.

  for (uint i = 0; i < users.length; i++){
    address user = users[i];

    uint rewards = ((deposits[user].value * msg.value) / total);

    deposits[user].value += rewards;
   }
}

The challenge description does not mention time anywhere as a variable. It only describes the rewards are paid weekly. That implies that anyone with funds in the pool can receive rewards. It does not matter how long the funds were there. My implementation goes through all the users in the list and distributes the received rewards according to the % of the stake on the pool. I had to consider two things when implementing this method.

  1. If the pool is empty, so the total equals 0, there is no need to distribute any rewards. We want to rollback the transaction if that happens. Otherwise, the funds for the rewards will be lost.
  2. The EVM does not support decimals or float numbers, so you have to be careful when doing divisions. The initial formula I used was this one (user deposits / total) * rewards. However, that produced a result equal to 0 most of the time, as the user deposits were less than the total.

The Withdraw method

function withdraw() public {
  uint256 deposit = deposits[msg.sender].value;

  require(deposit > 0, "You don't have anything left to withdraw");

  deposits[msg.sender].value = 0;

  (bool success, ) = msg.sender.call{value:deposit}("");

  require(success, "Transfer failed");
}

It checks the user's funds in the pool and transfers those. If the user does not have anything in the pool, the transaction is reverted. It also sets the deposits in the pool to zero before making the transfer to avoid reentrancy attacks. Once the transfer is made, it checks for the results and reverts the transaction if the call failed (our change in the deposits mapping is reverted as well).
There is no need to use a reentrancy guard, as setting the deposit to 0 is making the same effect.

Managing the Team Members

The challenge description is clear about who can deposit rewards in the pool. That's the team members. We need to add support in our contract for adding members. A simple way is to pass a list of addresses in the constructor, but that's not flexible. Once the contract is deployed, the list of members can not be changed anymore. I decided to use the OpenZeppelin Access Control contract, which provides support for roles.

import "@openzeppelin/contracts/access/AccessControl.sol";

contract ETHPool is AccessControl {
  constructor() {
    _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
    _setupRole(TEAM_MEMBER_ROLE, msg.sender);
  }
}

function addTeamMember(address account) public {
  grantRole(TEAM_MEMBER_ROLE, account);
}

function removeTeamMember(address account) public {
  revokeRole(TEAM_MEMBER_ROLE, account);
}

We assign the admin role and team member role to the Smart Contract's owner as a part of the constructor execution. The admin is the one that can assign users to the other roles. I also added two methods to grant or revoke the team member role to/from a user.

Finally, the depositRewards method was modified to check for that role.

function depositRewards() public payable onlyRole(TEAM_MEMBER_ROLE) {

Adding events

Events are nice to have features in Smart Contracts. They allow reporting information in the Blockchain transaction log that can not be inferred directly from a transaction. They can also be indexed and used by Dapps.

event Deposit(address indexed _address, uint256 _value);
event Withdraw(address indexed _address, uint256 _value);

I added two events for reporting when a deposit or a withdraw was made. Those are called in the respective methods.

receive() external payable {
   ....
   emit Deposit(msg.sender, msg.value);
}

function withdraw() public {
  ...
  emit Withdraw(msg.sender, deposit);
}

The complete implementation

You can find the complete source code in my Github repository.