sBOLD
Smart Contract Security Assessment
May 19, 2025

SUMMARY
ABSTRACT
Dedaub was commissioned to perform a security audit of sBOLD, an ERC-4626 implementation designed to help BOLD holders aggregate yield from interest accrual and liquidation penalties in Liquity.
BACKGROUND
Liquity is a permissionless lending protocol in which borrowers deposit WETH, wstETH, or rETH as collateral and have BOLD minted on their behalf.
An important part of the system is each collateral’s Stability Pool. The Stability Pool implicitly repays the BOLD debt of troves that are liquidated — assuming the contract holds enough BOLD to do so. BOLD holders may choose to deposit their BOLD into the Stability Pool in order to:
- Profit from the liquidation penalty incurred on liquidated collateral
- Gain yield from the system’s accrued interest
sBOLD wraps around the system’s Stability Pools and allows users to deposit BOLD and easily reap the above-mentioned benefits by tokenizing the produced yield into ERC-4626 shares, which should be redeemable for BOLD — minimizing the users’ exposure to seized collateral.
SETTING & CAVEATS
This audit report mainly covers the contracts of the at-the-time private repository https://github.com/K3Capital/sBOLD at commit 3630c7f6247b8fc8a709a9cfd036a90028fe0064
.
2 auditors worked on the codebase for 4 days on the following contracts:
- base/
- BaseSBold.sol
- interfaces/
- ICommon.sol
- IPriceOracle.sol
- IRegistry.sol
- ISBold.sol
- libraries/
- Common.sol
- helpers/
- Constants.sol
- Decimals.sol
- TransientStorage.sol
- logic/
- QuoteLogic.sol
- SpLogic.sol
- SwapLogic.sol
- oracle/
- chainlink/
- BaseChainlinkOracle.sol
- ChainlinkLstOracle.sol
- ChainlinkOracle.sol
- pyth/
- PythOracle.sol
- Registry.sol
- sBold.sol
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.
PROTOCOL-LEVEL CONSIDERATIONS
The protocol’s lifecycle consists of users depositing BOLD into the ERC-4626 vault and receiving sBOLD in return. The vault immediately distributes the deposited BOLD across Liquity’s Stability Pools.
The BOLD deposits in the Stability Pool gradually decrease as liquidations occur, since BOLD is used to repay a liquidated trove’s debt and seize its collateral.
Even though BOLD backs sBOLD, the exchange rate of sBOLD shares is not calculated solely based on the remaining BOLD — since the system’s BOLD balance is decreasing over time.
sBold::396
function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view override returns (uint256) {
return shares.mulDiv(_getSBoldRateWithRounding(rounding), 10 ** decimals(), rounding);
}
/// @notice Calculates the $sBOLD:BOLD rate with input rounding.
/// @param rounding Type of rounding on math calculations.
/// @return The $sBOLD:$BOLD rate.
function _getSBoldRateWithRounding(Math.Rounding rounding) private view returns (uint256) {
(uint256 totalBold, , , ) = _calcFragments();
return (totalBold + 1).mulDiv(10 ** decimals(), totalSupply() + 10 ** _decimalsOffset(), rounding);
}
Instead, the system also accounts for the seized collateral associated with the vault’s Stability Pool position. This collateral, denominated in BOLD, is considered part of the system’s effective BOLD deposits. Using sBold::swap
, the vault can convert the collateral accumulated by its position (and therefore by the positions of its depositors) back into BOLD. This mechanism is essential for facilitating sBOLD withdrawals, even when all BOLD deposits have been consumed by liquidations.
In general, not all BOLD holders choose to provide liquidity for trades. Moreover, as previously noted, Liquity offers strong incentives for users to lock their BOLD in its Stability Pools.
If the vault’s BOLD deposit has been entirely used in liquidations — and under extreme market conditions — there may not be sufficient on-chain BOLD liquidity to immediately convert all seized collateral back into BOLD. As a result, some users attempting to redeem their sBOLD may be unable to do so until BOLD liquidity returns to adequate levels on-chain.
As noted in P1, an important part of the system is the ability to automatically swap seized collateral into BOLD.
Swaps are facilitated through a specific swap adapter (set only by the owner), with the protocol aiming to use 1inch’s AggregationRouterV6
contract.
SwapLogic::_execute
function _execute(
address _adapter,
address _src,
address _dst,
uint256 _inAmount,
uint256 _minOut,
bytes memory _swapData
) private returns (uint256) {
IERC20 dst = IERC20(_dst);
// Get balance before the swap
uint256 balance0 = dst.balanceOf(address(this));
// Approve `_inAmount` for `adapter`
IERC20(_src).approve(_adapter, _inAmount);
// Execute swap
(bool success, bytes memory data) = _adapter.call(_swapData);
// Revert on failed swap
if (!success) revert ISBold.ExecutionFailed(data);
// Get balance after the swap
uint256 balance1 = dst.balanceOf(address(this));
// Get the amount received
uint256 amountOut = balance1 - balance0;
// Check if the amount received is equal or higher to the minimum
if (amountOut < _minOut) revert ISBold.InsufficientAmount(amountOut);
// Return decoded data
return amountOut;
}
The protocol does not explicitly check the swap’s calldata:
SwapLogic::prepareSwap:89
function prepareSwap(
address bold,
IPriceOracle priceOracle,
ISBold.SP[] memory sps,
ISBold.SwapData[] memory swapData
) internal returns (ISBold.SwapDataWithColl[] memory swapDataWithColl) {
if (swapData.length > sps.length || swapData.length == 0) {
revert ISBold.InvalidDataArray();
}
...
swapDataWithColl[i] = ISBold.SwapDataWithColl({
addr: sps[j].coll,
balance: balance, //@Dedaub: will be amount in of the swap
collInBold: collInBold,//@Dedaub: will be used to determine min out
data: swapData[i].data //@Dedaub: not checked
});
...
But instead check the side-effects of the swap by enforcing that the contract’s balance after the swap in BOLD is close to the swapped collateral’s denomination in BOLD.
The protocol not implicitly trusting (via swap slippage) that BOLD will be priced relatively close to $1 makes all collateral-based calculations more precise.
However, in the event that the Pyth oracle used for the BOLD-USD price malfunctions and reports a deviation from the typical BOLD price even momentarily, anyone could force a bad swap on behalf of the vault and steal a significant portion of the available collateral. In that scenario, the execution of a bad swap is made easier since 1inch’s aggregator allows arbitrary swap executors to be used:
AggregationRouterV6:4787
function swap(
IAggregationExecutor executor,
SwapDescription calldata desc,
bytes calldata data
)
external
payable
whenNotPaused()
...
{
...
if (!srcETH) {
srcToken.safeTransferFromUniversal(msg.sender, desc.srcReceiver, desc.amount, desc.flags & _USE_PERMIT2 != 0);
}
returnAmount = _execute(executor, msg.sender, desc.amount, data);
...
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
An important part of all vault operations is the exchange rate of the shares denominated in BOLD.
sBold::_getSBoldRateWithRounding:406
function _calcFragments() private view returns (uint256, uint256, uint256, uint256) {
...
// Get collateral value in USD and $BOLD
(, ..., uint256 collInBold) = _calcCollValue(bold, true);
// Calculate based on the minimum amount to be received after swap
uint256 collToBoldMinOut = SwapLogic.calcMinOut(collInBold, maxSlippage);
// Apply fees after swap
(uint256 collInBoldNet, , ) = SwapLogic.applyFees(collToBoldMinOut, swapFeeBps, rewardBps);
// Calculate total $BOLD value
uint256 totalBold = boldAmount + collInBoldNet;
return (totalBold, ...);
}
...
function _getSBoldRateWithRounding(Math.Rounding rounding) private view returns (uint256) {
(uint256 totalBold, , , ) = _calcFragments();
return (totalBold + 1).mulDiv(10 ** decimals(), totalSupply() + 10 ** _decimalsOffset(), rounding);
}
Apart from the vault’s BOLD balance and all BOLD gains in the Stability Pool, the vault also considers the value of all seized collateral denominated in BOLD.
This naturally makes the exchange rate of the shares sensitive to oracle updates and the following scenario could arise:
- An oracle update is submitted on-chain (e.g. BOLD-USD is updated)
- A user front-runs the oracle update by depositing into the vault
- The oracle update takes place
- The user withdraws the previously deposited shares
- The value of the vault’s collateral + BOLD balance prior to the oracle update is
- The vault had a total amount of shares prior to the oracle update
- The price update lowers the price of BOLD in USD, which means that with all things equal the same amount of collateral would be worth more BOLD tokens after the oracle update. We assume that
- The user deposits BOLD
We will assume that:
The user in step (2) will receive
shares.
And instead of redeeming them at a
rate,
The user will redeem their shares at a
rate.
The profitability of this race condition solely depends on , which will never be significant in typical market conditions. The protocol could choose to guard against this unlikely scenario by incorporating a time-weighted price average for BOLD-USD, although it is not expected for this to be a practical issue.
CENTRALIZATION CONSIDERATIONS
It is often desirable for DeFi protocols to assume no trust in a central authority, including the protocol’s owner. Even if the owner is reputable, users are more likely to engage with a protocol that guarantees no catastrophic failure even in the case the owner gets hacked/compromised. We list issues of this kind below. (These issues should be considered in the context of usage/deployment, as they are not uncommon. Several high-profile, high-value protocols have significant centralization threats.)
Even though the owner cannot arbitrarily set important parameters such as:
- Fee percentage
- Slippage percentage
because the protocol enforces bound checks on those parameters
The owner has the power to:
- Change the vault’s oracle registry, ultimately affecting the calculation of all deposit/withdrawal rates
- Set an arbitrary swap adapter, allowing to use the following snippet as a gadget for arbitrary code execution
SwapLogic::_execute:141
function _execute(
address _adapter,
address _src,
address _dst,
uint256 _inAmount,
uint256 _minOut,
bytes memory _swapData
) private returns (uint256) {
...
// Execute swap
(bool success, bytes memory data) = _adapter.call(_swapData);
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 below function sets the Stability Pools used by the system. It iterates over the input array of sps (_sps
), adding each, while re-iterating over all input pools to check there are no duplicates:
BaseSBold::_setSps:179-196
for (uint256 i = 0; i < _sps.length; i++) {
address spAddress = _sps[i].addr;
uint96 weight = _sps[i].weight;
// Verify input
Common.revertZeroAddress(spAddress);
if (weight == 0) revert ZeroWeight();
for (uint256 j = 0; j < _sps.length; j++) {
if (i != j && spAddress == _sps[j].addr) {
revert DuplicateAddress();
}
}
// Update Storage related to SP
sps.push(SP({sp: spAddress, weight: weight, coll: address(IStabilityPool(spAddress).collToken())}));
totalWeight += weight;
}
This can be optimised, reducing gas costs when _sps
has no duplicates, by iterating only up to i
instead of up to _sps.length
(on line 186).
In prepareSwap
, for each swapData[i]
the corresponding stability pool is discovered by looping over sps
. Once the right pool is found the search can be terminated with a break statement, which continues iteration in the outer loop. Given the definition of BaseSBold::_setSps
, there are no duplicate pools in sps
.
SwapLogic::prepareSwap:65-93
for (uint256 i = 0; i < swapData.length; i++) {
for (uint256 j = 0; j < sps.length; j++) {
if (sps[j].sp == swapData[i].sp) {
…
//@Dedaub: insert break here
}
}
}
Upon deployment, the sBOLD vault mints on its behalf 1e18 shares which are afterwards considered “dead”. This serves as a practical mechanism with which the classic inflation attack in ERC4626 vault is both unprofitable and expensive even when attempting to grief depositors.
However, at various points throughout the code, the vault accounts for the dead shares held by it:
sBold::swap:165
...
uint256 assetsInternal = ERC20(bold).balanceOf(address(this));
uint256 deadShareAmount = 10 ** decimals();
if (assetsInternal > deadShareAmount) {
SpLogic.provideToSP(sps, assetsInternal - deadShareAmount);
}
...
sBold::_maxWithdraw:337
…
if (maxWithdrawAssets > boldAmount) {
uint256 deadShareAmount = 10 ** decimals();
if (boldAmount < deadShareAmount) return 0;
return boldAmount - deadShareAmount;
}
…
The vault attempts to always maintain a 1e18 BOLD value corresponding to each of its shares. However, this is not strictly necessary, since even when the vault’s BOLD deposits are depleted, the protocol still accounts for the value of all seized collateral. A depositor’s shares should always correspond to some value—either in BOLD or seized collateral—and there is no risk of that value being claimed by another depositor.
Additionally, the vault always considers each share to be worth 1e18 BOLD. This is not entirely accurate from a financial perspective, as shares may have accrued yield from BOLD interest and seized collateral in the Stability Pool. This yield is implicitly donated back to the system.
Removing this unnecessary accounting convention would also allow the vault to more easily deposit any dust BOLD (residual balances) into the Stability Pool to generate yield. Some dust is always present due to potential rounding errors when distributing BOLD deposits across Liquity’s Stability Pools.
The code is compiled with Solidity 0.8.24
. Version 0.8.24
, in particular, 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.