Skip to main content
Liquity v2 ~ Cantina Fixes Review - May 13, 2025

Liquity v2 ~ Cantina Fixes Review

Smart Contract Security Assessment

May 13, 2025

Liquity

SUMMARY


ABSTRACT

Dedaub was commissioned to perform a security audit of the fixes applied to Liquity V2, following a full audit of the protocol’s code conducted during a Cantina audit contest. Although no major issues were identified in the contest, the protocol implemented changes to enhance the system’s robustness and improve code quality.


BACKGROUND

After fully addressing a potential attack to the protocol’s Stability Pool, the protocol decided to hold an audit contest on the entire codebase, with the intent of discovering any additional security issues prior to the protocol’s re-deployment.


SETTING & CAVEATS

This audit report mainly covers the contracts of the public repository https://github.com/liquity/bold, branch main.

2 auditors worked on the codebase for 5 days on the contracts affected by the following pull requests:

The following code improvement pull requests were also put in the scope:


ultimately making the commit at which the repository was examined abe7cbfbd465fba3812282c51773455766a70e96

The team spent considerable effort examining the rationale behind the changes, assessing whether prior security assumptions still held and whether any potential integration issues could arise.

The audit’s main target is security threats, i.e., what the community understanding would likely call "hacking", rather than the regular use of the protocol. Functional correctness (i.e. issues in "regular use") is a secondary consideration. Typically it can only be covered if we are provided with unambiguous (i.e. full-detail) specifications of what is the expected, correct behavior. In terms of functional correctness, we often trusted the code’s calculations and interactions, in the absence of any other specification. Functional correctness relative to low-level calculations (including units, scaling and quantities returned from external protocols) is generally most effectively done through thorough testing rather than human auditing. That said, the project’s test suite is of high quality, and all changes have been accompanied by meticulous testing.


VULNERABILITIES & FUNCTIONAL ISSUES

This section details issues affecting the functionality of the contract. Dedaub generally categorizes issues according to the following severities, but may also take other considerations into account such as impact or difficulty in exploitation:

Category
Description
CRITICAL
Can be profitably exploited by any knowledgeable third-party attacker to drain a portion of the system’s or users’ funds OR the contract does not function as intended and severe loss of funds may result.
HIGH
Third-party attackers or faulty functionality may block the system or cause the system or users to lose funds. Important system invariants can be violated.
MEDIUM
Examples:
  • User or system funds can be lost when third-party systems misbehave.
  • DoS, under specific conditions.
  • Part of the functionality becomes unusable due to a programming error.
LOW
Examples:
  • Breaking important system invariants but without apparent consequences.
  • Buggy functionality for trusted users where a workaround exists.
  • Security issues which may manifest when the system evolves.

Issue resolution includes “dismissed” or “acknowledged” but no action taken, by the client, or “resolved”, per the auditors.


CRITICAL SEVERITY

[No critical severity issues]


HIGH SEVERITY

[No high severity issues]


MEDIUM SEVERITY

[No medium severity issues]


LOW SEVERITY

[No low severity issues]


OTHER / ADVISORY ISSUES

This section details issues that are not thought to directly affect the functionality of the project, but we recommend considering them.

A1

The last liquidatable trove in a shut down branch is not liquidatable

ADVISORY
info
A1
The last liquidatable trove in a shut down branch is not liquidatable

The last trove of a shut-down branch cannot be liquidated:

TroveManager::_closeTrove:1477
...
// If branch has not been shut down, or it's a liquidation,
// require at least 1 trove in the system
if (shutdownTime == 0 || closedStatus == Status.closedByLiquidation) {
_requireMoreThanOneTroveInSystem(TroveIdsArrayLength);
}
...


This does not create any immediate issues, but restricting a debt-wiping operation during shutdown appears to offer no benefit to the system’s health, especially given that the primary goal during shutdown is to incentivize the rapid removal of the branch’s debt.

A2

TODO comments can be removed

ADVISORY
info
A2
TODO comments can be removed

Info

The item has been resolved by pull request #925

There are various TODO comments in the codebase. The team could consider removing them (deeming them as “won’t fix”), even if they are not implemented. The changes described do not affect the system’s functionality and/or serve as runtime assertions that should never be triggered during execution:

TroveManager::onOpenTroveAndJoinBatch:1304

assert(_troveChange.debtIncrease > 0); // TODO: remove before deployment

TroveManager::onAdjustTroveInsideBatch:1564

assert(_newTroveDebt > 0); // TODO: remove before deployment

TroveManager::onApplyTroveInterest:1620

assert(_newTroveDebt > 0); // TODO: remove before deployment

TroveManager::onSetInterestBatchManager:1761

//@Dedaub: the assertion is fine in this case, since there is no explicit check
// in BorrowerOperations::adjustTroveInterestRate
assert(_newTroveDebt > 0); // TODO: remove before deployment

