Reports

Smart Contract Security Assessment

Yield Basis DAO Security Review

Yield Basis is a protocol that features a new type of AMM that focuses on solving impermanent loss. The current review targets the DAO contracts of the protocol, including the governance token, along with the mechanism to vote, incentivize pools, and distribute rewards.

17
Issues
9
C/H/M
Period
Jun 02, 2025 - Jun 06, 2025
Auditors
fedebianu, adriro

Review Summary

Protocol Overview

Yield Basis is a protocol that features a new type of AMM that focuses on solving impermanent loss. The current review targets the DAO contracts of the protocol, including the governance token, along with the mechanism to vote, incentivize pools, and distribute rewards.

Protocol
Yield Basis
Timeline
Jun 02, 2025 - Jun 06, 2025
Audit Team
fedebianu, adriro

Audit Overview

Scope and Resources

Scope

This audit covers 6 smart contracts across 5 days of review.

Overall Assessment

Given the complexity of the contracts and the high number of severe findings present in this report, the auditors recommend strengthening the testing suite and conducting a new security review.

Evaluation Matrix

access control
Good

Correct usage of access control protection.

mathematics
Low

Several issues related to calculations or scaling were detected.

complexity
Average

The contracts are well-designed, but contain complex logic, particularly within the GaugeController and LiquidityGauge contracts.

libraries
Good

The codebase relies on the snekmate library.

decentralization
Good

The protocol and its emissions are governed by a set of decentralized contracts.

code stability
Good

The codebase remained stable during the review.

documentation
Average

The contracts are decorated with NatSpec metadata. Additional high-level documentation focused on explaining the dynamics of voting and gauges is encouraged.

monitoring
Good

Monitoring events are in place.

testing
Low

Despite the presence of fuzzing tests in the codebase, multiple severe issues remained undetected.

Key Findings

Findings Summary

4
Critical
4
High
1
Medium
2
Low
5
Informational
1
Gas
C-1 Finding

C-1: Incorrect interpretation of released rewards in `LiquidityGauge`

Critical

Summary:

The LiquidityGauge contract uses the value of the checkpointed reward rate integral to determine the updated amount of released reward tokens, leading to multiple accounting problems.

Description:

The implementation of _get_vested_rewards() takes the value of reward_rate_integral[token].v as the amount of released rewards up to the latest checkpointed time (used_rewards).

170:     last_reward_time: uint256 = self.reward_rate_integral[token].t
171:     used_rewards: uint256 = self.reward_rate_integral[token].v
172:     finish_time: uint256 = self.rewards[token].finish_time
173:     total: uint256 = self.rewards[token].total
174:     if finish_time > last_reward_time:
175:         new_used: uint256 = (total - used_rewards) * (block.timestamp - last_reward_time) //\
176:             (finish_time - last_reward_time) + used_rewards
177:         return min(new_used, total) - used_rewards

The used_rewards variable is then used to compute the remaining amount (total - used_rewards) and to calculate the new value of distributed rewards (new_used).

However, taking the integral of the reward rate is incorrect, as this value is already scaled by the inverse of the total supply as part of the checkpoint process.

148:         r.reward_rate_integral = self.reward_rate_integral[reward]
149:         if block.timestamp > r.reward_rate_integral.t:
150:             r.reward_rate_integral.v += (r.integral_inv_supply.v - self.integral_inv_supply_4_token[reward]) * d_reward //\
151:                (block.timestamp - r.reward_rate_integral.t)
152:             r.reward_rate_integral.t = block.timestamp

Impact:

Critical. The issue leads to multiple accounting problems and side effects, potentially bricking the state of the vault.

Recommendation:

Have a dedicated accumulator to measure the amount of distributed rewards per token. This counter should be incremented by the return value of _vest_rewards() whenever the state is persisted to storage (_checkpoint_user(), claim(), deposit_reward()).

Developer Response:

C-2 Finding

C-2: User checkpoint in `LiquidityGauge` fails to store rewards

Critical

Summary:

Rewards assigned to users are lost when the user state is checkpointed.

Description:

Unlike claim(), the implementation of _checkpoint_user() does not transfer the earned rewards to the user. However, these rewards are not stored in contract state, so they cannot be claimed later.

Impact:

Critical. Earned rewards are lost when the user interacts with the vault.

Recommendation:

The _checkpoint_user() function should store earned rewards (d_user_reward) in an accumulator of pending rewards, which can be flushed in claim(). An alternative would be to use user_rewards_integral.v which is the historic accumulated rewards, but this would also require an accumulator to track the withdrawn tokens to calculate the difference.

Developer Response:

Added such an accumulator in 1b3b6f49c5c5140ffb2f2f5b97851e578e36f12b.

C-3 Finding

C-3: Incorrect scale in `LiquidityGauge` checkpoint

Critical

Summary:

