Liquity v2 ~ Cantina Fixes Review
Smart Contract Security Assessment
May 13, 2025

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:
- https://github.com/liquity/bold/pull/889
- https://github.com/liquity/bold/pull/890
- https://github.com/liquity/bold/pull/891
- https://github.com/liquity/bold/pull/893
- https://github.com/liquity/bold/pull/894
- https://github.com/liquity/bold/pull/895
- https://github.com/liquity/bold/pull/896
- https://github.com/liquity/bold/pull/897
- https://github.com/liquity/bold/pull/899
The following code improvement pull requests were also put in the scope:
- https://github.com/liquity/bold/pull/918
- https://github.com/liquity/bold/pull/919
- https://github.com/liquity/bold/pull/925
- https://github.com/liquity/bold/pull/926
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:
- 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.
- 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.
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.
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;
...
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);
...
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 debt in a batch with debt and shares should retain the branch's share ratio.
Algebraically, that's easy to observe since:
Since the operation rounds down because of integer division, the actual amount of new shares is
with
When accounting for the rounding error that gets created it can be shown that:
But ultimately we would like to use in order to inflate the ratio by :
In a typical branch, since the debt in the debt:shares ratio does not account for redistributions, it is not practical to assume that for . This means one cannot inflate the ratio by 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 (i.e., inflate the ratio by a small number)
- carefully choose to create debt in order to maximize
- 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.
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.