Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions src/core/PaymentsEffectiveExposure.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ pragma solidity ^0.8.26;
import { Types } from "../libraries/Types.sol";
import { IStaking } from "../interfaces/IStaking.sol";
import { IPriceOracle } from "../oracles/interfaces/IPriceOracle.sol";
import { IAssetAdapter } from "../staking/adapters/IAssetAdapter.sol";

/// @notice Minimal view of the staking diamond's adapter registry (see `DepositManager`).
interface IAdapterLookup {
function getAssetAdapter(address token) external view returns (address);
}

/// @title PaymentsEffectiveExposure
/// @notice Mixin for computing effective exposure (delegation × exposureBps) for payment distribution
Expand Down Expand Up @@ -82,7 +88,9 @@ abstract contract PaymentsEffectiveExposure {
uint256 delegation = staking.getOperatorStakeForAsset(operator, commitment.asset);

if (delegation > 0) {
// Calculate exposed amount: delegation × exposureBps / _BPS_DENOM
// `delegation` is in staking DEPOSIT-UNITS (adapter shares for adapter-backed
// assets, raw tokens otherwise). Calculate exposed amount in those units:
// delegation × exposureBps / _BPS_DENOM.
uint256 exposedAmount = (delegation * commitment.exposureBps) / _BPS_DENOM;

if (useOracle && exposedAmount > 0) {
Expand All @@ -91,7 +99,11 @@ abstract contract PaymentsEffectiveExposure {
// payout weights across heterogeneous assets.
address token =
commitment.asset.kind == Types.AssetKind.Native ? address(0) : commitment.asset.token;
operatorEffectiveExposure += oracle.toUSD(token, exposedAmount);
// `oracle.toUSD` expects TOKEN units. Convert deposit-units -> tokens via the
// adapter for non-1:1 (rebasing) assets, otherwise the USD exposure (and thus
// the payout weight) is mis-priced. 1:1 for native / non-adapter assets.
uint256 exposedTokens = _depositUnitsToTokenAmount(staking, token, exposedAmount);
operatorEffectiveExposure += oracle.toUSD(token, exposedTokens);
} else {
// No oracle: use raw amount
operatorEffectiveExposure += exposedAmount;
Expand All @@ -112,6 +124,23 @@ abstract contract PaymentsEffectiveExposure {
}
}

/// @notice Convert a staking deposit-unit amount to raw token units for an asset.
/// @dev For adapter-backed assets, deposit-units are adapter shares; `sharesToAssets` maps them
/// to the rebasing token amount that `IPriceOracle.toUSD` expects. Native (token == 0) and
/// non-adapter assets are 1:1. Fails open to the input on a missing adapter so a
/// misconfiguration degrades to the prior (deposit-unit) behavior rather than reverting the
/// whole payment distribution.
function _depositUnitsToTokenAmount(IStaking staking, address token, uint256 units)
internal
view
returns (uint256)
{
if (token == address(0) || units == 0) return units;
address adapter = IAdapterLookup(address(staking)).getAssetAdapter(token);
if (adapter == address(0)) return units;
return IAssetAdapter(adapter).sharesToAssets(units);
}

/// @notice Calculate effective exposures using simple exposureBps fallback
/// @dev Used when operators have no security commitments (uses stored exposureBps directly)
/// @param operators Array of operator addresses
Expand Down
49 changes: 42 additions & 7 deletions src/staking/LiquidDelegationVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,21 @@ contract LiquidDelegationVault is ERC20, IERC7540Deposit, IERC7540Redeem, IERC75
// here would hand the depositor an inflated share of existing holders' stake.
if (!_rateDefinedForMint()) revert RateUndefined();

shares = convertToShares(assets);
if (shares == 0) revert ZeroShares();
// Snapshot the pre-deposit rate; the delegation pool grows by the CREDITED deposit-units
// computed below, not by the raw `assets`.
uint256 supplyBefore = totalSupply();
uint256 assetsBefore = totalAssets();

// Transfer assets from sender, push into staking, and delegate the exact
// deposit-units the staking layer credited (adapter shares for adapter-backed
// assets, raw assets otherwise).
_depositAndDelegate(assets);
uint256 credited = _depositAndDelegate(assets);

// Mint shares against the CREDITED deposit-units, NOT the raw token `assets`. `totalAssets()`
// is deposit-unit-denominated; for a non-1:1 (rebasing) adapter `credited != assets`, so
// pricing the mint off raw `assets` would over-/under-mint and dilute existing holders.
shares = credited.mulDiv(supplyBefore + VIRTUAL_SHARES, assetsBefore + VIRTUAL_ASSETS, Math.Rounding.Floor);
if (shares == 0) revert ZeroShares();

// Mint liquid shares to receiver
_mint(receiver, shares);
Expand Down Expand Up @@ -323,20 +331,47 @@ contract LiquidDelegationVault is ERC20, IERC7540Deposit, IERC7540Redeem, IERC75
// Refuse to mint while the rate is collapsed (slash-during-pending-redeem); see deposit().
if (!_rateDefinedForMint()) revert RateUndefined();

// Snapshot the pre-deposit rate (see deposit()).
uint256 supplyBefore = totalSupply();
uint256 assetsBefore = totalAssets();

// ERC-4626: mint() must round the asset cost UP so the minter pays at least fair value.
// Flooring here (as `convertToAssets` does) lets a minter receive shares worth more than
// they pay, diluting existing holders by the fractional remainder each call.
assets = _convertToAssetsRoundUp(shares);
// `_convertToAssetsRoundUp` returns the cost in DEPOSIT-UNITS (totalAssets()'s denomination).
uint256 requiredUnits = _convertToAssetsRoundUp(shares);
if (requiredUnits == 0) revert ZeroAssets();

// Convert the required deposit-units to the RAW token amount to pull. For a non-1:1
// (rebasing) adapter, deposit-units (adapter shares) are not raw tokens; charging
// `requiredUnits` raw tokens would mis-fund the position. 1:1 for non-adapter assets.
assets = _depositUnitsToAssets(requiredUnits);
if (assets == 0) revert ZeroAssets();

// Use the shared deposit/delegate path (correct spender + credited-unit delegation).
_depositAndDelegate(assets);
uint256 credited = _depositAndDelegate(assets);

// Mint against the CREDITED deposit-units at the pre-deposit rate (unit-consistent, and
// never dilutive). For 1:1 assets this equals the requested `shares`; a non-1:1 adapter may
// round it by a wei-level amount, which is the correct behavior given an externally-rebasing
// deposit unit.
shares = credited.mulDiv(supplyBefore + VIRTUAL_SHARES, assetsBefore + VIRTUAL_ASSETS, Math.Rounding.Floor);
if (shares == 0) revert ZeroShares();

_mint(receiver, shares);

emit Deposit(msg.sender, receiver, assets, shares);
}

/// @notice Convert staking deposit-units to the raw token amount they represent.
/// @dev Inverse of the credited-units the staking layer mints on deposit. For adapter-backed
/// assets, deposit-units are adapter shares, so `sharesToAssets` maps them to raw tokens;
/// 1:1 for non-adapter assets. Used by `mint` to size the token pull for a target unit cost.
function _depositUnitsToAssets(uint256 units) internal view returns (uint256) {
if (isNative || units == 0) return units;
address adapter = IAdapterLookup(address(staking)).getAssetAdapter(address(asset));
if (adapter == address(0)) return units;
return IAssetAdapter(adapter).sharesToAssets(units);
}

// ═══════════════════════════════════════════════════════════════════════════
// ASYNCHRONOUS REDEMPTION (ERC7540)
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
243 changes: 243 additions & 0 deletions test/audit/batch3/AdapterUnitMismatch.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import { Test } from "forge-std/Test.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

import { IMultiAssetDelegation } from "../../../src/interfaces/IMultiAssetDelegation.sol";
import { MultiAssetDelegation } from "../../../src/staking/MultiAssetDelegation.sol";
import { LiquidDelegationVault } from "../../../src/staking/LiquidDelegationVault.sol";
import { LiquidDelegationFactory } from "../../../src/staking/LiquidDelegationFactory.sol";
import { RebasingAssetAdapter } from "../../../src/staking/adapters/RebasingAssetAdapter.sol";
import { Types } from "../../../src/libraries/Types.sol";
import { StakingOperatorsFacet } from "../../../src/facets/staking/StakingOperatorsFacet.sol";
import { StakingDepositsFacet } from "../../../src/facets/staking/StakingDepositsFacet.sol";
import { StakingDelegationsFacet } from "../../../src/facets/staking/StakingDelegationsFacet.sol";
import { StakingSlashingFacet } from "../../../src/facets/staking/StakingSlashingFacet.sol";
import { StakingAssetsFacet } from "../../../src/facets/staking/StakingAssetsFacet.sol";
import { StakingViewsFacet } from "../../../src/facets/staking/StakingViewsFacet.sol";
import { StakingAdminFacet } from "../../../src/facets/staking/StakingAdminFacet.sol";
import { PaymentsEffectiveExposure, IAdapterLookup } from "../../../src/core/PaymentsEffectiveExposure.sol";
import { IStaking } from "../../../src/interfaces/IStaking.sol";
import { IPriceOracle } from "../../../src/oracles/interfaces/IPriceOracle.sol";
import { IAssetAdapter } from "../../../src/staking/adapters/IAssetAdapter.sol";

/// @notice Rebasing token (stETH-style): balances scale by a global multiplier.
contract MockRebasingToken is ERC20 {
uint256 public rebaseMultiplier = 1e18;

constructor() ERC20("Mock stETH", "mstETH") { }

function mint(address to, uint256 amount) external {
_mint(to, (amount * 1e18) / rebaseMultiplier);
}

function rebase(uint256 bps) external {
rebaseMultiplier = (rebaseMultiplier * (10_000 + bps)) / 10_000;
}

function balanceOf(address a) public view override returns (uint256) {
return (super.balanceOf(a) * rebaseMultiplier) / 1e18;
}

function totalSupply() public view override returns (uint256) {
return (super.totalSupply() * rebaseMultiplier) / 1e18;
}

function transfer(address to, uint256 amount) public override returns (bool) {
return super.transfer(to, (amount * 1e18) / rebaseMultiplier);
}

function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
return super.transferFrom(from, to, (amount * 1e18) / rebaseMultiplier);
}
}

/// @title AdapterUnitMismatchTest
/// @notice F1 regression: LiquidDelegationVault must mint shares against the CREDITED deposit-units
/// (adapter shares), not the raw token amount. With a rebasing adapter the two diverge; the
/// pre-fix code minted off the raw amount, letting a post-rebase depositor mint far more
/// shares than they actually funded — diluting earlier holders.
contract AdapterUnitMismatchTest is Test {
IMultiAssetDelegation internal staking;
LiquidDelegationFactory internal factory;
MockRebasingToken internal token;
RebasingAssetAdapter internal adapter;

address internal admin = makeAddr("admin");
address internal operator1 = makeAddr("operator1");
address internal user1 = makeAddr("user1");
address internal user2 = makeAddr("user2");

uint256 constant MIN_OPERATOR_STAKE = 1 ether;

function setUp() public {
token = new MockRebasingToken();

MultiAssetDelegation impl = new MultiAssetDelegation();
ERC1967Proxy proxy = new ERC1967Proxy(
address(impl), abi.encodeCall(MultiAssetDelegation.initialize, (admin, MIN_OPERATOR_STAKE, 0, 1000))
);
staking = IMultiAssetDelegation(payable(address(proxy)));

MultiAssetDelegation router = MultiAssetDelegation(payable(address(proxy)));
vm.startPrank(admin);
router.registerFacet(address(new StakingOperatorsFacet()));
router.registerFacet(address(new StakingDepositsFacet()));
router.registerFacet(address(new StakingDelegationsFacet()));
router.registerFacet(address(new StakingSlashingFacet()));
router.registerFacet(address(new StakingAssetsFacet()));
router.registerFacet(address(new StakingViewsFacet()));
router.registerFacet(address(new StakingAdminFacet()));

// Rebasing adapter for the token, wired to the staking diamond.
adapter = new RebasingAssetAdapter(address(token), admin);
adapter.setDelegationManager(address(staking));

staking.enableAsset(address(token), 1 ether, 0.1 ether, 0, 10_000);
staking.registerAdapter(address(token), address(adapter));

staking.addSlasher(admin);
staking.setTangle(admin);
vm.stopPrank();

factory = new LiquidDelegationFactory(staking);

vm.deal(operator1, 100 ether);
vm.prank(operator1);
staking.registerOperator{ value: 10 ether }();
vm.prank(operator1);
staking.setDelegationMode(Types.DelegationMode.Open);

token.mint(user1, 1000 ether);
token.mint(user2, 1000 ether);
}

function test_F1_DepositMintsOnCreditedUnitsNotRawAmount() public {
address vaultAddr = factory.createAllBlueprintsVault(operator1, address(token));
LiquidDelegationVault vault = LiquidDelegationVault(payable(vaultAddr));

// user1 deposits 100 tokens at the 1:1 bootstrap rate.
vm.startPrank(user1);
token.approve(address(vault), 100 ether);
uint256 user1Shares = vault.deposit(100 ether, user1);
vm.stopPrank();
assertGt(user1Shares, 0, "user1 minted shares");

// The adapter's token balance doubles via a +100% rebase. Adapter SHARES (deposit-units)
// are rebase-invariant, so the vault's accounting is unchanged — but 1 token is now worth
// HALF an adapter share. A depositor of the same nominal 100 tokens must therefore receive
// ~half the vault shares user1 got.
token.rebase(10_000); // +100%

vm.startPrank(user2);
token.approve(address(vault), 100 ether);
uint256 user2Shares = vault.deposit(100 ether, user2);
vm.stopPrank();

// FIXED: user2 funded ~half the deposit-units, so gets ~half the shares.
// BUGGY (raw-amount mint): user2 would get == user1Shares (100 raw vs 100 raw), stealing
// value from user1. This assertion fails under the bug.
assertLt(user2Shares, user1Shares, "post-rebase deposit must mint on credited units, not raw amount");
assertApproxEqRel(user2Shares, user1Shares / 2, 0.02e18, "~half the shares for the same nominal deposit");
}
}

/// @notice Harness exposing the internal effective-exposure calculation with overridable deps.
contract ExposureHarness is PaymentsEffectiveExposure {
address public stakingAddr;
address public oracleAddr;
mapping(uint64 => mapping(address => Types.AssetSecurityCommitment[])) internal _commits;

function setStaking(address s) external {
stakingAddr = s;
}

function setOracle(address o) external {
oracleAddr = o;
}

function addCommitment(uint64 svc, address op, Types.Asset memory a, uint16 bps) external {
_commits[svc][op].push(Types.AssetSecurityCommitment({ asset: a, exposureBps: bps }));
}

function _getStaking() internal view override returns (IStaking) {
return IStaking(stakingAddr);
}

function _getPriceOracle() internal view override returns (address) {
return oracleAddr;
}

function _getServiceSecurityCommitments(
uint64 svc,
address op
)
internal
view
override
returns (Types.AssetSecurityCommitment[] storage)
{
return _commits[svc][op];
}

function calc(uint64 svc, address[] memory ops) external view returns (uint256[] memory, uint256) {
return _calculateEffectiveExposures(svc, ops);
}
}

/// @notice F2 regression: effective-exposure USD weighting must convert deposit-units (adapter
/// shares) to TOKEN units before `oracle.toUSD`, which expects token units. Under a
/// non-1:1 (rebasing) adapter the pre-fix code fed deposit-units straight into toUSD,
/// mis-pricing the operator's payout weight.
contract ExposureUnitMismatchTest is Test {
function test_F2_ExposureUsesTokenUnitsNotDepositUnits() public {
ExposureHarness h = new ExposureHarness();
address mockStaking = makeAddr("mockStaking");
address mockAdapter = makeAddr("mockAdapter");
address mockOracle = makeAddr("mockOracle");
address erc20 = makeAddr("erc20token");

h.setStaking(mockStaking);
h.setOracle(mockOracle);

uint64 svc = 1;
address op = makeAddr("op");
Types.Asset memory asset = Types.Asset({ kind: Types.AssetKind.ERC20, token: erc20 });
h.addCommitment(svc, op, asset, 10_000); // 100% exposure

uint256 depositUnits = 100 ether; // adapter SHARES (what staking tracks)
uint256 tokenAmount = 200 ether; // their value in TOKENS after a +100% rebase

// staking.getOperatorStakeForAsset(op, asset) -> deposit-units
vm.mockCall(
mockStaking,
abi.encodeWithSelector(IStaking.getOperatorStakeForAsset.selector, op, asset),
abi.encode(depositUnits)
);
// staking.getAssetAdapter(erc20) -> mockAdapter
vm.mockCall(
mockStaking, abi.encodeWithSelector(IAdapterLookup.getAssetAdapter.selector, erc20), abi.encode(mockAdapter)
);
// adapter.sharesToAssets(depositUnits) -> tokenAmount (the non-1:1 conversion)
vm.mockCall(
mockAdapter,
abi.encodeWithSelector(IAssetAdapter.sharesToAssets.selector, depositUnits),
abi.encode(tokenAmount)
);
// oracle.toUSD(erc20, tokenAmount) -> identity, so the result reveals the amount fed in.
// NOTE: only the TOKEN-unit call is mocked. The buggy path would call toUSD with the raw
// deposit-units (100e18), which has NO mock and reverts — so this test fails pre-fix.
vm.mockCall(
mockOracle, abi.encodeWithSelector(IPriceOracle.toUSD.selector, erc20, tokenAmount), abi.encode(tokenAmount)
);

address[] memory ops = new address[](1);
ops[0] = op;
(uint256[] memory exp, uint256 total) = h.calc(svc, ops);

assertEq(exp[0], tokenAmount, "exposure priced on converted TOKEN units, not deposit-units");
assertEq(total, tokenAmount, "total matches");
}
}
Loading