The user reward calculation is incorrectly normalized, leading to an overinflated amount.

Description:

In _checkpoint(), integral_inv_supply is scaled by 1e36 but only normalized by 1e18 when calculating d_user_reward.

139: def _checkpoint(reward: IERC20, d_reward: uint256, user: address) -> RewardIntegrals:
140:     r: RewardIntegrals = empty(RewardIntegrals)
141: 
142:     r.integral_inv_supply = self.integral_inv_supply
143:     if block.timestamp > r.integral_inv_supply.t:
144:         r.integral_inv_supply.v += unsafe_div(10**36 * (block.timestamp - r.integral_inv_supply.t), erc4626.erc20.totalSupply)
145:         r.integral_inv_supply.t = block.timestamp
146: 
147:     if reward.address != empty(address):
148:         r.reward_rate_integral = self.reward_rate_integral[reward]
149:         if block.timestamp > r.reward_rate_integral.t:
150:             r.reward_rate_integral.v += (r.integral_inv_supply.v - self.integral_inv_supply_4_token[reward]) * d_reward //\
151:                (block.timestamp - r.reward_rate_integral.t)
152:             r.reward_rate_integral.t = block.timestamp
153: 
154:     if user != empty(address):
155:         r.user_rewards_integral = self.user_rewards_integral[user][reward]
156:         if block.timestamp > r.user_rewards_integral.t:
157:             r.d_user_reward = (r.reward_rate_integral.v - self.reward_rate_integral_4_user[user][reward]) *\
158:                 erc4626.erc20.balanceOf[user] // 10**18
159:             r.user_rewards_integral.v += r.d_user_reward
160:             r.user_rewards_integral.t = block.timestamp
161: 
162:     return r

Impact:

Critical. Reward amounts are inflated by a factor of 1e18.

Recommendation:

In d_user_reward, divide by 10**36 instead of 10**18.

Developer Response:

C-4 Finding

C-4: NFT reentrancy allows using voting power when transferring token

Critical

Summary:

A reentrancy in safeTransferFrom() would allow an attacker to use their voting power just before being merged with the recipient.

Description:

The implementation of safeTransferFrom() does the transfer with the callback after performing the check but before clearing the owner's voting power.

538: def safeTransferFrom(owner: address, to: address, token_id: uint256, data: Bytes[1_024] = b""):
539:     assert erc721._is_approved_or_owner(msg.sender, token_id), "erc721: caller is not token owner or approved"
540:     assert self._ve_transfer_allowed(owner, to), "Need max veLock"
541:     erc721._safe_transfer(owner, to, token_id, data)
542:     self._merge_positions(owner, to)
543:     erc721._burn(token_id)

This would allow a malicious user to exercise their voting power in between the transfer, as the check is done before the callback and their votes are reset after the callback.

Impact:

Critical. Voting power can be reused multiple times by performing the described attack.

Recommendation:

Since the token is immediately burned after transferring, the underlying transfer operation is not really needed, the token can be just burned.

Developer Response:

Fixed for both transferFrom() and safeTransferFrom() in 4d6cd7183e39487a80dc51b7976099b156f440a0.

H-1 Finding

H-1: Incorrect finish time calculation changes reward distribution rate instead of maintaining it

High

Summary:

LiquidityGauge.deposit_reward() incorrectly calculates the new finish time when adding rewards to an ongoing distribution period, resulting in unintended changes to the reward distribution rate.

Description:

LiquidityGauge.deposit_reward() contains logic to extend the finish time when adding rewards to maintain the current rate:

# Keep the reward rate
assert r.finish_time > block.timestamp, "Rate unknown"
r.finish_time = block.timestamp + (r.finish_time - block.timestamp) * (r.total + amount) // r.total

However, this formula uses the cumulative total deposited tokens instead of the remaining undistributed tokens, causing the distribution rate to change:

Example scenario:

  • Initial: 1000 tokens over 10 days → rate = 100 tokens/day
  • r.total = 1000 (cumulative total)
  • After 6 days: 600 tokens distributed, 400 remaining
  • Add 500 tokens using current formula:
    • remaining_time = 4 days
    • r.total + amount = 1000 + 500 = 1500
    • ratio = 1500 / 1000 = 1.5
    • new_remaining_time = 4 * 1.5 = 6 days
    • New rate = 900 / 6 = 150 tokens/day

The formula should use remaining tokens (400) instead of cumulative total (1000) to maintain the same rate:

  • Correct ratio = (400 + 500) / 400 = 2.25
  • Correct new time = 4 * 2.25 = 9 days
  • Correct rate = 900 / 9 = 100 tokens/day

Impact:

High. The reward distribution rate changes unexpectedly when adding tokens to ongoing distributions, violating the stated intention to "keep the reward rate".

Recommendation:

