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: 11/125
Findings: 3
Award: $360.24
🌟 Selected for report: 1
🚀 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/main/src/VotingEscrow.sol#L326-L349 https://github.com/code-423n4/2023-08-verwa/blob/main/src/VotingEscrow.sol#L356-L387
VotingEscrow.sol
contract for a duration of 5 years. This lock grants them voting power corresponding to the locked tokens.VotingEscrow::delegate()
function. It's important to note that a user can only delegate their voting power to another user who has a longer lock duration.VotingEscrow
contract.Let's illustrate this through a scenario:
User A
locks 50 CANTO tokens and gains corresponding voting power.User Z
locks 10 CANTO tokens.User A
delegates their voting power to User Z
.User Z
withdraws all initially deposited CANTO tokens.User A
attempts to withdraw their CANTO tokens, they encounter a problem. Their voting power is not self-delegated.User A
tries to delegate their voting power back to themselves, it's impossible due to the requirement of a longer lock period for delegation. This creates an effectively repeating delegation cycle.User A
is to create a new lock with a new account, let's say User D
, and then delegate their voting power to this new account.User A
remain perpetually locked within the VotingEscrow
contract.Copy the following code to 2023-08-verwa/src/test/PoC.t.sol
and run forge test --mc "PoC" -vvvv
// SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.16; import "forge-std/Test.sol"; import "../VotingEscrow.sol"; contract PoC is Test { VotingEscrow public ve; address public constant userA = address(10001); address public constant userD = address(10002); address public constant userZ = address(10004); uint256 public constant LOCK_AMT_A = 50 ether; uint256 public constant LOCK_AMT_Z = 10 ether; uint256 public constant LOCK_AMT_D = 1 ether; uint256 public constant LOCK_DUR = 1825 days; function setUp() public { ve = new VotingEscrow("Voting Escrow", "VE"); vm.deal(userA, 100 ether); vm.deal(userD, 100 ether); vm.deal(userZ, 100 ether); } function test_WithdrawDelegtedFail() public { //1. User Z locks 30 for 5 years vm.prank(userZ); ve.createLock{value: LOCK_AMT_Z}(LOCK_AMT_Z); //2. User A locks and delegates 50 to User Z vm.prank(userA); ve.createLock{value: LOCK_AMT_A}(LOCK_AMT_A); vm.prank(userA); ve.delegate(userZ); //3. After 5 years, User Z withdraws first vm.warp(LOCK_DUR + 1); vm.prank(userZ); ve.withdraw(); //4. User A tries to withdraw, but fails! vm.prank(userA); vm.expectRevert(); ve.withdraw(); vm.prank(userD); ve.createLock{value: LOCK_AMT_D}(LOCK_AMT_D); //5. User A tries to delegate to User D vm.prank(userA); ve.delegate(userD); //6. User A then again tries to withdraw vm.prank(userA); vm.expectRevert("Lock delegated"); ve.withdraw(); //⚠️ User A can't withdraw! CANTO is LOCKED ⚠️ } }
Manual Review and Foundry
delegate()
that a if a user's lock time is over then they are allowed to delegate the voting power to themselves so that they can withdraw their funds.Invalid Validation
#0 - c4-pre-sort
2023-08-12T11:06:41Z
141345 marked the issue as primary issue
#1 - 141345
2023-08-13T16:33:22Z
Also depends on if user A's lock period has expried or not. In this POC user A did nothing, so A's lock also expires.
not exactly the same, but the similar reason, combine with https://github.com/code-423n4/2023-08-verwa-findings/issues/112
#2 - c4-pre-sort
2023-08-13T16:33:25Z
141345 marked the issue as duplicate of #178
#3 - c4-pre-sort
2023-08-13T17:42:33Z
141345 marked the issue as not a duplicate
#4 - c4-pre-sort
2023-08-14T07:26:34Z
141345 marked the issue as duplicate of #112
#5 - c4-judge
2023-08-24T07:16:08Z
alcueca marked the issue as duplicate of #82
#6 - c4-judge
2023-08-24T07:20:40Z
alcueca changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-08-24T07:30:15Z
alcueca marked the issue as satisfactory
#8 - c4-pre-sort
2023-08-24T08:20:06Z
141345 marked the issue as not a duplicate
#9 - c4-pre-sort
2023-08-24T08:20:23Z
141345 marked the issue as not a duplicate
#10 - c4-pre-sort
2023-08-24T08:22:42Z
141345 marked the issue as duplicate of #211
#11 - c4-judge
2023-08-26T21:24:29Z
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
12.7665 USDC - $12.77
Invariants to the VotingEscrow contract can be broken. While an enormous quantity is necessary, greater than the total supply of $CANTO, it’s worth mentioning due to the fact that invariants in the system can be broken, if only in testing environments.
Invariants affected in VotingEscrow.sol
:
LockedBalance.delegated
must always be > 0
LockedBalance.amount
must always be > 0
Point.slope
must always be ≥ 0
Point.bias
must always be ≥ 0
Cases in VotingEscrow.sol
where casting creates vulnerable attack surfaces:
newLocked.delegated += int128(int256(_value));
newLocked.delegated += int128(int256(_value));
locked_.amount += int128(int256(_value));
locked_.delegated += int128(int256(_value));
newLocked.amount += int128(int256(_value));
newLocked.delegated += int128(int256(_value));
Debugging and auditing this code is made more complex due to the inclusion of this casting, which puts the protocol more at risk than had they been unused.
It's not fully clear why the types for amount
and delegated
of the LockedBalance
struct are of type int128
. This could introduce unforseen edge cases due to the copious casting. All assignments from function arguments are of type uint256
(coming from createLock()
and increaseAmount()
), making the usage more peculiar and inconsistent. Similarly, int128
is used for bias
and slope
of the Point
struct, and in all places where they are used for calculations, they must be checked for being negative and are subsequently reset to 0
in the case of being negative. The suspicion is that since the origin of the ve
system came from the original Curve contracts, the implicit assumptions of that system were kept wholesale.
Use unsigned integers for all financial calculations so that all casting is removed and remove the subsequent unnecessary "resets" to 0
. Instead, for places where the "resets" are used, define pre-conditions that ensure negative-valued outcomes are not permissible in calls to createLock()
and increaseLock()
.
Similar to the previous “Casting between types makes values negative for huge input values”, huge inputs can allow for attacks on the protocol, if only in a testing environment.
In VotingEscrow.sol
the code in question is from _checkpoint()
, lines 129 - 136:
// When a huge deposit is made, the casting of _value in createLock can allow delegated to overflow into negative a value... if (_oldLocked.end > block.timestamp && _oldLocked.delegated > 0) { // ...these can become negative as a result userOldPoint.slope = _oldLocked.delegated / int128(int256(LOCKTIME)); userOldPoint.bias = userOldPoint.slope * int128(int256(_oldLocked.end - block.timestamp)); } if (_newLocked.end > block.timestamp && _newLocked.delegated > 0) { // ...these can become negative as a result userNewPoint.slope = _newLocked.delegated / int128(int256(LOCKTIME)); userNewPoint.bias = userNewPoint.slope * int128(int256(_newLocked.end - block.timestamp)); }
Control over the sign of bias
and slope
allows for the control of decay of voting power, because at any point they become < 0, they are reset to 0, giving the theoretical attacker the ability to temporarily stem the decay of their voting power.
Similar to “Casting between types makes values negative for huge input values”, bypass the need to cast (and automatically hard-lock values to 0) by using unsigned types with guards for relevant pre-conditions that ensure negative-valued outcomes are not permissible in calls to createLock()
and increaseLock()
.
Locks for veRWA are set for 5 years, given no further actions on a lock are performed. This 5 year period is an important duration, because it also determines the calculations for the decay in voting power a lock has. In **VotingEscrow.sol**
, the 5 year lock period is measured as 1825 days.
Places where the 1785 day measurement is used:
_checkpoint()
, line 185for (uint256 i = 0; i < 255; i++) { iterativeTime = iterativeTime + WEEK; ... if (iterativeTime == block.timestamp) { lastPoint.blk = block.number; break; } ... }
_supplyAt()
, line 538function _supplyAt(..., uint256 _t) ... { for (uint256 i = 0; i < 255; i++) { iterativeTime = iterativeTime + WEEK; ... if (iterativeTime == _t) { break; } } }
There is a possibility that the protocol faces a case where locked funds are untouched and no new funds are locked across a 5 year period. In this “last users standing” scenario, the protocol theoretically is still responsible for calculating the final voting weights, allowing investors to maximize the utility of their locked funds regardless of the relevancy of the protocol.
Surprisingly, the maximum span the protocol can actually measure is less than 5 years, it is 255 weeks, which equates to 1785 days. For those final 40 days and afterwards, for example, weights will be miscalculated, and, especially depending on the size of the funds locked, returns on those funds or supply totals will be miscalculated.
Increase the number of iterations to fully calculate weights through the 5 year period.
Reentrancy is a common root-cause for exploits in smart contracts. To avoid the specific case of basic reentrancy, a nonReentrant
modifier is often used. In the case of a handful of functions in VotingEscrow.sol
, this modifier is unnecessary, because the cause of reentrancy - calling into external contracts - is also missing. While they are not a risk, they do cause extra computation, and thus raise gas costs, which may be of some notice especially during high network congestion.
Locations where they exist:
function createLock(uint256 _value) external payable nonReentrant {
function increaseAmount(uint256 _value) external payable nonReentrant {
function withdraw() external nonReentrant {
function delegate(address _addr) external nonReentrant {
No identified risk, since no external contract calls are made in these functions. However there is the cost of computation from running the modifiers during each call, thus increasing gas costs.
Remove the modifiers from the function definitions.
While there wasn’t enough time to explore possible attack vectors for these cases, it’s apparent that there is definite loss in precision while calculating important values in the system.
_checkpoint()
in VotingEscrow.sol
, lines 129 - 132:if (_oldLocked.end > block.timestamp && _oldLocked.delegated > 0) { userOldPoint.slope = _oldLocked.delegated / int128(int256(LOCKTIME)); userOldPoint.bias = userOldPoint.slope * int128(int256(_oldLocked.end - block.timestamp)); }
_checkpoint()
in VotingEscrow.sol
, lines 133 - 136: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)); }
_checkpoint()
in VotingEscrow.sol
, lines 176 - 218uint256 blockSlope = 0; // dblock/dt if (block.timestamp > lastPoint.ts) { blockSlope = (MULTIPLIER * (block.number - lastPoint.blk)) / (block.timestamp - lastPoint.ts); } // Go over weeks to fill history and calculate what the current point is uint256 iterativeTime = _floorToWeek(lastCheckpoint); for (uint256 i = 0; i < 255; i++) { ... // PRECISION LOSS HERE: blockslope was originally calculated w/ a division, now it's multiplied further lastPoint.blk = initialLastPoint.blk + (blockSlope * (iterativeTime - initialLastPoint.ts)) / MULTIPLIER; ... }
_checkpoint_lender()
in LendingLedger.sol, line 60:uint256 currEpoch = (block.timestamp / WEEK) * WEEK;
Precision loss in critical calculations can make the risk/reward for investors less appealing. In critical cases there could be possible losses involved when these discrepancies are exploited.
Reorder operations to ensure division happens before multiplication in the listed instances.
NatSpec is a boon to all Solidity developers. Not only does it provide a structure for developers to document their code within the code itself, it encourages the practice of documenting code. When future developers read code documented with NatSpec, they are able to increase their capacity to understand, upgrade, and fix code. Without code documented with NatSpec, that capacity is hindered.
The veRWA codebase does have a high level of documentation with NatSpec. However there are numerous instances of functions missing NatSpec.
Most functions that handle accounting for funds in smart contracts typically have a complementary function to provide the opposite operation. In the case of LendingLedger.sol
, there is the receive
function, however there is no related accounting in that function, or a way to recover funds sent to that contract.
receive() external payable {} // No way to get funds out, no accounting for balance in contract
Any CANTO deposited in the contract will be locked there because there is no method to transfer it back to the depositor.
Remove the receive function.
Using old versions of 3rd-party libraries in the build process can introduce vulnerabilities to the protocol code.
Review the latest version of the OpenZeppelin contracts and upgrade
Meaningful error messages give better handles to test against. While building a test suite, edge cases can become numerous, and providing descriptive error messages allows not just for the project developers to write better tests, it helps developers of 3rd-party integrations and forks (other protocols, token projects, etc) to understand the intentions and reasoning of the parent project.
In the veRWA codebase, there are places where the error cases are materially different from the guards they are included with.
VotingEscrow.sol
, line 573:require(_blockNumber <= block.number, "Only past block number"); //Should be “Only before block number”
Use more descriptive handler names and error messages and limit reusing error messages for categorically-similar-yet-different errors.
totalSupply
, totalSupplyAt
, supplyAt
in VotingEscrow.sol
refer to voting power, not locked supply. This causes confusion to developers or auditors onboarding to the codebase.
In many cases, dead code can create attack surfaces for malicious exploits. In the case for VotingEscrow.sol
, there are instances of dead and unused code, though the risks are much lower.
_checkpoint()
in VotingEscrow.sol
, line 206:lastCheckpoint = iterativeTime; // <= this is never used
Ex. VotingEscrow.sol, line 62 and 64
enum LockAction { ... INCREASE_TIME, // <= never used QUIT, // <= never used ... }
While no attack vectors were identified from these instances of dead code, there is a definite increase in gas usage during execution and deployment.
Remove the instances of dead code from the codebase.
Ex. LendingLedger.sol
, line 48 and GaugeController.sol
line 59
governance = _governance; // TODO: Maybe change to Oracle
The references to IVotingEscrow are misleading because there is no IVotingEscrow anywhere in the codebase. This makes it difficult to trust comments in the codebase, let alone find the referenced documentation that may enlighten the developer or auditor.
Examples:
#0 - c4-judge
2023-08-22T13:36:06Z
alcueca marked the issue as selected for report
#1 - alcueca
2023-08-28T21:03:54Z
"Unnecessary or Uncomplemented receive function" is invalid, the CANTO sent to LendingLedger is extracted through claim
.
🌟 Selected for report: catellatech
Also found by: 0x73696d616f, 0xSmartContract, 0xbrett8571, MrPotatoMagic, RED-LOTUS-REACH, rjs, thekmj
As part of the RED-LOTUS team’s audit approach, we identify previous audit findings from original implementation of the protocol code, in the case Curve’s GaugeController.vy. The following High finding within the 2020 Trail of Bits audit of Curve DAO (https://solodit.xyz/issues/several-loops-are-not-executable-due-to-gas-limitation-difficulty-high-trailofbits-curve-dao-pdf) was examined to see if it was applicable to the veRWA codebase.
While GaugeController is based on Curve Finance’s protocol, it can be observed that the main modifications from the Curve implementation are that gauge types have been removed, different whitelisting of gauge addresses is implemented due to the removed types and specific gauges have been removed also. Furthermore, the Governance role / address plays a more crucial and centralized role in administration of the protocol.
To understand Canto’s veRWA implementation of GaugeController, we first need to understand the concepts of gauges, weights, bias, check-ins and slopes pioneered originally within Curve and then forked, modified and implemented in veRWA.
Within Canto’s veRWA and as aforementioned, pioneered by Curve, a gauge is a mechanism used to incentivize liquidity providers and govern the distribution of rewards within the protocol. Let's break down the concept of gauges:
vote_for_gauge_weights()
function vote_for_gauge_weights(address _gauge_addr, uint256 _user_weight) external
Overall, gauges in veRWA play a crucial role in incentivizing liquidity provision and governing the distribution of rewards. They allow liquidity providers to earn CANTO tokens based on their participation in the protocol and provide a mechanism for the community to influence the allocation of rewards through voting.
The addition and removal of gauges within the protocol are handled by:
function add_gauge(address _gauge) external onlyGovernance { function remove_gauge(address _gauge) external onlyGovernance {
Weights refer to the relative importance or allocation of different assets within a liquidity pool. These weights determine the pricing and trading dynamics of the pool.
In more detail:
By assigning weights to assets within liquidity pools, veRWA aims to provide efficient and low-slippage trading for stablecoins and similar assets. The specific weight allocations used in different pools are determined by the protocol developers and are subject to ongoing evaluation and adjustment.
The core functions that manage gauge and protocol weights are
function _gauge_relative_weight(address _gauge, uint256 _time) private view returns (uint256) { function gauge_relative_weight(address _gauge, uint256 _time) external view returns (uint256) { function gauge_relative_weight_write(address _gauge, uint256 _time) external returns (uint256) { function _change_gauge_weight(address _gauge, uint256 _weight) internal { function change_gauge_weight(address _gauge, uint256 _weight) public onlyGovernance { function get_gauge_weight(address _gauge) external view returns (uint256) { function get_total_weight() external view returns (uint256) {
In the context of the veRWA codebase, the concept of "bias" refers to a parameter that influences the pricing and trading dynamics within the veRWA related pools.
In more detail:
Bias Parameter: The bias parameter is a configurable value within the Curve protocol that influences the pricing and trading dynamics. It is set by the protocol developers and can vary for different pools.
Impact on Pricing: The bias parameter affects the pricing curve of the stablecoin pool. It determines how the prices of stablecoins change as the trading volume and liquidity in the pool fluctuate. A higher bias value results in a more aggressive price curve, while a lower bias value leads to a flatter price curve.
Trading Dynamics: The bias parameter also impacts the trading dynamics within the veRWA pools. It affects the slippage, or price impact, of trades. A higher bias value can result in lower slippage for smaller trades but higher slippage for larger trades. Conversely, a lower bias value can lead to higher slippage for smaller trades but lower slippage for larger trades.
Balancing Trade-offs: The choice of bias value involves a trade-off between providing low-slippage trades for smaller transactions and maintaining stability and efficiency for larger transactions. The bias parameter is set based on considerations such as market conditions, liquidity requirements, and user experience.
By adjusting the bias parameter, veRWA aims to optimize the trading experience and provide efficient stablecoin swaps within its pools. The specific bias values used in different pools are determined by the protocol developers and are subject to ongoing evaluation and adjustment.
In the context of veRWA, a "check-in" refers to a specific action or interaction that liquidity providers are required to perform within a designated timeframe. Check-ins are an essential part of the protocol's mechanism to ensure active participation and management of liquidity positions. Let's explore the concept of check-ins in more detail:
Check-In Mechanism: veRWA implements a check-in mechanism to incentivize liquidity providers to actively manage their positions. This mechanism encourages regular interaction with the protocol to ensure the stability and efficiency of the liquidity pools.
Timeframe: The check-in mechanism operates within a specific timeframe, typically on a weekly basis. Liquidity providers are expected to perform certain actions or updates within this timeframe to maintain their participation in the pool.
Actions and Updates: The specific actions or updates required for a successful check-in may vary depending on the protocol and pool. They can include activities such as adding or removing liquidity, adjusting weights, or performing other necessary operations.
Importance of Check-Ins: Check-ins serve several purposes within the Curve protocol:
a. Pool Management: Regular check-ins help liquidity providers actively manage their positions and respond to changes in market conditions. This ensures that the liquidity pools remain efficient and responsive.
b. Governance Participation: Check-ins often play a role in governance processes within the protocol. Liquidity providers may be required to check-in and vote on proposals related to parameter updates, such as adjusting the bias parameter or making changes to the protocol's functionality.
c. Reward Distribution: Check-ins may also be necessary to ensure liquidity providers receive their rewards accurately and in a timely manner. By checking in, providers confirm their continued participation and eligibility for rewards.
If a liquidity provider fails to perform the required actions or updates within the designated timeframe, it is considered a "missed check-in." This may result in penalties or a reduction in rewards, depending on the specific rules and mechanisms implemented by the protocol.
The following functions in GaugeController can be observed to fill historic gauge weights week-over-week for missed checkins, return the sum of weights for the future week and return both the sum of weights and gauge weight respectively.
The functions that handle historic weights and sum-of-weights are:
function _get_sum() internal returns (uint256) { function _get_weight(address _gauge_addr) private returns (uint256) {
Overall, the check-in mechanism in veRWA encourages liquidity providers to actively manage their positions, participate in governance processes, and contribute to the stability and efficiency of the liquidity pools.
Slopers refer to a parameter that influences the rate of change in the pricing curve of a liquidity pool. The slope determines how the prices of assets within the pool adjust in response to changes in supply and demand. It plays a crucial role in determining the trading dynamics and slippage within the veRWA pools.vote_for_gauge_weights()
concerns itself with generating slopes for gauge calculations.
As mentioned previously, one of the main changes to the GaugeController was an increased role of Governance in the administration of the protocol. This can be seen in the code snippet of the modifier below.
modifier onlyGovernance() { require(msg.sender == governance); _; }
Specifically in the context of the previous High audit finding in the original Curve protocol codebase (https://solodit.xyz/issues/several-loops-are-not-executable-due-to-gas-limitation-difficulty-high-trailofbits-curve-dao-pdf) in which a DoS vector was possible due to the permisonless nature of adding gauges in the original Curve protocol.
Due to the onlyGovernance modifier being added to the add_gauge
function, this DoS vector is no longer viable in the veRWA codebase. Nonethless, it does again add a risk of centralization within the protocol and priveleged persons (whale token holders i.e project team and founders) potentially being able to abuse their position of trust.
GaugeController is a Solidity port of Curve’s Vyper GaugeController. Due to recent events in which Curve pools with contracts utilizing the Vyper programming language were exploited, alarm could be raised over veRWA’s use of this code. However, the exploit was due to a vulnerability within the Vyper compiler itself and not the code. As such, the exploit would not be viable within the ported Solidity code.
This comprehensive breakdown of the GaugeController protocol, explanation of key concepts such as Gauges, Weights, Bias, Slopes and Check-In’s and modifications from the original Curve codebase should give an external observer a robust understanding of the architecture and operation of the contract.
The Lending Ledger is responsible for allowing users to interact with Canto's veRWA system in order to provide liquidity for different lending markets which are whitelisted by the governance system. In addition to this responsibility, the users who provide liquidity are rewarded in the form of CANTO
, respective to their share. As a result, the lending ledger has a duty of ensuring how much liquidity a specific user has supplied for any given market, during any specified timeframe. Therefore, it is quite important to analyse the possibilities of any attack vector, whether this is from the result of a governance proposal or from a malicious exploit.
When a user interacts with the LendingLedger
, their transaction is ultimately processed via sync_ledger()
:
function sync_ledger(address _lender, int256 _delta) external {
The sync_ledger
checks to ensure that there are no underflows, and that the user is interacting with a valid lending market which has been whitelisted:
address lendingMarket = msg.sender; require(lendingMarketWhitelist[lendingMarket], "Market not whitelisted"); ... 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 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
The use of the _checkpoint_lender
function is rather important, because a user’s first deposit is actually stored and updated via this function, and without it being included within sync_ledger
, the user would never have the ability to successfully execute the claim
function days, weeks or years after providing their liquidity to any given market:
_checkpoint_lender(lendingMarket, _lender, type(uint256).max);
This was an initial problem upon first discovery, because it was not clear whether the user has an updated value for userLastClaimed
upon first deposit however they do, purely because sync_ledger
calls _checkpoint_lender
, and within the checkpoint lender function we have the following lines of code which makes the accounting aspect of this successful:
if (lastUserUpdateEpoch == 0) { // Store epoch of first deposit userClaimedEpoch[_market][_lender] = currEpoch; lendingMarketBalancesEpoch[_market][_lender] = currEpoch; ...
With this being said, the tests which exist for epoch vulnerabilities are very well made, and had a well-thought-out process. The Red Lotus team was unable to find any vulnerabilities pertaining to the reusage of the same epoch, or attempting to claim rewards for future epochs which do not exist at that point in time.
Additionally, from an accounting perspective, it is remarkable that Canto implemented a fail-safe methodology to “fill in gaps in the market total balances history (if any exist)” because if any problems were to arise, Canto has the ability to successfully maintain accurate accounting. This is something that not every protocol considers, and therefore we deem it as a good addition to the protocol:
function _checkpoint_market(address _market, uint256 _forwardTimestampLimit) private { ... if (lastMarketUpdateEpoch == 0) { 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; } ... } }
Regarding the actual mechanism of the protocol, we would argue that it is important to account for user mistakes such as the ones which relate to “Users can forfeit the rewards for some epochs by skipping them when calling claim
”. This problem can be eliminated and as a result, would improve user friendliness for the usage of the protocol overall and would provide a safer environment for the users.
Something that we would like to bring to light is the actual mechanism of the protocol, where the governance essentially has to assure that LendingLedger
at all times contains enough CANTO
. It can be argued that it is quite fragile in this sense because if the Governance
system were to fail, this would mean that LendingLedger
would also fail because the Governance
system itself assures that the LendingLedger
contains enough CANTO
, and this allows for the possibility that users may not have the ability to withdraw their CANTO
rewards due to an inaccurate balance on Canto’s side - something which is out of the user’s control and is in the hands of Canto.
It’s important to note that attempts to clarify with the developers which token was actually accepted by LendingLedger
revealed the assumption that only cNOTE
is accepted as a valid form of currency within the veRWA
protocol, it is apparent from the audit that CANTO
is the expected token of deposit.
It is not clear that cNOTE
is the only accepted form of currency when it comes to providing liquidity for the markets, and without a crystal clear notice to the users, it may cause the users to lose out on their potential liquidity assignment to the specified markets, also allowing them to not retrieve the funds back which is a tragedy. Whilst this may not be a big problem for Canto, it is a big problem for the architectural design and how it links to user-friendliness.
Curve Voting Escrow (ve
) has become the common way for DeFi projects to get, lose, and use voting power in their smart contract functions. The motivation for ve
is to provide the ability to vote for which pools will get the share of limited rewards emitted by the protocol. To combat the gaming of votes by powerful voters, decay in the voting power is included.
Decay in voting power is mediated by three major factors:
ve
makes later lockers competitive with early lockers. While it introduces the problem of continuous risk/reward judgements by investors due to the game theory involved, it keeps the protocol’s markets available to a larger pool of investors wanting to compete for the fees and rewards by the protocol.
Since its inception, ve
has remained a popular DeFi mechanic. Forks and ports have been made and remade. Originally, the first version of the contract was written in Vyper. Projects like Frax, Reflexer, and Workhard Finance later provided a version in Solidity. The version used in veRWA is a fork of the FIAT DAO implementation, which is itself a fork of Curve's original implementation.
Users can lock up CANTO (for five years) in the VotingEscrow
 contract to get veCANTO.
When you lock up your CANTO, any further actions such as increasing your locked amount (**increaseAmount**
) will reset your lock-time to 5 years. This is primarily a strategic choice in terms of business or design, and its done to prevent mercenary capital from locking a large number of tokens for a brief period to gain significant voting power for a limited number of periods. These short-term investors can provide negative value to the protocol because of the constant flux in locked capital, making it less attractive for long-term investors looking for safe and consistent gains.
For the rest of this analysis, we’ll delve into the mechanics of the smart contract that implements veRWA’s ve
functionality.
ve
FunctionalityYou can't transfer the veCANTO token. The only method to get veCANTO is by locking up CANTO. As the time left until you unlock your CANTO decreases, your veCANTO balance goes down in a straight line. For instance, if you lock up 4000 CANTO for one year, you'll have the same veCANTO as if you locked up 2000 CANTO for two years, or 1000 CANTO for four years.
Locks can be created with createLock
.
createLock
updates your locked balance with the chosen amount of CANTO and sets the unlock time. Your delegated tokens also increase, and you become the delegatee.
The withdraw
function allows you to retrieve your locked tokens after your lock-time has passed.
Actions:
The increaseAmount
function allows you to add more tokens to an existing locked balance, enhancing your voting power.
If you're updating your own locked balance (not delegating), your locked balance is updated:
amount
and end
values are copied to a new LockedBalance
.amount
is increased by the chosen _value
.end
time is updated to the rounded down week after the current time.\If you're updating a delegated lock:
_value
.Users can let someone else control when their lock ends and how much they have for voting. Both the person giving control (delegator) and the person receiving it (delegatee) should have active locks when this happens. Also, the delegatee's lock should last longer than the delegator's lock.
Actions:
90 hours
#0 - c4-judge
2023-08-22T07:08:15Z
alcueca marked the issue as selected for report
#1 - c4-judge
2023-08-28T20:57:26Z
alcueca marked the issue as grade-a
#2 - c4-judge
2023-08-30T06:27:13Z
alcueca marked the issue as not selected for report