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: 10/125
Findings: 4
Award: $388.96
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: ltyu
Also found by: 0xDING99YA, 3docSec, KmanOfficial, MrPotatoMagic, RED-LOTUS-REACH, Tendency, Yuki, bart1e, bin2chen, carrotsmuggler, cducrest, kaden, mert_eren, pep7siup, popular00, qpzm, seerether, zhaojie
43.2097 USDC - $43.21
https://github.com/code-423n4/2023-08-verwa/blob/9a2e7be003bc1a77b3b87db31f3d5a1bcb48ed32/src/VotingEscrow.sol#L371 https://github.com/code-423n4/2023-08-verwa/blob/9a2e7be003bc1a77b3b87db31f3d5a1bcb48ed32/src/VotingEscrow.sol#L383
If the user has delegated their power to another address and their lock expires, the user will not be able to withdraw their CANTO.
There are two steps involved in this:
Address A = User's address (I'll be using the terms "User" and "address A" interchangeably) Address B = The address User delegates his power to
1. User delegates power to B 2. User un-delegates power from address B and delegates back to his address A
Let's understand the first step in a detailed manner:
File: src/VotingEscrow.sol 356: function delegate(address _addr) external nonReentrant { 357: LockedBalance memory locked_ = locked[msg.sender]; 358: // Validate inputs 359: require(locked_.amount > 0, "No lock"); 360: require(locked_.delegatee != _addr, "Already delegated"); 361: // Update locks 362: int128 value = locked_.amount; 363: address delegatee = locked_.delegatee; 364: LockedBalance memory fromLocked; 365: LockedBalance memory toLocked; 366: locked_.delegatee = _addr; 367: if (delegatee == msg.sender) { 368: // Delegate 369: fromLocked = locked_; 370: toLocked = locked[_addr]; 371: } else if (_addr == msg.sender) { 372: // Undelegate 373: fromLocked = locked[delegatee]; 374: toLocked = locked_; 375: } else { 376: // Re-delegate 377: fromLocked = locked[delegatee]; 378: toLocked = locked[_addr]; 379: // Update owner lock if not involved in delegation 380: locked[msg.sender] = locked_; 381: } 382: require(toLocked.amount > 0, "Delegatee has no lock"); 383: require(toLocked.end > block.timestamp, "Delegatee lock expired"); 384: require(toLocked.end >= fromLocked.end, "Only delegate to longer lock"); 385: _delegate(delegatee, fromLocked, value, LockAction.UNDELEGATE); 386: _delegate(_addr, toLocked, value, LockAction.DELEGATE); 387: }
File: src/VotingEscrow.sol 363: address delegatee = locked_.delegatee; //@audit delegatee = msg.sender 366: locked_.delegatee = _addr; //@audit sets _addr to be new delegatee
File: src/VotingEscrow.sol 367: if (delegatee == msg.sender) { 368: // Delegate 369: fromLocked = locked_; 370: toLocked = locked[_addr]; 371: }
File: src/VotingEscrow.sol 382: require(toLocked.amount > 0, "Delegatee has no lock"); 383: require(toLocked.end > block.timestamp, "Delegatee lock expired"); 384: require(toLocked.end >= fromLocked.end, "Only delegate to longer lock");
File: src/VotingEscrow.sol 385: _delegate(delegatee, fromLocked, value, LockAction.UNDELEGATE); 386: _delegate(_addr, toLocked, value, LockAction.DELEGATE);
File: src/VotingEscrow.sol 390: function _delegate( 391: address addr, 392: LockedBalance memory _locked, 393: int128 value, 394: LockAction action 395: ) internal { 396: LockedBalance memory newLocked = _copyLock(_locked); 397: if (action == LockAction.DELEGATE) { 398: newLocked.delegated += value; 399: emit Deposit(addr, uint256(int256(value)), newLocked.end, action, block.timestamp); 400: } else { 401: newLocked.delegated -= value; 402: emit Withdraw(addr, uint256(int256(value)), action, block.timestamp); 403: } 404: locked[addr] = newLocked; 405: if (newLocked.amount > 0) { 406: // Only if lock (from lock) hasn't been withdrawn/quitted 407: _checkpoint(addr, _locked, newLocked); 408: } 409: }
This marks the end of the first step where the user delegated his power to address B sucessfully.
Let's understand Step 2 now where the user's lock has expired and he tries to un-delegate power from address B and delegate back to his address A:
File: src/VotingEscrow.sol 356: function delegate(address _addr) external nonReentrant { 357: LockedBalance memory locked_ = locked[msg.sender]; 358: // Validate inputs 359: require(locked_.amount > 0, "No lock"); 360: require(locked_.delegatee != _addr, "Already delegated"); 361: // Update locks 362: int128 value = locked_.amount; 363: address delegatee = locked_.delegatee; 364: LockedBalance memory fromLocked; 365: LockedBalance memory toLocked; 366: locked_.delegatee = _addr; 367: if (delegatee == msg.sender) { 368: // Delegate 369: fromLocked = locked_; 370: toLocked = locked[_addr]; 371: } else if (_addr == msg.sender) { 372: // Undelegate 373: fromLocked = locked[delegatee]; 374: toLocked = locked_; 375: } else { 376: // Re-delegate 377: fromLocked = locked[delegatee]; 378: toLocked = locked[_addr]; 379: // Update owner lock if not involved in delegation 380: locked[msg.sender] = locked_; 381: } 382: require(toLocked.amount > 0, "Delegatee has no lock"); 383: require(toLocked.end > block.timestamp, "Delegatee lock expired"); 384: require(toLocked.end >= fromLocked.end, "Only delegate to longer lock"); 385: _delegate(delegatee, fromLocked, value, LockAction.UNDELEGATE); 386: _delegate(_addr, toLocked, value, LockAction.DELEGATE); 387: }
File: src/VotingEscrow.sol 363: address delegatee = locked_.delegatee; //@audit delegatee = msg.sender 366: locked_.delegatee = _addr; //@audit sets _addr to be new delegatee
File: src/VotingEscrow.sol 371: else if (_addr == msg.sender) { 372: // Undelegate 373: fromLocked = locked[delegatee]; 374: toLocked = locked_; 375: }
File: src/VotingEscrow.sol 382: require(toLocked.amount > 0, "Delegatee has no lock"); 383: require(toLocked.end > block.timestamp, "Delegatee lock expired"); 384: require(toLocked.end >= fromLocked.end, "Only delegate to longer lock");
File: src/VotingEscrow.sol 326: function withdraw() external nonReentrant { 327: LockedBalance memory locked_ = locked[msg.sender]; 328: // Validate inputs 329: require(locked_.amount > 0, "No lock"); 330: require(locked_.end <= block.timestamp, "Lock not expired"); 331: require(locked_.delegatee == msg.sender, "Lock delegated"); 332: // Update lock 333: uint256 amountToSend = uint256(uint128(locked_.amount)); 334: LockedBalance memory newLocked = _copyLock(locked_); 335: newLocked.amount = 0; 336: newLocked.end = 0; 337: newLocked.delegated -= int128(int256(amountToSend)); 338: newLocked.delegatee = address(0); 339: locked[msg.sender] = newLocked; 340: newLocked.delegated = 0; 341: // oldLocked can have either expired <= timestamp or zero end 342: // currentLock has only 0 end 343: // Both can have >= 0 amount 344: _checkpoint(msg.sender, locked_, newLocked); 345: // Send back deposited tokens 346: (bool success, ) = msg.sender.call{value: amountToSend}(""); 347: require(success, "Failed to send CANTO"); 348: emit Withdraw(msg.sender, amountToSend, LockAction.WITHDRAW, block.timestamp); 349: }
These two steps show us how delegation could be changed from address A to address B but not back from address B to address A due to the user's lock expired problem. Additionally, the delegation problem showed us how withdrawal of CANTO tokens is disallowed, thereby locking the user's CANTO.
Things to note: 1. A new lock cannot be created through createLock() as well since the previous lock has not been withdrawn. 2. I believe this issue is not the user's mistake since the user knows that they can only withdraw their CANTO after their lock has expired. Therefore, it is natural for them to undelegate any existing delegation after their lock has expired and then followingly withdraw CANTO. 3. This issue is different from my previous issue number #58 submitted as the root cause in this is the user's lock expiration while in issue number #58 it is the user not having a longer end time. Even if we resolve the issue in #58, the issue still persists in this case.)
Here is a Coded POC which proves that delegation back to user's address A is not possible after lock has expired:
forge test --match-test testDelegationBackToMsgSenderSuccessAfterLockExpired -vvvv
File: src/VotingEscrow.t.sol 358: function testDelegationBackToMsgSenderSuccessAfterLockExpired() public { 359: vm.prank(user1); 360: ve.createLock{value: LOCK_AMT}(LOCK_AMT); //user1 creates a lock 361: vm.prank(user2); 362: ve.createLock{value: LOCK_AMT}(LOCK_AMT); //user2 creates a lock 363: vm.prank(user1); 364: ve.delegate(user2);//user1 delegates power to user2 365: vm.warp(block.timestamp + 1826 days); //warp 1826 days ahead since after 1825 days is when user's lock expires (i.e. this indicates user's lock has now expired) 366: vm.prank(user1); 367: ve.delegate(user1);//user1 tries to undelegate user2 and delegate back to himself (this should succeed but it fails since user's lock has expired) 368: }
Manual Review
We know the require check causing the problem is important for other cases. Therefore, the best solution would be to move the require check in the if and else blocks but not the else if block like this (updates made on Line 371,382):
File: src/VotingEscrow.sol 367: if (delegatee == msg.sender) { 368: // Delegate 369: fromLocked = locked_; 370: toLocked = locked[_addr]; 371: require(toLocked.end > block.timestamp, "Delegatee lock expired");//@audit check added here 372: } else if (_addr == msg.sender) { 373: // Undelegate 374: fromLocked = locked[delegatee]; 375: toLocked = locked_; 376: } else { 377: // Re-delegate 378: fromLocked = locked[delegatee]; 379: toLocked = locked[_addr]; 380: // Update owner lock if not involved in delegation 381: locked[msg.sender] = locked_; 382: require(toLocked.end > block.timestamp, "Delegatee lock expired");//@audit check added here 383: }
Note: Do not forget to remove the require check on Line 383 when making these changes.
Other
#0 - c4-pre-sort
2023-08-11T11:56:03Z
141345 marked the issue as duplicate of #223
#1 - c4-pre-sort
2023-08-13T12:00:34Z
141345 marked the issue as duplicate of #112
#2 - c4-judge
2023-08-24T07:15:58Z
alcueca marked the issue as duplicate of #82
#3 - c4-judge
2023-08-24T07:20:40Z
alcueca changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-08-24T07:39:48Z
alcueca marked the issue as satisfactory
#5 - alcueca
2023-08-24T07:40:09Z
Did not mark 50% due to the extent of the description.
#6 - c4-pre-sort
2023-08-24T08:20:02Z
141345 marked the issue as not a duplicate
#7 - c4-pre-sort
2023-08-24T08:20:23Z
141345 marked the issue as not a duplicate
#8 - c4-pre-sort
2023-08-24T08:22:14Z
141345 marked the issue as duplicate of #211
#9 - c4-judge
2023-08-26T21:24:29Z
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/9a2e7be003bc1a77b3b87db31f3d5a1bcb48ed32/src/VotingEscrow.sol#L371 https://github.com/code-423n4/2023-08-verwa/blob/9a2e7be003bc1a77b3b87db31f3d5a1bcb48ed32/src/VotingEscrow.sol#L384
User will not be able to withdraw their CANTO tokens if they delegate their power to another address through the delegate() function.
There are two steps involved in this:
Address A = User's address (I'll be using the terms "User" and "address A" interchangeably) Address B = The address User delegates his power to
1. User delegates power to B 2. User un-delegates power from address B and delegates back to his address A
Let's understand the first step in a detailed manner:
File: src/VotingEscrow.sol 356: function delegate(address _addr) external nonReentrant { 357: LockedBalance memory locked_ = locked[msg.sender]; 358: // Validate inputs 359: require(locked_.amount > 0, "No lock"); 360: require(locked_.delegatee != _addr, "Already delegated"); 361: // Update locks 362: int128 value = locked_.amount; 363: address delegatee = locked_.delegatee; 364: LockedBalance memory fromLocked; 365: LockedBalance memory toLocked; 366: locked_.delegatee = _addr; 367: if (delegatee == msg.sender) { 368: // Delegate 369: fromLocked = locked_; 370: toLocked = locked[_addr]; 371: } else if (_addr == msg.sender) { 372: // Undelegate 373: fromLocked = locked[delegatee]; 374: toLocked = locked_; 375: } else { 376: // Re-delegate 377: fromLocked = locked[delegatee]; 378: toLocked = locked[_addr]; 379: // Update owner lock if not involved in delegation 380: locked[msg.sender] = locked_; 381: } 382: require(toLocked.amount > 0, "Delegatee has no lock"); 383: require(toLocked.end > block.timestamp, "Delegatee lock expired"); 384: require(toLocked.end >= fromLocked.end, "Only delegate to longer lock"); 385: _delegate(delegatee, fromLocked, value, LockAction.UNDELEGATE); 386: _delegate(_addr, toLocked, value, LockAction.DELEGATE); 387: }
File: src/VotingEscrow.sol 363: address delegatee = locked_.delegatee; //@audit delegatee = msg.sender 366: locked_.delegatee = _addr; //@audit sets _addr to be new delegatee
File: src/VotingEscrow.sol 367: if (delegatee == msg.sender) { 368: // Delegate 369: fromLocked = locked_; 370: toLocked = locked[_addr]; 371: }
File: src/VotingEscrow.sol 382: require(toLocked.amount > 0, "Delegatee has no lock"); 383: require(toLocked.end > block.timestamp, "Delegatee lock expired"); 384: require(toLocked.end >= fromLocked.end, "Only delegate to longer lock");
File: src/VotingEscrow.sol 385: _delegate(delegatee, fromLocked, value, LockAction.UNDELEGATE); 386: _delegate(_addr, toLocked, value, LockAction.DELEGATE);
File: src/VotingEscrow.sol 390: function _delegate( 391: address addr, 392: LockedBalance memory _locked, 393: int128 value, 394: LockAction action 395: ) internal { 396: LockedBalance memory newLocked = _copyLock(_locked); 397: if (action == LockAction.DELEGATE) { 398: newLocked.delegated += value; 399: emit Deposit(addr, uint256(int256(value)), newLocked.end, action, block.timestamp); 400: } else { 401: newLocked.delegated -= value; 402: emit Withdraw(addr, uint256(int256(value)), action, block.timestamp); 403: } 404: locked[addr] = newLocked; 405: if (newLocked.amount > 0) { 406: // Only if lock (from lock) hasn't been withdrawn/quitted 407: _checkpoint(addr, _locked, newLocked); 408: } 409: }
This marks the end of the first step where the user delegated his power to address B sucessfully.
Let's understand Step 2 now where the user un-delegates power from address B and delegates back to his address A:
File: src/VotingEscrow.sol 356: function delegate(address _addr) external nonReentrant { 357: LockedBalance memory locked_ = locked[msg.sender]; 358: // Validate inputs 359: require(locked_.amount > 0, "No lock"); 360: require(locked_.delegatee != _addr, "Already delegated"); 361: // Update locks 362: int128 value = locked_.amount; 363: address delegatee = locked_.delegatee; 364: LockedBalance memory fromLocked; 365: LockedBalance memory toLocked; 366: locked_.delegatee = _addr; 367: if (delegatee == msg.sender) { 368: // Delegate 369: fromLocked = locked_; 370: toLocked = locked[_addr]; 371: } else if (_addr == msg.sender) { 372: // Undelegate 373: fromLocked = locked[delegatee]; 374: toLocked = locked_; 375: } else { 376: // Re-delegate 377: fromLocked = locked[delegatee]; 378: toLocked = locked[_addr]; 379: // Update owner lock if not involved in delegation 380: locked[msg.sender] = locked_; 381: } 382: require(toLocked.amount > 0, "Delegatee has no lock"); 383: require(toLocked.end > block.timestamp, "Delegatee lock expired"); 384: require(toLocked.end >= fromLocked.end, "Only delegate to longer lock"); 385: _delegate(delegatee, fromLocked, value, LockAction.UNDELEGATE); 386: _delegate(_addr, toLocked, value, LockAction.DELEGATE); 387: }
File: src/VotingEscrow.sol 363: address delegatee = locked_.delegatee; //@audit delegatee = msg.sender 366: locked_.delegatee = _addr; //@audit sets _addr to be new delegatee
File: src/VotingEscrow.sol 371: else if (_addr == msg.sender) { 372: // Undelegate 373: fromLocked = locked[delegatee]; 374: toLocked = locked_; 375: }
File: src/VotingEscrow.sol 382: require(toLocked.amount > 0, "Delegatee has no lock"); 383: require(toLocked.end > block.timestamp, "Delegatee lock expired"); 384: require(toLocked.end >= fromLocked.end, "Only delegate to longer lock");
File: src/VotingEscrow.sol 326: function withdraw() external nonReentrant { 327: LockedBalance memory locked_ = locked[msg.sender]; 328: // Validate inputs 329: require(locked_.amount > 0, "No lock"); 330: require(locked_.end <= block.timestamp, "Lock not expired"); 331: require(locked_.delegatee == msg.sender, "Lock delegated"); 332: // Update lock 333: uint256 amountToSend = uint256(uint128(locked_.amount)); 334: LockedBalance memory newLocked = _copyLock(locked_); 335: newLocked.amount = 0; 336: newLocked.end = 0; 337: newLocked.delegated -= int128(int256(amountToSend)); 338: newLocked.delegatee = address(0); 339: locked[msg.sender] = newLocked; 340: newLocked.delegated = 0; 341: // oldLocked can have either expired <= timestamp or zero end 342: // currentLock has only 0 end 343: // Both can have >= 0 amount 344: _checkpoint(msg.sender, locked_, newLocked); 345: // Send back deposited tokens 346: (bool success, ) = msg.sender.call{value: amountToSend}(""); 347: require(success, "Failed to send CANTO"); 348: emit Withdraw(msg.sender, amountToSend, LockAction.WITHDRAW, block.timestamp); 349: }
These two steps show us how delegation could be changed from address A to address B but not back from address B to address A due to the longer end time problem. Additionally, the delegation problem showed us how withdrawal of CANTO tokens is disallowed, thereby locking the user's CANTO. (Note: A new lock cannot be created through createLock() as well since the previous lock has not been withdrawn.)
Here is a Coded POC which proves that delegation back to user's address A is not possible:
forge test --match-test testDelegationBackToMsgSenderSuccess -vvvv
File: src/VotingEscrow.t.sol 346: function testDelegationBackToMsgSenderSuccess() public { 347: vm.prank(user1); 348: ve.createLock{value: LOCK_AMT}(LOCK_AMT); //user1 creates a lock 349: vm.warp(block.timestamp + 5 days); 350: vm.prank(user2); 351: ve.createLock{value: LOCK_AMT}(LOCK_AMT);//after 5 days, user2 creates a lock 352: vm.prank(user1); 353: ve.delegate(user2);//user1 delegates power to user2 354: vm.prank(user1); 355: ve.delegate(user1);//user1 tries to undelegate user2 and delegate back to himself (this should succeed but it fails since user1's end time is less than user2's) 356: }
Manual Review
We know the require check causing the problem is important for other cases. Therefore, the best solution would be to move the require check in the if and else blocks but not the else if block like this (updates made on Line 371,382):
File: src/VotingEscrow.sol 367: if (delegatee == msg.sender) { 368: // Delegate 369: fromLocked = locked_; 370: toLocked = locked[_addr]; 371: require(toLocked.end >= fromLocked.end, "Only delegate to longer lock");//@audit check added here 372: } else if (_addr == msg.sender) { 373: // Undelegate 374: fromLocked = locked[delegatee]; 375: toLocked = locked_; 376: } else { 377: // Re-delegate 378: fromLocked = locked[delegatee]; 379: toLocked = locked[_addr]; 380: // Update owner lock if not involved in delegation 381: locked[msg.sender] = locked_; 382: require(toLocked.end >= fromLocked.end, "Only delegate to longer lock");//@audit check added here 383: }
Note: Do not forget to remove the require check on Line 384 when making these changes.
Other
#0 - c4-pre-sort
2023-08-13T13:53:17Z
141345 marked the issue as duplicate of #178
#1 - 141345
2023-08-13T13:54:25Z
can increaseAmount()
to make A's end time longer to undelegate()
, not good solution though.
#2 - c4-judge
2023-08-24T07:13:56Z
alcueca marked the issue as duplicate of #82
#3 - c4-judge
2023-08-24T07:20:40Z
alcueca changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-08-24T07:41:25Z
alcueca marked the issue as satisfactory
#5 - mcgrathcoutinho
2023-08-28T21:50:52Z
Hi @alcueca, thank you for taking the time to read this.
I believe this issue is a High severity issue due to the following reasons:
As @141345 mentioned, increaseAmount() is not a good solution to this, especially because of the psychological impact it can cause to users who will now refrain from staking more funds and time into the protocol.
Thank you once again for re-evaluating this.
#6 - c4-judge
2023-08-29T06:37:36Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: RED-LOTUS-REACH
Also found by: 0x3b, 0x4non, 0xCiphky, 0xDING99YA, 0xDetermination, 0xE1, 0xG0P1, 0xStalin, 0xWaitress, 0xbrett8571, 0xhacksmithh, 0xkazim, 0xmuxyz, 0xweb3boy, 14si2o_Flint, AlexCzm, Alhakista, Bube, Bughunter101, Deekshith99, Eeyore, Giorgio, HChang26, InAllHonesty, JP_Courses, KmanOfficial, MatricksDeCoder, Mike_Bello90, MrPotatoMagic, Naubit, QiuhaoLi, RHaO-sec, Raihan, Rolezn, SUPERMAN_I4G, Shubham, Silverskrrrt, Strausses, T1MOH, Topmark, Tripathi, Watermelon, _eperezok, aakansha, auditsea, audityourcontracts, ayden, carlos__alegre, castle_chain, cducrest, ch0bu, d23e, deadrxsezzz, deth, devival, erebus, fatherOfBlocks, halden, hassan-truscova, hpsb, hunter_w3b, imkapadia, immeas, jat, kaden, kaveyjoe, klau5, koxuan, kutugu, ladboy233, lanrebayode77, leasowillow, lsaudit, markus_ether, matrix_0wl, merlin, nemveer, ni8mare, nonseodion, oakcobalt, owadez, p_crypt0, pipidu83, piyushshukla, popular00, ppetrov, rjs, sandy, sl1, supervrijdag, tay054, thekmj, wahedtalash77, windhustler, zhaojie
9.8204 USDC - $9.82
QA | Issues | Instances |
---|---|---|
[N-01] | Public variables only used within contract can be changed to private visibility | 30 |
[N-02] | Variables that are unchanging should be marked constant or immutable if assigned in constructor | 7 |
[N-03] | Missing event emission for critical storage changes in functions | 11 |
[N-04] | Remove redundant code and comments to improve code readability and maintainability | 6 |
[N-05] | Consider using delete instead of assigning address(0) to clear values | 1 |
[N-06] | Require statements should provide an error message in case condition fails | 2 |
[N-07] | Modify code to improve code readability and maintainability | 1 |
[N-08] | Return values not handled in functions | 5 |
[N-09] | Duplicated require/if statements should be refactored | 1 |
[L-01] | Require check incorrectly implemented | 1 |
[L-02] | Loss of precision due to division occurring before multiplication | 9 |
[L-03] | Slope can round down to zero if numerator is smaller than denominator | 1 |
[L-04] | LOCKTIME does not consider for 2 or more extra days introduced due to leap years | 1 |
[L-05] | Consider skipping the epoch for which rewards are already set | 1 |
Total Non-Critical issues: 64 instances across 9 issues Total Low-Severity issues: 13 instances across 5 issues Total issues: 77 instances across 14 issues
Public variables that are only used within the contract can be marked private. There are 30 instances of this:
There are 3 instances here:
File: src/VotingEscrow.sol 26: string public name; 27: string public symbol; 28: uint256 public decimals = 18;
There are 6 instances here:
File: src/VotingEscrow.sol 36: uint256 public globalEpoch; 37: Point[1000000000000000000] public pointHistory; // 1e9 * userPointHistory-length, so sufficient for 1e9 users 38: mapping(address => Point[1000000000]) public userPointHistory; 39: mapping(address => uint256) public userPointEpoch; 40: mapping(uint256 => int128) public slopeChanges; 41: mapping(address => LockedBalance) public locked;
https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/LendingLedger.sol#L13C1-L27C1 https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/LendingLedger.sol#L32
There are 9 instances here:
File: src/LendingLedger.sol 13: address public governance; 14: GaugeController public gaugeController; 15: mapping(address => bool) public lendingMarketWhitelist; 16: /// @dev Lending Market => Lender => Epoch => Balance 17: mapping(address => mapping(address => mapping(uint256 => uint256))) public lendingMarketBalances; // cNote balances of users within the lending markets, indexed by epoch 18: /// @dev Lending Market => Lender => Epoch 19: mapping(address => mapping(address => uint256)) public lendingMarketBalancesEpoch; // Epoch when the last update happened 20: /// @dev Lending Market => Epoch => Balance 21: mapping(address => mapping(uint256 => uint256)) public lendingMarketTotalBalance; // Total balance locked within the market, i.e. sum of lendingMarketBalances for all 22: /// @dev Lending Market => Epoch 23: mapping(address => uint256) public lendingMarketTotalBalanceEpoch; // Epoch when the last update happened 24: 25: /// @dev Lending Market => Lender => Epoch 26: mapping(address => mapping(address => uint256)) public userClaimedEpoch; // Until which epoch a user has claimed for a particular market (exclusive this value) 32: mapping(uint256 => RewardInformation) public rewardInformation;
There are 12 instances here:
File: src/GaugeController.sol 24: VotingEscrow public votingEscrow; 25: address public governance; 26: mapping(address => bool) public isValidGauge; 27: mapping(address => mapping(address => VotedSlope)) public vote_user_slopes; 28: mapping(address => uint256) public vote_user_power; 29: mapping(address => mapping(address => uint256)) public last_user_vote; 30: 31: mapping(address => mapping(uint256 => Point)) public points_weight; 32: mapping(address => mapping(uint256 => uint256)) public changes_weight; 33: mapping(address => uint256) time_weight; 34: 35: mapping(uint256 => Point) points_sum; 36: mapping(uint256 => uint256) changes_sum; 37: uint256 public time_sum;
Variables than do not change can be marked constant and those that do not change after assignment during construction time can be marked immutable There are 7 instances of this:
There are 3 instances here: Vsriables name and symbol can be marked immutable and decimals can be marked constant.
File: src/VotingEscrow.sol 26: string public name; 27: string public symbol; 28: uint256 public decimals = 18;
There are 2 instances here: Variables can be made immutable since they do not change after construction time.
File: src/GaugeController.sol 24: VotingEscrow public votingEscrow; 25: address public governance;
There are 2 instances here: Variables can be made immutable since they do not change after construction time.
File: src/LendingLedger.sol 13: address public governance; 14: GaugeController public gaugeController;
Functions that make storage updates or critical configuration updates are missing events and/or their emissions. There are 11 instances of this:
Redundant code logic and comments serving no purpose in the codebase can be removed to improve code readability and maintainability.
There are 6 instances of this:
We do not need to check if _user_weight >= 0
in the require statement below since the input _user_weight
is taken as an uint256 parameter, thus it will always be greater than or equal to 0. If the function is called with _user_weight
less than zero, the function reverts automatically due to it not being an uint256. Thus, the first check can be removed.
File: src/GaugeController.sol 212: require(_user_weight >= 0 && _user_weight <= 10_000, "Invalid user weight");
The if block below can be removed since Line 143 is being overwritten on Line 149 here. The uEpoch is 0 only when the user is creating a lock for the first time. Thus, since it is being overwritten anyway, the if block can be removed.
File: src/VotingEscrow.sol 142: if (uEpoch == 0) { 143: userPointHistory[_addr][uEpoch + 1] = userOldPoint; 144: }
Line 340 below can be removed since it serves no purpose in the code. It is not related to the call to _checkPoint() on Line 344 as well.
File: src/VotingEscrow.sol 340: newLocked.delegated = 0;
Note: Although _checkpoint
theoretically reads it below, _newLocked.end > block.timestamp
is never true in this case (as the end is 0 since it was updated in the withdraw() function), thus should be safe to remove
if (_newLocked.end > block.timestamp && _newLocked.delegated > 0) { userNewPoint.slope = _newLocked.delegated / int128(int256(LOCKTIME)); userNewPoint.bias = userNewPoint.slope * int128(int256(_newLocked.end - block.timestamp)); }
TODO statement should be solved and removed.
File: src/LendingLedger.sol 48: governance = _governance; // TODO: Maybe change to Oracle
TODO statement should be solved and removed.
File: src/GaugeController.sol 59: governance = _governance; // TODO: Maybe change to Oracle
Import can be removed since it is not used anywhere in the LendingLedger.sol contract.
File: src/LendingLedger.sol 4: import {VotingEscrow} from "./VotingEscrow.sol";
(Note: This instance is missing in the [N-31] bot finding)
The delete keyword more closely matches the semantics of what is being done (deletion of lock), and draws more attention to the changing of state, which may lead to a more thorough audit of its associated logic.
There is 1 instance of this:
File: src/VotingEscrow.sol 338: newLocked.delegatee = address(0);
An error message should be added to provide meaning on failure of condition in require statements.
There are 2 instances of this:
A short error message like !auth
should be provided for clarity on failure.
File: src/LendingLedger.sol 42: require(msg.sender == governance);
A short error message like !auth
should be provided for clarity on failure.
File: src/GaugeController.sol 51: require(msg.sender == governance);
Code should be refactored to improve code readability and maintainability. This removes any additional redundant code as well.
There is 1 instance of this issue:
Instead of this:
File: src/GaugeController.sol 60: uint256 last_epoch = (block.timestamp / WEEK) * WEEK; 61: time_sum = last_epoch;
Use this:
File: src/GaugeController.sol 60: time_sum = (block.timestamp / WEEK) * WEEK;
There are 5 instances here:
The _get_weight() and _get_sum() functions return uint256 values. But they are not handled here. There are 2 instances here:
File: src/GaugeController.sol 180: _get_weight(_gauge); 181: _get_sum();
_get_sum() returns an uint256 value, which is not handled here. There is 1 instance here:
File: src/GaugeController.sol 136: _get_sum();
The _get_weight() and _get_sum() functions return uint256 values. But they are not handled here. There are 2 instances here:
File: src/GaugeController.sol 142: _get_weight(_gauge); 143: _get_sum();
(Note: The instance below is not included in the [N-10] bot finding)
There is 1 instance of this:
https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/GaugeController.sol#L128 https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/GaugeController.sol#L213C69-L213C69
File: src/GaugeController.sol 128: require(isValidGauge[_gauge], "Invalid gauge address"); //@audit Duplicate on Line 213 213: require(isValidGauge[_gauge_addr], "Invalid gauge address");
There is 1 instance of this issue:
The check below is implemented as follows with the >
operator:
File: src/VotingEscrow.sol 294: require(locked_.end > block.timestamp, "Lock expired");
But the @dev
tag on Line 287 mentions it should use >=
operator:
File: src/VotingEscrow.sol 287: // @dev A lock is active until both lock.amount==0 and lock.end<=block.timestamp
Solution:
File: src/VotingEscrow.sol 294: require(locked_.end >= block.timestamp, "Lock expired");
(Note: This is different from the [L-08] bot finding
Performing multiplication before division is generally better to avoid loss of precision because Solidity integer division might truncate. See here
Most of the instances below are timing-related, thus it is important to ensure that any operations that determine time are as precise as possible so that any reads and writes from/to storage are synced and correct. There are 9 instances of this:
File: src/VotingEscrow.sol 425: return (_t / WEEK) * WEEK;
File: src/GaugeController.sol 60: uint256 last_epoch = (block.timestamp / WEEK) * WEEK;
File: src/GaugeController.sol 153: uint256 t = (_time / WEEK) * WEEK;
File: src/GaugeController.sol 191: uint256 next_time = ((block.timestamp + WEEK) / WEEK) * WEEK;
File: src/GaugeController.sol 224: uint256 next_time = ((block.timestamp + WEEK) / WEEK) * WEEK;
File: src/LendingLedger.sol 60: uint256 currEpoch = (block.timestamp / WEEK) * WEEK;
File: src/LendingLedger.sol 84: uint256 currEpoch = (block.timestamp / WEEK) * WEEK;
File: src/LendingLedger.sol 134: uint256 currEpoch = (block.timestamp / WEEK) * WEEK;
File: src/LendingLedger.sol 162: uint256 currEpoch = (block.timestamp / WEEK) * WEEK;
(Note: The instance below is not included in the [L-08] bot finding)
Solidity doesn't support fractions, so divisions by large numbers could result in the quotient being zero. To avoid this, it's recommended to require a minimum numerator amount to ensure that it is always greater than the denominator.
There is 1 instance of this:
If slope * _user_weight
is less than 10_000, it can lead to rounding to zero and incorrect evaluations in the statements following it.
File: src/GaugeController.sol 231: slope: (slope * _user_weight) / 10_000,
The current LOCKTIME considers that all 5 years are 365 days long. We could let this by if there was only leap year that the lock comes across during its lifespan of 5 years. But that is not the case here.
Let's take three examples:
Suppose a lock is created in 2024, the end time for it would be 2029. Since 2024 to 2029 includes two leap years (2024 and 2028), this adds 2 extra days. But since the locktime considers each year to be 365 days long, we skip these 2 extra days. (Note: It is highly likely such an issue occurs as 2024 is next year and with the launch of this fresh codebase, there are high chances of locks being created in 2024).
Suppose a lock is created in 2023, the end time for it would be 2028. Since 2023 to 2028 includes one leap year (2024, the lock ends at start of 2028). this adds 1 extra day. We can let this by but if the lock is extended by increaseAmount() function, the introduction of a second leap year (2028) is possible. In that case, two extra days are not added to the lifespan of the lock.
If any lock created is extended with increaseAmount() function, that will introduce an additional leap year (i.e. an extra day) which is not accounted for in the lifespan of the contract. This adds up if the user keeps on extending their lock.
Solution: If you go to see technically, each year is 365 days and 6 hours long. That is why we have a leap year every 4 years (6 * 4 = 24 hours = 1 day) since the 6 hours are accounted in the leap year. The solution to this problem would be to change the LOCKTIME from 1825 days to 1828 days. This is because 1825/5 = 365 days but 1828/5 = 365.6 (i.e. 1828 days accounts for the 6 hours in each year, thus now making each year 365 days and 6 hours long rather than 365 days only). Note that there is no rounding error occurring here since when creating a lock, we are just adding the days to block.timestamp, which is then rounded down to weeks.
File: src/VotingEscrow.sol 32: uint256 public constant LOCKTIME = 1825 days; //@audit make this 1828 days
Here is the discussion between the sponsor and I to gain some context before reading my solution:
Warden:
Hi, over here do you think it would make sense to just "continue" the for loop rather than revert if rewards are already set? That way it might be easy for the governance to set the rewards as well I think. https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/LendingLedger.sol#L195
Sponsor:
I revert there on purpose because if there is a governance proposal to set rewards for epochs X to X + Y, I think it should either set it for all epochs or fail (and require a new one). Only setting it for a few could be a bit confusing imo, but would of course also work
Warden:
hmm what I was thinking is if we set rewards for epochs X to X + Y, there might be an epoch X + 1 which might have rewards already set and there is no way to set it's ri.set to false. Therefore the governance might need to set the rewards as X to X and X + 2 to X + Y to go around X + 1. I think it is highly unlikely something like this would occur but the case is still valid I believe. Lmk what you think about this.
Sponsor:
Yes exactly, in such a scenario I would prefer two separate proposals with [X, X] and [X + 2, X + Y] because I think the danger with one proposal for [X, X + Y] is that voters expect that the rewards will be set for the whole range
Warden:
hmm your right that makes sense from the voters perspective for ease of understandability and I believe is the way to go. Just had an additional point that if there is more than 1 epoch for which rewards are set then I believe it might be time consuming and a bit heavy work-wise on the voter's side. Again the likelihood for this is low.
Solution:
If ri.set is false it can lead to reversion of all previous storage updates. This can prevent the governance from setting rewards per epoch in the range fromEpoch to toEpoch. Although this reversion is the desired behaviour (i.e. what the sponsor expects - discussed with sponsor) and can be solved with two separate proposals to go around the epoch (rewards for which are already set), I think it might be time consuming and a bit heavy work-wise on the governance's side if there is more than 1 epoch for which rewards are already set. That is why I believe using the continue keyword is much better for ease of operation. Additionally, an event can be emitted for an epoch for which rewards are already set in order to make the governance aware of them. (Note: This event emission is necessary as voters are expecting that the rewards will be set for the whole range - as mentioned by the sponsor above)
Instead of this:
File: src/LendingLedger.sol 188: function setRewards( 189: uint256 _fromEpoch, 190: uint256 _toEpoch, 191: uint248 _amountPerEpoch 192: ) external is_valid_epoch(_fromEpoch) is_valid_epoch(_toEpoch) onlyGovernance { 193: for (uint256 i = _fromEpoch; i <= _toEpoch; i += WEEK) { 194: RewardInformation storage ri = rewardInformation[i]; 195: require(!ri.set, "Rewards already set"); 196: ri.set = true; 197: ri.amount = _amountPerEpoch; 198: } 199: }
Use this (changes made on lines 195,196,197,198):
File: src/LendingLedger.sol 188: function setRewards( 189: uint256 _fromEpoch, 190: uint256 _toEpoch, 191: uint248 _amountPerEpoch 192: ) external is_valid_epoch(_fromEpoch) is_valid_epoch(_toEpoch) onlyGovernance { 193: for (uint256 i = _fromEpoch; i <= _toEpoch; i += WEEK) { 194: RewardInformation storage ri = rewardInformation[i]; 195: if(!ri.set) { 196: emit RewardAlreadySetForEpoch(i); //@audit this can make the sponsor aware of the epochs for which rewards are already set before continuing 197: continue; 198: } 199: ri.set = true; 200: ri.amount = _amountPerEpoch; 201: } 202: }
#0 - c4-judge
2023-08-22T14:11:52Z
alcueca marked the issue as grade-a
🌟 Selected for report: catellatech
Also found by: 0x73696d616f, 0xSmartContract, 0xbrett8571, MrPotatoMagic, RED-LOTUS-REACH, rjs, thekmj
My issues comprise 2 Highs, 1 Medium, 9 Non-Critical and 5 Low issues. Below listed are some important contextual points on both my high severity findings, 1 Medium and 1 low severity finding.
Root cause of both my highs are different - The impact of both the high risk findings is the same (i.e. locking of user's funds) but the root cause is different. The first issue arises due to the user's end time being shorter than the previous assigned delegatee's. The second issue arises due to the user's lock expiring under an existing delegation to another delegatee. Why both these issues look similar (though they're not) is due to the user not being able to transfer delegation back to themselves. Even if we solve the first issue, the second issue still persists. This I believe is proof enough for them to be considered as separate issues.
My Medium issue challenges the LOCKTIME
of 5 years, which is native to the project idea. Due to this, an issue arises where the user does not receive any voting power on deposits below 1825 CANTO. I've added more context on this issue in the following sections below. Additionally, this issue should be specified in the documentation for security researchers in future audits and made aware to the user by mentioning that a minimum deposit of 1825 CANTO is required to receive voting power.
One of my low severity issues - [L-05] Consider skipping the epoch for which rewards are already set
- includes my discussions with the sponsor to provide more context on why I think my solution would be more useful for the governance/voters.
The scope included only 3 files, therefore it was easy to identify the base to child contract path. It as follows:
VotingEscrow.sol (parentA) <= GaugeController.sol (childA parentB) <= LendingLedger.sol (childA childB)
Day 1 : Audited VotingEscrow.sol with 413 SLOC (heaviest contract)
Day 2 : Audited GaugeController.sol and LendingLedger.sol with 336 SLOC combined (lighter contracts)
Day 3 : Creating reports and proving validity of findings through tests
During all 3 days, I had several discussions with the sponsor on the architecture of the codebase (which I have documented in one of my low severity findings), especially because it is an implementation of the FIATDAO VotingEscrow contract with certain functions removed and modifications made.
The sponsor has kept the codebase one step ahead in terms of security by implementing an already audited FIATDAO codebase. But the modifications made introduce two high severity and 1 Medium severity security bugs. Here is how:
First modification: The increaseUnlockTime()
function has been removed in the VotingEscrow.sol contract while the FIATDAO VE contract includes it. After following the happy case and spotting the 2 high issues, I believe this increaseUnlockTime() function is important and should be added with access control to the codebase as it will allow the person with access to extend a user's unlock time to a certain extent, allowing them to withdraw their locked CANTO.
Second modification: The LOCKTIME
for a user's lock has been fixed to 5 years, which is native to the project's idea I believe. But this introduces a higher entry barrier to those who want to create locks. To prevent the rounding issue (as mentioned in my issue #299), the user needs to deposit atleast 1825 CANTO to have valid voting power greater than 0. (Note: The barrier might look low right now since we're in a bear market and 1825 CANTO is around 200 USD at the time of reading. But as we enter the bull market, the barrier becomes higher). We could say that the barrier is proportional to the price of 1825 CANTO. Even if this LOCKTIME is native to the project idea, I would recommend the sponsor to reconsider it.
Other than these modifications, there is another change I would recommend in the architecture of the setRewards() function:
[L-05] Consider skipping the epoch for which rewards are already set
- This finding is included in my QA report but it is worth mentioning here as well. The governance sets the rewards for epochs in the range X to Y, but if there is an epoch [X+1] in this range for which rewards are already set, all the previous changes made revert. Although this reversion is the desired behaviour (i.e. what the sponsor expects - discussed with sponsor) and can be solved with two separate proposals to go around the epoch [X+1] (rewards for which are already set) in the manner [X,X] and [X+2,Y], I think it might be time consuming and a bit heavy work-wise on the governance's side if there is more than 1 epoch for which rewards are already set. That is why I believe using the continue keyword is much better for ease of operation. Additionally, an event can be emitted for an epoch for which rewards are already set in order to make the governance aware of them. (Note: This event emission is necessary as voters are expecting that the rewards will be set for the whole range - as mentioned by the sponsor)The codebase quality is between above average. Why not Medium or High you may ask? I say this due to 2 reasons:
newLocked.amount
and newLocked.delegated
storage variables correctly. Due to this, I would assign it the High rank.Gauge model used: Linear decay
Function/Equation for this decaying linear model: W(t) = m*t + b , where b is the bias and m is the slope, and t is the time.
Brief explanation of the model: A user can lock up tokens and receive voting power. User's voting power decays linearly over time (in our case over 5 years, i.e. is 0 after 5 years). The VotingEscrow.sol stores the linear function (which is parameterized by a slope and a bias) of this user and then you can always linearly interpolate the time if amount for lock is increased by increaseAmount() function.
Here is a mindmap created to get an idea of the important contract and their function flow: https://drive.google.com/file/d/1RQ3WPYnjOEBIg1FDJfJUZDjwhN1Xl1Ua/view?usp=sharing
30 hours
#0 - c4-judge
2023-08-22T07:00:13Z
alcueca marked the issue as grade-a