Calculate the extension based on the original rate to maintain consistent distribution: block.timestamp + (undistributed_reward + amount) / reward_rate.

Developer Response:

H-2 Finding

H-2: Token emission calculations use stale weights for non-checkpointed gauges

High

Summary:

When checkpointing a specific gauge, the global emission rate calculation uses potentially stale weights from other gauges that haven't been recently checkpointed.

Description:

GaugeController._checkpoint_gauge() updates weights only for the target gauge, but then uses global weight sums (aw_sum, w_sum) to calculate the emission rate factor.

If other gauges have changed their weights but haven't been checkpointed, their stale weights are still included in the global sums, leading to incorrect emission rate calculations.

Impact:

High. Incorrect emission rates affect the entire protocol's token distribution, potentially over-minting or under-minting tokens based on outdated weight information.

Recommendation:

Implement global gauge aggregation and update it during gauge checkpoints, similar to Curve's _get_total() approach, adapted for Yield Basis with no gauge types.

Developer Response:

Disagree. That's the thing. Weights are only ever updated with per-gauge checkpoint.

That checkpoint happens at voting as well as at claim. But if gauge, for example, had adjustment number changed somehow - a stale number is applied all across the board before chekpoint or claim happens.

It is by design. So weights CANNOT actually update without being checkpointed. Disadvantage of this approach is that weights cannot be a pure function of time: they are only updated in actions which cause checkpoints. But this is not a big disadvantage because timescale of vote weight changes (years) is much larger than times between checkpoints (e.g. between claims, or deposits/withdrawals).

H-3 Finding

H-3: `VotingEscrow.increase_amount()` has parameter inconsistency that could lead to unauthorized lock modifications

High

Summary:

VotingEscrow.increase_amount() has a critical parameter inconsistency that creates a mismatch between validation and execution.

Description:

VotingEscrow.increase_amount() performs validation checks against msg.sender's lock data but then calls _deposit_for(_for, ...), which modifies the lock for the _for address. This creates a scenario where the actual token deposit and lock modification occurs for the _for address, while the validation is done with msg.sender's lock data and the _locked parameter passed to _deposit_for contains msg.sender's lock data, not _for's.

Impact:

High. This vulnerability allows:

  • Users to call the function for addresses that don't have existing locks
  • Bypassing validation that the target lock is valid and non-expired
  • Creating inconsistent lock states where the wrong lock parameters are used
  • Potential manipulation of the voting escrow system by depositing for invalid targets

Recommendation:

Fix the parameter inconsistency by ensuring validation and operation target the same address:

    @external
    @nonreentrant
    def increase_amount(_value: uint256, _for: address = msg.sender):
-       _locked: LockedBalance = self.locked[msg.sender]
+       _locked: LockedBalance = self.locked[_for]

        assert _value > 0  # dev: need non-zero value
        assert _locked.amount > 0, "No existing lock found"
        assert _locked.end > block.timestamp, "Cannot add to expired lock. Withdraw"

        self._deposit_for(_for, _value, 0, _locked, LockActions.INCREASE_AMOUNT)

Or alternatively, remove the _for parameter if it's not needed.

Developer Response:

H-4 Finding

H-4: Incorrect emissions rate in YB token

High

Summary:

The emission rate in the Yield Basis token is incorrectly scaled.

Description:

The emissions calculation is given by the implementation of _emissions():

