PoolTogether is a protocol that allows users to join a trust-minimized no-loss lottery on the Ethereum network. Pods is a new feature that will allow users to pool their lottery tickets and share the rewards.
Having previously audited the main system, the PoolTogether team asked us to review and audit the new Pods feature. We looked at the code and now publish our results.
The audited commit is 8041b3dc72efd02b94d49fb37b9b308603af5ce
and the contracts included in the scope were:
– ExchangeRateTracker
,
– FixedPoint
,
– ScheduledBalance
,
– Pod
Additionally, we reviewed the callRewarded function
in the original system, which is required to support the Pods feature.
All external code and contract dependencies were assumed to work as documented.
Update: All issues have been addressed or accepted by the PoolTogether team. Our analysis of the mitigations assumes the pull requests will be merged, but disregards any other potential changes to the code base.
Here we present our findings.
Overall, we are happy with the security posture of the team and the health of the codebase. As with the original audit, we are pleased to see the use of small, encapsulated functions and well-documented contracts. Our only concern is the lack of access controls. Not limiting who is able to join a pod may undermine the intention of the feature (see “[N01] Fragile Use Case”).
Our original audit report contains a description of the main system.
The Pods feature extends the system to introduce an optional intermediate contract between users and a particular pool. This intermediate contract aggregates user funds and deposits them into the PoolTogether system on behalf of the users. In exchange, users receive Pod shares in the form of a new ERC777 token.
If the Pod contract wins a lottery, it updates the internal exchange rate between the Pod tokens and the Pool tokens. This effectively distributes the winnings to all shareholders in the pod.
When a user deposits collateral, the total supply and their individual balance are scheduled to be updated in the next draw. However, if the collateral is withdrawn before the next round, the user’s individual balance is updated but the supply is not.
When the supply is consolidated, additional Pod tokens will be minted that are not assigned to any user.
Subsequently, when the pod wins a lottery, the new Pool tokens will be spread evenly over all Pod tokens, even the ones that are unassigned. This means that users will receive less than their fair share of the winnings.
Consider updating the scheduled supply when withdrawing a pending deposit. More generally, consider abstracting the interaction with scheduled user balances and supply so they are both updated with the same call.
Update: Fixed in PR#2. The supply is updated when withdrawing a pending deposit.
The _deposit
function of the Pod contract attempts to take collateral from the operator
but credits it to the from
address.
If successful, this effectively transfers funds from the operator to the from
address. On the other hand, if the operator has insufficient funds or the Pod contract does not have approval, the operatorDeposit
function will revert. Either scenario is undesirable.
Consider updating the transferFrom
arguments to retrieve collateral from the from
address.
Update: Fixed in PR#3. Collateral is correctly taken from the from
address.
When a user deposits funds into a pod, they are scheduled to receive Pod tokens after the current draw. Conceptually, when the draw is over, they should have full access to the ERC777 functionality of their Pod tokens. In practice, they only receive the tokens once consolidateBalanceOf is called (which occurs on any subsequent state-changing interaction with the Pod-specific functions).
In the mean time, the send
, transfer
and transferFrom
functions do not account for the new tokens.
Consider extending these methods to call consolidateBalanceOf
. Alternatively, consider making consolidateBalanceOf
a public function so users can manually consolidate their tokens.
Update: Fixed in PR#4. The Pod
contract extends send
, operatorSend
, transfer
and transferFrom
to call consolidateBalanceOf
on the relevant address before calling the corresponding parent method.
The clearConsolidated function signature claims the function returns a uint256
but it doesn’t return anything. Additionally, its return value is not checked in the the only place it is used.
Consider removing the return value from the function signature.
Update: Fixed in PR#5. The return value was removed from the clearConsolidated
function signature.
Some of the code comments could be clearer.
– In line 85 of FixedPoint.sol: fixed point 18 number
should be fixed point 18 mantissa
to be consistent with the convention used in the code base.
– In line 106 and line 119 of ExchangeRateTracker.sol
: the phrase in the past
should be at the specified timestamp
.
– In line 80 and line 91 of Pod.sol
: burned
should be redeemed
– In line 113 of Pod.sol: debited
should be credited
Update: Fixed in PR#6. The comments were updated appropriately.
There are cases where the data structure leads to unnecessarily complicated code. In particular the scheduled balances track the previousBalance
and lastBalance
separately. However, the only time the previous balance is updated occurs immediately after the balance is consolidated, and only when the currentTimestamp
is after the lastTimestamp
. In this case, the previousBalance
will be increased by zero. This implies it will always be zero.
This fact is not obvious, but it is implicitly assumed when the scheduled balances are converted to tokens (since all balances use the same exchange rate, no matter when they were added). While correct, it makes the code harder to reason about.
Consider simplifying the scheduled balance data structure to record a single balance with its timestamp.
Update: Fixed in PR#7. The data structure was updated as suggested and the ScheduledBalance
and Pod
contracts were updated accordingly.
Instead of simply depositing funds in Compound Finance directly and receiving small steady interest payments, users of the PoolTogether system receive large sporadic payoffs.
Pods allow users to choose an intermediate point on this spectrum, where the probability of winning a particular lottery scales with the size of the pod, but each user’s share of the winnings is reduced accordingly.
It seems that the usefulness of this feature depends on each user’s ability to choose the size of the pod that they would like to join, and hence where they fall on the payoff spectrum. They may also want to join a pod with specific participants. However, the lack of access control or lock-in undermines this goal, since a given user may find themselves in a significantly larger or smaller pod than they anticipated, due to the actions of other users.
Consider introducing access control or lock-in requirements, or at least documenting the reason for their absence.
Update: The PoolTogether team have indicated that this is the intended behavior, since they prefer to launch this feature with minimal initial restrictions.
When a pod wins a lottery, the rewarded
function is executed with a stipend of 200000 gas. The reason for this decision was explained to us during the audit but we believe it should also be documented in the contract. Consider documenting the reason for the limit, as well as why the particular value of 200000 was chosen.
Update: Fixed in PoolTogether PR#25. The BasePool.callRewarded
comment includes an explanation of the stipend.
In the ExchangeRateTracker
contract, the currentExchangeRateMantissa
function could be simplified by calling the currentExchangeRate
function.
Similarly, the tokenToCollateralValue
function and the collateralToTokenValue
function could reuse the tokenToCollateralValueAt
and collateralToTokenValueAt
functions respectively (perhaps after a minor refactor).
Update: Partially fixed in PR#8. The currentExchangeRateMantissa
function was removed. The conversion functions were retained for convenience.
The ExchangeRateTracker
data structure can be reinitialized, effectively clearing its contents.
To improve predictability, consider preventing initialize
from being called on an initialized tracker. If desired, a reset
function can be used instead.
Update: Fixed in PR#9. There is a check to ensure initialize
can only be called once.
When calculating the unconsolidated balance, the result may be calculated by adding zero to the desired value. Consider setting the result
directly without the unnecessary addition.
Update: This issue is obsolete because the function was removed when addressing “[L03] Complicated Code”.
Here are some typographical errors within the codebase:
* In line 25 of ExchangeRateTracker.sol: it's backing
should be its backing
.
* In lines 54-56 of ScheduledBalance.sol:
* deposit are consolidated
should be deposits are consolidated
* last deposit self
should be last deposit in self
* In line 123 of ScheduledBalance.sol: timeslot
should be timestamp
* In line 153 of ScheduledBalance.sol: give the current timestamp
should be given the current timestamp
* In line 167 of Pods.sol: half
should be behalf
* In line 226 of Pods.sol: users
should be user's
Update: Fixed in PR#10.
One critical and one high severity issue was found. Some changes were proposed to follow best practices and reduce potential attack surface.