Platform: Code4rena
Start Date: 07/07/2023
Pot Size: $121,650 USDC
Total HM: 36
Participants: 111
Period: 7 days
Judge: Picodes
Total Solo HM: 13
Id: 258
League: ETH
Rank: 21/111
Findings: 8
Award: $1,488.31
π Selected for report: 1
π Solo Findings: 0
π Selected for report: Udsen
Also found by: 0xMirce, 0xPsuedoPandit, 0xStalin, 0xbepresent, Aymen0909, Bobface, Co0nan, GREY-HAWK-REACH, Jeiwan, John, KupiaSec, LuchoLeonel1, Nyx, Praise, RedTiger, alexweb3, bin2chen, btk, dacian, dirk_y, josephdara, keccak123, ktg, mahdirostami, markus_ether, minhtrng, ni8mare, peanuts, ptsanev, ravikiranweb3, rvierdiiev, seeques, serial-coder, shaka, teawaterwire, wangxx2026, zzzitron
2.2492 USDC - $2.25
Anyone can claim the yield fees and mint the extra shares to their address. The shares can then be redeemed for assets.
The Vault.mintYieldFee function allow minting the yield fee that was set out during liquidations. The function can be called by anyone and an arbitrary fee recipient address can be provided, allowing anyone to steal accumulated yield fee.
The contract defines the _yieldFeeRecipient state variable, which is the address that's expected to receive yield fees. However, the variable is not used in the Vault.mintYieldFee
function.
Manual review
In the Vault.mintYieldFee
function, consider minting shares only to the address specified in the _yieldFeeRecipient
state variable.
Access Control
#0 - c4-judge
2023-07-14T22:19:51Z
Picodes marked the issue as duplicate of #396
#1 - c4-judge
2023-08-05T22:04:44Z
Picodes marked the issue as satisfactory
π Selected for report: 0xkasper
Also found by: 0xStalin, 0xbepresent, 3docSec, Aymen0909, Co0nan, GREY-HAWK-REACH, Jeiwan, minhtrng, qpzm
163.3108 USDC - $163.31
Anyone can revoke the chances to win for any other user. A malicious actor can use the vulnerability to exclude big depositors from the draws, thus increasing the malicious actor's chances to win prizes.
The Vault.sponsor function allows the caller to:
uint256 _shares = deposit(_assets, _receiver);
if ( _twabController.delegateOf(address(this), _receiver) != _twabController.SPONSORSHIP_ADDRESS() ) { _twabController.sponsor(_receiver); }
As per the definition of the sponsorship address (TwabController.sol#L23-L24):
Allows users to revoke their chances to win by delegating to the sponsorship address.
The vulnerability is that the sponsor
function can be called on any address, without the address' owner permission. A malicious actor can call the sponsor
address with the amount of assets set to 0 (or 1 wei) and the receiver address set to the address of a whale (the top holder of shares). _twabController.sponsor
will be called with the whale's address, and all their shares will be delegated to the sponsorship address, thus completely preventing the whale from participating in draws.
As can be seen from the _transferDelegateBalance function, when delegating to the sponsorship address, the delegated balance of the user and the total supply is decreased. This will create a new observation with the delegate balance of the whale set to 0. The TWAB of a user is the most important metric when determining if the user is a winner in a draw or not. Thus, by delegating user's balance to the sponsorship address, the TWAB of the user will be reduced, thus reducing their chances to win draws. Since the sponsor
function can be called on any address, it can also be used to un-delegate all the tokens delegated to the victim, to reduce the victim's delegated balance to 0 and totally exclude them from competing for draws.
Manual review
In the Vault.sponsor
and Vault.sponsorWithPermit
functions, consider setting the receiver parameter to msg.sender
and consider disallowing the caller to specify it.
Other
#0 - c4-judge
2023-07-14T23:10:35Z
Picodes marked the issue as duplicate of #393
#1 - c4-judge
2023-08-06T10:29:53Z
Picodes marked the issue as satisfactory
π Selected for report: dirk_y
Also found by: 0xbepresent, 0xkasper, Jeiwan, KupiaSec, bin2chen, rvierdiiev, xuwinnie
252.0228 USDC - $252.02
https://github.com/GenerationSoftware/pt-v5-twab-controller/blob/0145eeac23301ee5338c659422dd6d69234f5d50/src/TwabController.sol#L596-L599 https://github.com/GenerationSoftware/pt-v5-twab-controller/blob/0145eeac23301ee5338c659422dd6d69234f5d50/src/TwabController.sol#L622-L626 https://github.com/GenerationSoftware/pt-v5-twab-controller/blob/0145eeac23301ee5338c659422dd6d69234f5d50/src/TwabController.sol#L633-L637
Users cannot re-delegate their tokens after delegating them to the zero address. Such users will forever burn their delegated tokens and won't be allowed to participate in draws, since their delegateBalance
will always be 0.
The TwabController.delegate function lets users delegate their tokens to any address. One of the option is to delegate to the zero address, in case a user wants to opt out of participating in draws: when delegating to the zero address, the user's balance of delegated tokens is decreased and the total supply of delegated tokens is also decreased. The function also allows delegating from the zero address: the total supply is expected to be increased. The function is deliberately designed to handle the cases when either "to delegate" or "from delegate" addresses is zero, thus delegating to the zero address seems like a valid use case.
However, the "from delegate" address can never be the zero address: when taking the current delegate, _delegateOf
is called, which returns the user's address if the delegate address is the zero address. Thus, after delegating to the zero address, the "from delegate" address will be set to the user's address, leading to the following outcomes:
delegateBalance
to delegate (since the _fromDelegate
is set to the user's address, not to the zero address).Thus, the delegated tokens will be lost forever, which will reduce the user's TWAB and will prevent them from participating in draws.
If at this point the user receives or mints more tokens, their delegateBalance
will be increased as expected. However, it will be smaller than their token balance, and they won't be able to delegate at all since, when delegating, it's the token balance that's delegated, not delegateBalance
.
Manual review
The simplest solutions seems disallowing delegating to the zero address: since delegating to the sponsorship address is used to opt out of draws, allowing transfers to the zero address seems unnecessary.
Alternatively, delegating to the zero address could mean irrevocable burning of delegateBalance
. In this case, the code needs to be updated to not allow delegating from the zero address, and the users need to be able to delegate newly minted or received tokens.
Other
#0 - c4-judge
2023-07-15T08:33:17Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-07-20T20:14:55Z
asselstine marked the issue as sponsor confirmed
#2 - c4-judge
2023-08-06T19:59:48Z
Picodes marked the issue as satisfactory
#3 - Picodes
2023-08-06T20:01:27Z
High severity seems justified because of the lines if (_userDelegate == address(0)) { _userDelegate = _user;}
, it seems likely that some users are tricked into thinking that delegating back to the 0 address will cancel their delegation
#4 - c4-judge
2023-08-06T20:01:50Z
Picodes marked issue #206 as primary and marked this issue as a duplicate of 206
#5 - asselstine
2023-08-17T21:05:35Z
768.245 USDC - $768.25
The prize tokens deposited directly to the prize pool can be contributed to the prize pool by anyone. This frees liquidators from providing prize tokens, while they still receive vault shares. Another impact is the reserves of the prize pool become inflated: the actual amounts of tokens in reserves will be smaller. This can break claiming of prizes when there's not enough tokens in the pool and the reserves are used to pay the prize.
The PrizePool.increaseReserve function allows anyone to deposit prize tokens directly to the reserves of the prize pool. However, these tokens are not accounted in the balance of the prize pool: the _accountBalance function doesn't count the reserves. As a result, the tokens deposited directly to the reserves can also be contributed to the prize pool: the contributePrizeTokens function computes the amount of tokens to contribute as the difference between the current contract's balance and the amount of accounted balances:
uint256 _deltaBalance = prizeToken.balanceOf(address(this)) - _accountedBalance(); if (_deltaBalance < _amount) { revert ContributionGTDeltaBalance(_amount, _deltaBalance); }
What this means is that, after the PrizePool.increaseReserve
function is called and some amount of tokens is deposited, the Vault.liquidate function can be called by anyone to contribute the tokens, basically making the liquidation free for the caller. The user who deposited the tokens to the reserve loses them.
Manual review
There doesn't seem to be a quick solution. It's clear that the reserves shouldn't be accounted because they're mainly made from the contributed tokens (after shrinking unused tiers). Thus, it seems that donated reserves should be counted in a separate state variable and the state variable should be accounted in _accountedBalance
.
Other
#0 - c4-judge
2023-07-16T15:21:59Z
Picodes marked the issue as duplicate of #200
#1 - c4-judge
2023-08-06T11:04:35Z
Picodes marked the issue as satisfactory
π Selected for report: wvleak
Also found by: 0xbepresent, 3docSec, DadeKuma, Jeiwan, Udsen, dirk_y, keccak123, rvierdiiev, serial-coder, shaka, wvleak
20.0427 USDC - $20.04
https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L1053 https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L1068
Any winner can intentionally or not intentionally block claiming of prized for themselves and other winners. The caller of claimPrizes
would have to exclude the winner from the list to claim prizes for other winners.
The Vault.claimPrizes function allows to claim prizes for multiple winners. The function calls _claimPrize for each winner. In _claimPrize
, user-specified hooks are called to determine the prize recipient address and perform post-claim operations (Vault.sol#L1052-L1075):
if (hooks.useBeforeClaimPrize) { recipient = hooks.implementation.beforeClaimPrize(_winner, _tier, _prizeIndex); } else { recipient = _winner; } uint prizeTotal = _prizePool.claimPrize( _winner, _tier, _prizeIndex, recipient, _fee, _feeRecipient ); if (hooks.useAfterClaimPrize) { hooks.implementation.afterClaimPrize( _winner, _tier, _prizeIndex, prizeTotal - _fee, recipient ); }
The hooks are set by users and can point at any address that implements the IvaultHooks interface. However, the hooks can fail, but the _claimPrize
function won't detect that and will revert the entire transaction. Even if a user doesn't have hooks set, claiming a prize for them may fail because of a failing hook of another user.
Manual review
For the beforeClaimPrize
hook, consider wrapping the call in try/catch and skipping the claiming of the prize if the call fails. For the afterClaimPrize
hook, consider wrapping the call in try/catch and silently skipping failed calls (since prize claiming cannot be reverted after it was done).
DoS
#0 - c4-judge
2023-07-16T22:30:33Z
Picodes marked the issue as duplicate of #465
#1 - c4-judge
2023-08-07T15:17:00Z
Picodes marked the issue as partial-50
π Selected for report: Jeiwan
Also found by: 0xSmartContract, 0xStalin, 3docSec, ABAIKUNANBAEV, btk, dev0cloo, dirk_y, grearlake, jaraxxus, keccak123, neumo, oxchsyston, rvierdiiev
29.8484 USDC - $29.85
A malicious vault can be deployed via the official VaultFactory
contract. Users may lose funds while interacting with such vaults.
The VaultFactory.deployVault function allows deploying Vault
contracts. The factory contract maintains a mapping to verify that a vault has been deployed via the factoryβthis allows users to check the authenticity of a vault to ensure the that implementation of a vault is authentic (i.e. not altered/malicious).
However, the business logic of vaults is split into multiple contracts: Vault, TwabController, and PrizePool. TwabController
tracks the historical balances of users to determine their chances of winning prizes. PrizePool
runs regular draws and distributes prizes among winners. Thus, it's critical that, in every authentic Vault
contract, the implementations of TwabController
and PrizePool
are also authentic. Otherwise, a malicious actor could deploy an authentic vault via the official VaultFactory
and provide malicious TwabController
and PrizePool
contracts, which, for example, incorrectly determine user balances, favors some specific addresses when determining winners, or steals the prize token. In the current implementation, users are be able to check the authenticity of a vault contract, but not the authenticity of the TwabController
and the PrizePool
contracts a vault integrates with.
Manual review
Consider implementing a TwabController
and a PrizePool
factory contracts. In the contracts, consider tracking the addresses of deployed TwabController
and PrizePool
contracts. In the VaultFactory.deployVault
function, consider checking that the passed _twabController
and _prizePool
address were deployed via the respective factory contracts.
Other
#0 - c4-judge
2023-07-18T15:32:22Z
Picodes marked the issue as duplicate of #324
#1 - c4-judge
2023-08-06T10:46:09Z
Picodes marked the issue as selected for report
#2 - Picodes
2023-08-06T22:48:47Z
#3 - c4-judge
2023-08-08T08:51:42Z
Picodes marked the issue as satisfactory
π Selected for report: bin2chen
Also found by: 0x11singh99, 0xWaitress, 0xbepresent, ABAIKUNANBAEV, ArmedGoose, Bauchibred, DadeKuma, GREY-HAWK-REACH, GalloDaSballo, Inspecktor, Jeiwan, Kaysoft, MohammedRizwan, Rolezn, Vagner, alexzoid, alymurtazamemon, ayden, banpaleo5, catellatech, dacian, erebus, eyexploit, fatherOfBlocks, grearlake, joaovwfreire, keccak123, kutugu, lanrebayode77, markus_ether, nadin, naman1778, rvierdiiev, squeaky_cactus, volodya, yixxas
140.2996 USDC - $140.30
Calling PrizePool.getTotalContributedBetween
or PrizePool.getContributedBetween
can revert when a correct _endDrawIdInclusive
is provided. Third-party integration and off-chain analysis tools won't be able to obtain the total contributed amounts or the contributed amounts of a vault.
The DrawAccumulatorLib.getDisbursedBetween function limits the _endDrawId
argument: it cannot be more than draw before the last observed draw. To validate the value of the argument, the function calls readDrawIds
obtain the oldest and the newest observations (the newest is stored in drawIds.second
) and then compares the draw ID of the newest observation with the value of _endDrawId
(DrawAccumulatorLib.sol#L190-L192):
if (_endDrawId < drawIds.second - 1) { revert InvalidDisbursedEndDrawId(_endDrawId); }
However, not every draw has an observation, and a previous observation can have a draw ID that's smaller than the newest observation's draw ID - 1. Observations are created in the DrawAccumulator.add function, which is only called when a new contribution is made to the prize pool (PrizePool.sol#L311-L327). Thus, the following situation is possible:
I.e., there were no contributions in draw 9 and no observations were created for the draw. In this case, when calling DrawAccumulatorLib.getDisbursedBetween
, a correct value of _endDrawId
would be 8 (the ID of the observation before the newest observation), however the function won't allow values smaller than 9, even though there's no observation for draw 9.
Manual review
When validating the value of _endDrawId
in DrawAccumulatorLib.getDisbursedBetween
, consider comparing it to the actual draw ID of the observation before the newest one, e.g. using:
_accumulator.drawRingBuffer[ RingBufferLib.offset(indexes.second, 1, ringBufferInfo.cardinality) ];
To obtain the draw ID of the observation before the newest one.
Other
#0 - c4-sponsor
2023-07-20T20:36:01Z
asselstine marked the issue as sponsor confirmed
#1 - Picodes
2023-08-08T08:34:14Z
Downgrading to Low for "assets are not at risk: state handling". As there is nothing critical here and this can be managed by integrators I don't think it qualifies for Med.
#2 - c4-judge
2023-08-08T08:34:18Z
Picodes changed the severity to QA (Quality Assurance)
π Selected for report: 0xStalin
Also found by: 0xSmartContract, 0xWaitress, 3docSec, ABAIKUNANBAEV, DadeKuma, Jeiwan, K42, catellatech, dirk_y, keccak123, peanuts, squeaky_cactus
112.2875 USDC - $112.29
The audited PoolTogether protocol can be described as an advanced ERC4626 vault with extra mechanics and features. The main Vault
contract is compatible with the ERC4626 standard, however it adds multiple unique and tricky features.
Foremost, the Vault
sets the share-to-asset exchange rate to 1 or less than 1. This means that a deposited amount of assets cannot be later withdrawn as a bigger amount. During withdrawing, users receive the exact same amount of assets as they deposited, besides the situations when the exchange rate goes below 1 (i.e. when the vault becomes undercollateralized).
Second, the yield generated and collected by the vault is not distributed among the depositors of the vault: it can only be liquidated. Liquidating yield means swapping it for an amount of prize tokens and contributing the prize tokens into the prize pool. The liquidator converts the yield into prize tokens and deposits them to the prize pool, while receiving an equal amount of shares as reward.
Another big change is that the vault contract doesn't mint and manage sharesβthis functionality is delegated to the TwabController
contract.
The PrizePool
contract is another core contract of the protocol. The contract receives the yield generated by the vault and converted to prize tokens. The prize tokens are then distributed among the depositors of the vault via daily draws. During each draw, the total amount of prize tokens is split into multiple tiers, with each tier having a unique odds of winning, a unique prize size, and a unique number of prizes. A draw is triggered by the draw manager, which is a restricted role that provides a winning random number for each draw. The number randomizes the winners of a draw, however the main factor that contributes to the chances of a depositor to win is the historical vault shares balance of the depositor.
The TwabController
contract glues the vault and the prize pool. Its main purpose is to store the vault share balances of depositors and keep the record of their historical balances. The latter is implemented via TWAB, Time-Weighted Average Balances, a mechanism that's identical to Uniswap's Time-Weighted Average Price oracle.
The final core contract is Claimer
. The contract allows anyone to claim prizes for all winners in a draw for a fee. The size of the fee is determined via a Linear Variable Rate Gradual Dutch Auction, which was invented by Paradigm. The auction increases the fee as more time passes since the time the draw was closed at. The claimers thus get a higher reward the later they claim prizes.
The codebase as a whole is a solid engineering construction that relies on the standards and battle-tested algorithms, while providing novelty in how they're used together. The vault implements the ERC4626 standard, while significantly modifying it. The prize pool and the TWAB controller contracts implement the battle-tested historical oracle algorithm invented and used by Uniswap. The claimer contract cleverly uses a Variable Rate Gradual Dutch Auction to incentivize prize payouts for winners of draws.
The protocol doesn't expose significant centralization risks. All the audited contracts are permissionless and don't implement restricted functions. The only exception is the PrizePool.closeDraw
function that can only be called by the draw manager. To avoid draw results manipulation, a centralized actor is required to provide a random number.
The audit was performed using the standard technique of reading the entire codebase line-by-line, validating each line against the whitepaper and using the common sense, as well as bird's eye view reviewing of the codebase with the goal of finding discrepancies in the integrations of the contracts.
30 hours
#0 - c4-judge
2023-07-18T18:48:55Z
Picodes marked the issue as grade-b
#1 - c4-sponsor
2023-07-20T20:14:25Z
asselstine marked the issue as sponsor confirmed