Skip to main content
sBOLD - May 19, 2025

sBOLD

Smart Contract Security Assessment

May 19, 2025

K3_Capital

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:

  1. Profit from the liquidation penalty incurred on liquidated collateral
  2. 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:

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

P1

The protocol’s operability relies on the market’s BOLD liquidity

PROTOCOL-LEVEL-CONSIDERATION
info
P1
The protocol’s operability relies on the market’s BOLD liquidity

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.

P2

The protocol’s inability to accurately price BOLD could have security consequences

PROTOCOL-LEVEL-CONSIDERATION
info
P2
The protocol’s inability to accurately price BOLD could have security consequences

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:

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

L1

Race condition in oracle updates

LOW
acknowledged
L1
Race condition in oracle updates

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.

BOLDcollateral=USDcollateralBOLDUSD=USDcollateral1USDBOLD\frac{BOLD}{collateral} = \frac{USD}{collateral} \frac{BOLD}{USD} = \frac{USD}{collateral} \frac{1}{\frac{USD}{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


    We will assume that:

    • The value of the vault’s collateral + BOLD balance prior to the oracle update is bVbV
    • The vault had a total amount of tStS 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 bV=(1+δ)bVbV' = (1 + \delta)bV
    • The user deposits MM BOLD

    The user in step (2) will receive

    tSbVM\frac{tS}{bV}M

    shares.

    And instead of redeeming them at a

    bV+MtS+tSbVM\frac{bV + M}{tS + \frac{tS}{bV}M}

    rate,

    The user will redeem their shares at a


    bV(1+δ)+MtS+tSbVM\frac{bV(1 + \delta) + M}{tS + \frac{tS}{bV}M}

    rate.

    The profitability of this race condition solely depends on δ\delta , 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.)

N1

Owner is a trusted party

CENTRALIZATION
info
N1
Owner is a trusted party

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.

A1

Code optimisations when checking for duplicate SPs

ADVISORY
info
A1
Code optimisations when checking for duplicate SPs

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).

A2

Code optimisation in prepareSwap

ADVISORY
info
A2
Code optimisation in prepareSwap

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
}
}
}

A3

Counter-intuitive handling of dead shares

ADVISORY
info
A3
Counter-intuitive handling of dead shares

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.

A4

Compiler bugs

ADVISORY
info
A4
Compiler bugs

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.