50: def _emissions(t: uint256, rate_factor: uint256) -> uint256:
51:     assert rate_factor <= 10**18
52:     dt: int256 = convert(t - self.last_minted, int256)
53:     rate: int256 = convert(max_mint_rate * rate_factor // 10**18, int256)
54:     reserve: int256 = convert(self.reserve, int256)
55:     return convert(
56:         reserve * (10**18 - math._wad_exp(-dt * rate // 10**18)) // 10**18,
57:         uint256)

The rate variable is calculated as the max_mint_rate (max rate per second) scaled by the rate_factor. However, the rate variable is then normalized again by 10**18 in the expression of -dt * rate // 10**18 (line 56). Given dt and rate are denominated in seconds, dividing by 10**18 will yield the incorrect result.

Impact:

High. Emission rate in YB token is incorrect and will likely result in zero emissions.

Recommendation:

The exponential term should multiply dt (elapsed seconds) by rate (emissions per second).

reserve * (10**18 - math._wad_exp(-dt * rate)) // 10**18

Developer Response:

M-1 Finding

M-1: `LiquidityGauge.withdraw()` is broken due to incorrect `assert` logic

Medium

Summary:

LiquidityGauge.withdraw() contains an incorrect assert statement that validates the wrong account's withdrawal capacity, causing legitimate withdrawal operations to fail when transferring assets to third parties.

Description:

In LiquidityGauge.withdraw() the assert checks _max_withdraw(receiver) instead of _max_withdraw(owner). The validation should ensure the owner has sufficient withdrawable balance, not the receiver.

Impact:

Medium. This bug breaks core functionality, but severity is mitigated because redeem() is correct and can be used instead.

Recommendation:

Fix the assert to validate the owner's withdrawal capacity instead of the receiver's.

Developer Response:

L-1 Finding

L-1: Prevent LP token from being registered as rewards

Low

Summary:

Using the LP token as a reward would conflict with the vault's accounting.

Description:

LP tokens sent by the distributor would be mixed with staked tokens and treated as the vault's assets, disrupting the accounting.

Impact:

Low.

Recommendation:

Check the reward token is not the LP token in add_reward().

    def add_reward(token: IERC20, distributor: address):
        assert token != YB, "YB"
+       assert token != LP_TOKEN, "LP_TOKEN"

Developer Response:

L-2 Finding

L-2: Wrong condition in `preview_emissions()`

Low

Summary:

The ealy return in preview_emissions() implements the wrong condition.

Description:

The implementation of preview_emissions() early returns with zero using the wrong condition. A gauge isn't registered when time_weight[gauge] == 0, a positive value means the gauge has been enabled.

325:     if self.time_weight[gauge] > 0:
326:         return 0

Impact:

Low. preview_emissions() always returns zero for registered gauges.

Recommendation:

Change the condition to if self.time_weight[gauge] == 0.

Developer Response:

I-1 Finding

I-1: Missing exports from modules

Informational

Summary:

There are severals functions inherited from modules which are not re-exported from the contract.

Description:

  • GaugeController.vy:
    • owner()
  • VestingEscrow.vy:
    • owner()
  • VotingEscrow.vy:
    • tokenURI()
    • supportsInterface()

Impact:

Informational.

Recommendation:

Add the missing exports to expose the functions.

Developer Response:

I-2 Finding

I-2: Check for array length mismatch

Informational

Description:

VestingEscrow.fund() accepts two arrays but only validates the loop bounds against the recipients array.

Impact:

Informational.

Recommendation:

Add explicit length validation for better error messaging:

assert len(_recipients) == len(_amounts), "Array length mismatch"

Developer Response:

I-3 Finding

I-3: `VotingEscrow.getPastVotes()` should not return future voting power

Informational

Description:

VotingEscrow.getPastVotes() allows querying voting power at any timestamp without proper validation. However, the function's purpose is to return voting power from the past only.

Impact:

Informational.

Recommendation:

Add proper timepoint validation check:

assert timepoint <= block.timestamp, "Timepoint in the future"

Developer Response:

Acknowledged. It's a view method which is to be called from frontend. Sometimes RPCs are using multiple nodes behind a load balancer which could be not entirely at sync (one can be ahead of another by 1 block). So, I can imagine a situation that check <= block.timestamp will actually fail after reading the current timestamp worked. Moreover, this function DOES work for the nearest future as well. Overall, I think better leave it without this assert.

I-4 Finding

I-4: Fix typos

Informational

Description:

The CliffEscrow.vy contract contains a spelling error in an immutable variable name:

RECEPIENT: public(immutable(address))

Impact:

Informational.

Recommendation:

Fix typos.

Developer Response:

I-5 Finding

I-5: Follow CEI pattern

Informational

Summary:

Some functions updates the contract state after making an external call, violating the Checks-Effects-Interactions (CEI) pattern.

Description:

VotingEscrow.withdraw() performs the state update erc721._burn(convert(msg.sender, uint256)) before making the external call to TOKEN.transfer().

LiquidityGauge.deposit_reward() performs the state update self.rewards[token] = r before making the external call to token.transferFrom().

Impact:

Informational.

Recommendation:

Follow CEI pattern as a best practice.

Developer Response:

G-1 Finding

G-1: Remove unused variables in `VotingEscrow._merge_positions()`

Gas

Description:

In VotingEscrow._merge_positions() two variables pt and to_pt are declared and assigned but never used, resulting in unnecessary gas consumption.

Impact:

Gas savings.

Recommendation:

Remove the unused variables.

Developer Response:

Final Remarks

The DAO contracts of Yield Basis resemble much of the mechanism present in the Curve protocol. While the structure is similar, multiple modifications have been made to simplify the logic and its implementation. Given the complexity of the contracts and the high number of severe findings present in this report, the auditors recommend strengthening the testing suite and conducting a new security review.

Methodology

Severity Classification

Critical

Immediate threat to user funds or protocol integrity

Direct loss of funds, protocol compromise

High

Significant security risk requiring urgent attention

Potential fund loss, major functionality disruption

Medium

Important issue that should be addressed

Limited fund risk, functionality concerns

Low

Minor issue with minimal impact

Best practice violations, minor inefficiencies

Gas

Findings that improve gas efficiency

Increased transaction costs

Informational

Code quality and best practice recommendations

Reduced maintainability and readability