MainnetPriceFeedBase:52
// TODO: remove this and set address in constructor, since we'll use CREATE2
function setAddresses(address _borrowOperationsAddress) external onlyOwner {
...
HybridCurveUniV3Exchange:23
...
// TODO: pass it as param in functions, so we can reuse the same exchange for different branches
IERC20 public immutable collToken;
...

A3

Code can be simplified

ADVISORY
info
A3
Code can be simplified

Both LeverageLSTZapper::openLeveragedTroveWithRawETH and LeverageWETHZapper::openLeveragedTroveWithRawETH could use _getTroveIndex(uint256) instead of _getTroveIndex(address _sender, uint256 _ownerIndex), since the prior uses msg.sender directly when calculating the troves owner index

BaseZapper:36
function _getTroveIndex(address _sender, uint256 _ownerIndex) internal pure returns (uint256) {
return uint256(keccak256(abi.encode(_sender, _ownerIndex)));
}

function _getTroveIndex(uint256 _ownerIndex) internal view returns (uint256) {
return _getTroveIndex(msg.sender, _ownerIndex);
}
LeverageLSTZapper:29
function openLeveragedTroveWithRawETH(OpenLeveragedTroveParams memory _params) external payable {
require(msg.value == ETH_GAS_COMPENSATION, "LZ: Wrong ETH");
require(
_params.batchManager == address(0) || _params.annualInterestRate == 0,
"LZ: Cannot choose interest if joining a batch"
);

...
_params.ownerIndex = _getTroveIndex(msg.sender, _params.ownerIndex);
...
LeverageWETHZapper:26
function openLeveragedTroveWithRawETH(OpenLeveragedTroveParams memory _params) external payable {
require(msg.value == ETH_GAS_COMPENSATION, "LZ: Wrong ETH");
require(
_params.batchManager == address(0) || _params.annualInterestRate == 0,
"LZ: Cannot choose interest if joining a batch"
);

...
_params.ownerIndex = _getTroveIndex(msg.sender, _params.ownerIndex);
...

A4

Troves may be griefed out of a batch

ADVISORY
info
A4
Troves may be griefed out of a batch

Pull request #897 introduces the ability to permissionlessly remove troves from a batch which has debt:shares ratio more than 1e9.

This is meant to serve as a way to:

  • Remove zombie troves that have received a large redistributed debt, but cannot redeem the debt while being inside the batch
  • Allow the rest of the healthy troves in the batch to mint additional debt shares
BorrowerOperations::_removeFromBatch:1087
...
if (_kick) {
_requireTroveIsOpen(vars.troveManager, _troveId);
} else {
_requireTroveIsActive(vars.troveManager, _troveId);
_requireCallerIsBorrower(_troveId);
_requireValidAnnualInterestRate(_newAnnualInterestRate);
}

vars.batchManager = _requireIsInBatch(_troveId);
vars.trove = vars.troveManager.getLatestTroveData(_troveId);
vars.batch = vars.troveManager.getLatestBatchData(vars.batchManager);

if (_kick) {
if (vars.batch.totalDebtShares * MAX_BATCH_SHARES_RATIO >= vars.batch.entireDebtWithoutRedistribution) {
revert BatchSharesRatioTooLow();
}
_newAnnualInterestRate = vars.batch.annualInterestRate;
}
...

An issue could arise if someone is able to:

  • Join an existing batch
  • Inflate the debt:share ratio of the batch
  • Kick the rest of the troves out of the batch

To do this the griefer can utilize a flashloan, but they would have to pay for fees applicable (e.g., upfront fee for joining the batch) out of pocket. The grieferer wouldn’t gain anything out of this, this is mostly aimed at griefing other users.

In principle, step 2 cannot be performed by borrowing, since the debt:share ratio is preserved for debt shares minted through borrowing. The ratio starts at 1 and increases as interest accrues and batch management fees are added. However, it is theoretically possible to cause the ratio to change through borrowing by exploiting the fact that the number of minted debt shares is rounded down:

TroveManager::_updateBatchShares:1845
...
batchDebtSharesDelta = currentBatchDebtShares * debtIncrease / _batchDebt;
...

The team was already aware of this, but we are interested in producing a risk analysis of this.

In principle, a trove created with dd debt in a batch with dbatchd_{batch} debt and sbatchs_{batch} shares should retain the branch's share ratio.

Algebraically, that's easy to observe since:

dbatch+dsbatch+dsbatchdbatch=dbatchsbatch\frac{d_{batch} + d'}{s_{batch} + d' \frac{s_{batch}}{d_{batch}}} = \frac{d_{batch}}{s_{batch}}

Since the operation rounds down because of integer division, the actual amount of new shares is

ddbatchsbatch=ddbatchsbatchϵ\lfloor d' \frac{d_{batch}}{s_{batch}} \rfloor = d' \frac{d_{batch}}{s_{batch}} - \epsilon

with ϵ1\epsilon \leq 1

When accounting for the rounding error that gets created it can be shown that:

dbatch+dsbatch+dsbatchdbatchϵ>dbatchsbatch\frac{d_{batch} + d'}{s_{batch} + d' \frac{s_{batch}}{d_{batch}} - \epsilon} > \frac{d_{batch}}{s_{batch}}

But ultimately we would like to use dd' in order to inflate the ratio by δ\delta:

dbatch+dsbatch+dsbatchdbatchϵ=dbatchsbatch+δ    \frac{d_{batch} + d'}{s_{batch} + d' \frac{s_{batch}}{d_{batch}} - \epsilon} = \frac{d_{batch}}{s_{batch}} + \delta \iff

dbatch+dsbatch+dsbatchdbatchϵ=dbatch+δsbatchsbatch    \frac{d_{batch} + d'}{s_{batch} + d' \frac{s_{batch}}{d_{batch}} - \epsilon} = \frac{d_{batch} + \delta\cdot s_{batch}}{s_{batch}} \iff

dbatch+dsbatch+dsbatchdbatchϵ=dbatch+δsbatchsbatch    \frac{d_{batch} + d'}{s_{batch} + d' \frac{s_{batch}}{d_{batch}} - \epsilon} = \frac{d_{batch} + \delta\cdot s_{batch}}{s_{batch}} \iff

sbatchdbatch+dsbatch=dbatchsbatch+dsbatchϵdbatch+δsbatch2+dsbatch2δdbatchϵδsbatch    s_{batch}d_{batch} + d's_{batch} = d_{batch}s_{batch} + d's_{batch} - \epsilon \cdot d_{batch} + \delta \cdot s_{batch}^2 + \frac{d's_{batch}^2\delta}{d_{batch}} - \epsilon \cdot \delta \cdot s_{batch} \iff

0=δsbatch2+dsbatch2δdbatchϵ(δsbatch+dbatch)    0 = \delta \cdot s_{batch}^2 + \frac{d's_{batch}^2\delta}{d_{batch}} - \epsilon(\delta \cdot s_{batch} + d_{batch}) \iff

dsbatch2δdbatch=ϵ(δsbatch+dbatch)δsbatch2    \frac{d's_{batch}^2\delta}{d_{batch}} = \epsilon(\delta \cdot s_{batch} + d_{batch}) - \delta \cdot s_{batch}^2 \iff

dsbatch2δ=[ϵ(δsbatch+dbatch)δsbatch2]dbatch    d's_{batch}^2\delta = [ \epsilon(\delta \cdot s_{batch} + d_{batch}) - \delta \cdot s_{batch}^2 ] d_{batch}\iff

d=[ϵ(δsbatch+dbatch)δsbatch2]dbatchsbatch2δd' = [ \epsilon(\delta \cdot s_{batch} + d_{batch}) - \delta \cdot s_{batch}^2 ] \frac{d_{batch}}{s_{batch}^2\delta}

In a typical branch, since the debt in the debt:shares ratio does not account for redistributions, it is not practical to assume that δsbatch2dbatch\delta * s_{batch}^2 \sim d_{batch} for δ1\delta \geq 1. This means one cannot inflate the ratio by δ\delta in a single adjustment—unless we are dealing with batches that contain a very small number of debt shares.

In order to pull off step 2 in the above-mentioned attack, the griefer would have to:

  • choose a small enough δ\delta (i.e., inflate the ratio by a small number)
  • carefully choose to create debt dd' in order to maximize ϵ\epsilon
  • perform the above two parts multiple times.

The orchestration required, together with the gas cost and the fact that the griefer does not benefit directly, makes this griefing scenario highly unlikely to occur in practice. If the team wishes to address this without changing the rounding direction in the debt shares expression, they could consider allowing only the batch manager or the trove owner to kick a trove out of a branch.

A5

Compiler bugs

ADVISORY
info
A5
Compiler bugs

The code is compiled with Solidity 0.8.24. Version 0.8.24, at the time of writing, has no known bugs.



DISCLAIMER

The audited contracts have been analyzed using automated techniques and extensive human inspection in accordance with state-of-the-art practices as of the date of this report. The audit makes no statements or warranties on the security of the code. On its own, it cannot be considered a sufficient assessment of the correctness of the contract. While we have conducted an analysis to the best of our ability, it is our recommendation for high-value contracts to commission several independent audits, a public bug bounty program, as well as continuous security auditing and monitoring through Dedaub Security Suite.


ABOUT DEDAUB

Dedaub offers significant security expertise combined with cutting-edge program analysis technology to secure some of the most prominent protocols in DeFi. The founders, as well as many of Dedaub's auditors, have a strong academic research background together with a real-world hacker mentality to secure code. Protocol blockchain developers hire us for our foundational analysis tools and deep expertise in program analysis, reverse engineering, DeFi exploits, cryptography and financial mathematics.