Skip to content

Commit eeb3eff

Browse files
marktodaccashwellgretzke
authored
[audit] Staked ETH Rounding - Disable exact output (#460)
* fix: steth rounding * feat: add fork tests * feat: fork tests * fix(audit): revert exactOutput swaps for wstETH<>stETH Due to an unavoidable property of the share mechanics used internally within the stETH implementation, it is not currently possible for v4 to support exact outputs in either direction when interacting with stETH. Rather than violate the spirit of the exactOut request by either overshooting the conversion target or ignoring the unexpected delta, this implementation opts to disallow exactOut by reverting in the `beforeSwap` hook. This resolves H-01. * feat: add wsteth fork tests since it has a very specific rounding implementation we test it with the real thing * Use infura for fork testing * add infura api key to environment * bump deprecated ubuntu version * Only run fork tests if API key is present, else skip (#462) * fix: comments * revert: semgrep * fix: merge * fix: snapshots * fix: snapshots --------- Co-authored-by: Chris Cashwell <[email protected]> Co-authored-by: gretzke <[email protected]>
1 parent 444c526 commit eeb3eff

File tree

12 files changed

+457
-127
lines changed

12 files changed

+457
-127
lines changed

.github/workflows/test.yml

+1
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ jobs:
2626
env:
2727
FOUNDRY_PROFILE: ci
2828
FORGE_SNAPSHOT_CHECK: true
29+
INFURA_API_KEY: ${{ secrets.INFURA_API_KEY }}

foundry.toml

+1-2
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,4 @@ sepolia = "https://rpc.sepolia.org"
3838
unichain_sepolia = "https://sepolia.unichain.org"
3939
base_sepolia = "https://sepolia.base.org"
4040
arbitrum_sepolia = "https://sepolia-rollup.arbitrum.io/rpc"
41-
42-
# See more config options https://github.com/foundry-rs/foundry/tree/master/config
41+
mainnet = "https://mainnet.infura.io/v3/${INFURA_API_KEY}"

snapshots/StateViewTest.json

+10-10
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
{
2-
"StateView_extsload_getFeeGrowthGlobals": "2203",
3-
"StateView_extsload_getFeeGrowthInside": "7875",
4-
"StateView_extsload_getLiquidity": "1439",
5-
"StateView_extsload_getPositionInfo": "2761",
6-
"StateView_extsload_getPositionLiquidity": "1691",
7-
"StateView_extsload_getSlot0": "1486",
8-
"StateView_extsload_getTickBitmap": "1432",
9-
"StateView_extsload_getTickFeeGrowthOutside": "2490",
10-
"StateView_extsload_getTickInfo": "2693",
11-
"StateView_extsload_getTickLiquidity": "1686"
2+
"StateView_extsload_getFeeGrowthGlobals": "8703",
3+
"StateView_extsload_getFeeGrowthInside": "24375",
4+
"StateView_extsload_getLiquidity": "5939",
5+
"StateView_extsload_getPositionInfo": "11261",
6+
"StateView_extsload_getPositionLiquidity": "6191",
7+
"StateView_extsload_getSlot0": "5986",
8+
"StateView_extsload_getTickBitmap": "5932",
9+
"StateView_extsload_getTickFeeGrowthOutside": "8990",
10+
"StateView_extsload_getTickInfo": "11193",
11+
"StateView_extsload_getTickLiquidity": "6186"
1212
}

src/base/hooks/BaseTokenWrapperHook.sol

+44-14
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ abstract contract BaseTokenWrapperHook is BaseHook, DeltaResolver {
3636
/// @dev Fee must be 0 as wrapper pools don't charge fees
3737
error InvalidPoolFee();
3838

39+
/// @notice Thrown when exact input swaps are not supported
40+
error ExactInputNotSupported();
41+
42+
/// @notice Thrown when exact output swaps are not supported
43+
error ExactOutputNotSupported();
44+
3945
/// @notice The wrapped token currency (e.g., WETH)
4046
Currency public immutable wrapperCurrency;
4147

@@ -118,27 +124,25 @@ abstract contract BaseTokenWrapperHook is BaseHook, DeltaResolver {
118124
returns (bytes4, BeforeSwapDelta swapDelta, uint24)
119125
{
120126
bool isExactInput = params.amountSpecified < 0;
127+
if (isExactInput && !_supportsExactInput()) revert ExactInputNotSupported();
128+
if (!isExactInput && !_supportsExactOutput()) revert ExactOutputNotSupported();
121129

122130
if (wrapZeroForOne == params.zeroForOne) {
123131
// we are wrapping
124132
uint256 inputAmount =
125133
isExactInput ? uint256(-params.amountSpecified) : _getWrapInputRequired(uint256(params.amountSpecified));
126-
_take(underlyingCurrency, address(this), inputAmount);
127-
uint256 wrappedAmount = _deposit(inputAmount);
128-
_settle(wrapperCurrency, address(this), wrappedAmount);
134+
(uint256 actualUnderlyingAmount, uint256 wrappedAmount) = _deposit(inputAmount);
129135
int128 amountUnspecified =
130-
isExactInput ? -wrappedAmount.toInt256().toInt128() : inputAmount.toInt256().toInt128();
136+
isExactInput ? -wrappedAmount.toInt256().toInt128() : actualUnderlyingAmount.toInt256().toInt128();
131137
swapDelta = toBeforeSwapDelta(-params.amountSpecified.toInt128(), amountUnspecified);
132138
} else {
133139
// we are unwrapping
134140
uint256 inputAmount = isExactInput
135141
? uint256(-params.amountSpecified)
136142
: _getUnwrapInputRequired(uint256(params.amountSpecified));
137-
_take(wrapperCurrency, address(this), inputAmount);
138-
uint256 unwrappedAmount = _withdraw(inputAmount);
139-
_settle(underlyingCurrency, address(this), unwrappedAmount);
143+
(uint256 actualWrappedAmount, uint256 unwrappedAmount) = _withdraw(inputAmount);
140144
int128 amountUnspecified =
141-
isExactInput ? -unwrappedAmount.toInt256().toInt128() : inputAmount.toInt256().toInt128();
145+
isExactInput ? -unwrappedAmount.toInt256().toInt128() : actualWrappedAmount.toInt256().toInt128();
142146
swapDelta = toBeforeSwapDelta(-params.amountSpecified.toInt128(), amountUnspecified);
143147
}
144148

@@ -155,17 +159,29 @@ abstract contract BaseTokenWrapperHook is BaseHook, DeltaResolver {
155159

156160
/// @notice Deposits underlying tokens to receive wrapper tokens
157161
/// @param underlyingAmount The amount of underlying tokens to deposit
162+
/// @return actualUnderlyingAmount the actual number of underlying tokens used, i.e. to account for rebasing rounding errors
158163
/// @return wrappedAmount The amount of wrapper tokens received
159-
/// @dev Implementing contracts should handle the wrapping operation
160-
/// The base contract will handle settling tokens with the pool manager
161-
function _deposit(uint256 underlyingAmount) internal virtual returns (uint256 wrappedAmount);
164+
/// @dev Implementing contracts should handle:
165+
// - taking tokens from PoolManager
166+
// - performing the wrapping operation
167+
// - settling tokens on PoolManager
168+
function _deposit(uint256 underlyingAmount)
169+
internal
170+
virtual
171+
returns (uint256 actualUnderlyingAmount, uint256 wrappedAmount);
162172

163173
/// @notice Withdraws wrapper tokens to receive underlying tokens
164174
/// @param wrappedAmount The amount of wrapper tokens to withdraw
175+
/// @return actualWrappedAmount the actual number of wrapped tokens used, i.e. to account for rebasing rounding errors
165176
/// @return underlyingAmount The amount of underlying tokens received
166-
/// @dev Implementing contracts should handle the unwrapping operation
167-
/// The base contract will handle settling tokens with the pool manager
168-
function _withdraw(uint256 wrappedAmount) internal virtual returns (uint256 underlyingAmount);
177+
/// @dev Implementing contracts should handle:
178+
// - taking tokens from PoolManager
179+
// - performing the unwrapping operation
180+
// - settling tokens on PoolManager
181+
function _withdraw(uint256 wrappedAmount)
182+
internal
183+
virtual
184+
returns (uint256 actualWrappedAmount, uint256 underlyingAmount);
169185

170186
/// @notice Calculates underlying tokens needed to receive desired wrapper tokens
171187
/// @param wrappedAmount The desired amount of wrapper tokens
@@ -184,4 +200,18 @@ abstract contract BaseTokenWrapperHook is BaseHook, DeltaResolver {
184200
function _getUnwrapInputRequired(uint256 underlyingAmount) internal view virtual returns (uint256) {
185201
return underlyingAmount;
186202
}
203+
204+
/// @notice Indicates whether the hook supports exact output swaps
205+
/// @dev Default implementation returns true
206+
/// @dev Override for wrappers that cannot support exact output swaps
207+
function _supportsExactOutput() internal view virtual returns (bool) {
208+
return true;
209+
}
210+
211+
/// @notice Indicates whether the hook supports exact input swaps
212+
/// @dev Default implementation returns true
213+
/// @dev Override for wrappers that cannot support exact input swaps
214+
function _supportsExactInput() internal view virtual returns (bool) {
215+
return true;
216+
}
187217
}

src/hooks/WETHHook.sol

+16-5
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,26 @@ contract WETHHook is BaseTokenWrapperHook {
2727
}
2828

2929
/// @inheritdoc BaseTokenWrapperHook
30-
function _deposit(uint256 underlyingAmount) internal override returns (uint256) {
31-
weth.deposit{value: underlyingAmount}();
32-
return underlyingAmount; // 1:1 ratio
30+
/// @dev Note the WETH deposit relies on the WETH wrapper having a receive function that mints WETH to msg.sender
31+
function _deposit(uint256 underlyingAmount) internal override returns (uint256, uint256) {
32+
// Sync WETH on PoolManager
33+
poolManager.sync(wrapperCurrency);
34+
// take ETH from PoolManager and deposit directly into the WETH contract
35+
// this will mint WETH to msg.sender (PoolManager in this case)
36+
_take(underlyingCurrency, address(weth), underlyingAmount);
37+
// Settle on PoolManager which will take into account the new weth
38+
poolManager.settle();
39+
return (underlyingAmount, underlyingAmount); // 1:1 ratio
3340
}
3441

3542
/// @inheritdoc BaseTokenWrapperHook
36-
function _withdraw(uint256 wrapperAmount) internal override returns (uint256) {
43+
function _withdraw(uint256 wrapperAmount) internal override returns (uint256, uint256) {
44+
// take WETH into this hook contract
45+
_take(wrapperCurrency, address(this), wrapperAmount);
46+
// Withdraw WETH - this returns ETH back to this hook contract
3747
weth.withdraw(wrapperAmount);
38-
return wrapperAmount; // 1:1 ratio
48+
_settle(underlyingCurrency, address(this), wrapperAmount);
49+
return (wrapperAmount, wrapperAmount); // 1:1 ratio
3950
}
4051

4152
/// @notice Required to receive ETH

src/hooks/WstETHHook.sol

+36-15
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,20 @@ pragma solidity 0.8.26;
33

44
import {ERC20} from "solmate/src/tokens/ERC20.sol";
55
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
6-
import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol";
6+
import {FixedPointMathLib} from "solmate/src/utils/FixedPointMathLib.sol";
7+
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
78
import {BaseTokenWrapperHook} from "../base/hooks/BaseTokenWrapperHook.sol";
8-
import {IWstETH} from "../interfaces/external/IWstETH.sol";
9+
import {IWstETH, IStETH} from "../interfaces/external/IWstETH.sol";
10+
import {SafeTransferLib} from "solmate/src/utils/SafeTransferLib.sol";
911

1012
/// @title Wrapped Staked ETH (wstETH) Hook
1113
/// @notice Hook for wrapping/unwrapping stETH/wstETH in Uniswap V4 pools
1214
/// @dev Implements dynamic exchange rate wrapping/unwrapping between stETH and wstETH
1315
/// @dev wstETH represents stETH with accrued staking rewards, maintaining a dynamic exchange rate
1416
contract WstETHHook is BaseTokenWrapperHook {
17+
using FixedPointMathLib for uint256;
18+
using SafeTransferLib for ERC20;
19+
1520
/// @notice The wstETH contract used for wrapping/unwrapping operations
1621
IWstETH public immutable wstETH;
1722

@@ -22,28 +27,39 @@ contract WstETHHook is BaseTokenWrapperHook {
2227
constructor(IPoolManager _manager, IWstETH _wsteth)
2328
BaseTokenWrapperHook(
2429
_manager,
25-
Currency.wrap(address(_wsteth)), // wrapper token is wsteth
30+
Currency.wrap(address(_wsteth)), // wrapper token is wstETH
2631
Currency.wrap(_wsteth.stETH()) // underlying token is stETH
2732
)
2833
{
2934
wstETH = _wsteth;
30-
ERC20(Currency.unwrap(underlyingCurrency)).approve(address(wstETH), type(uint256).max);
35+
ERC20(Currency.unwrap(underlyingCurrency)).safeApprove(address(wstETH), type(uint256).max);
3136
}
3237

3338
/// @inheritdoc BaseTokenWrapperHook
34-
/// @notice Wraps stETH to wstETH
35-
/// @param underlyingAmount Amount of stETH to wrap
36-
/// @return Amount of wstETH received
37-
function _deposit(uint256 underlyingAmount) internal override returns (uint256) {
38-
return wstETH.wrap(underlyingAmount);
39+
function _deposit(uint256 underlyingAmount)
40+
internal
41+
override
42+
returns (uint256 actualUnderlyingAmount, uint256 wrappedAmount)
43+
{
44+
_take(underlyingCurrency, address(this), underlyingAmount);
45+
// For wrapping, the key is ensuring we wrap exactly what we got
46+
actualUnderlyingAmount = IStETH(Currency.unwrap(underlyingCurrency)).balanceOf(address(this));
47+
48+
// Wrap exactly what we have (which might be 1-2 wei less than requested)
49+
wrappedAmount = wstETH.wrap(actualUnderlyingAmount);
50+
_settle(wrapperCurrency, address(this), wrappedAmount);
3951
}
4052

4153
/// @inheritdoc BaseTokenWrapperHook
42-
/// @notice Unwraps wstETH to stETH
43-
/// @param wrapperAmount Amount of wstETH to unwrap
44-
/// @return Amount of stETH received
45-
function _withdraw(uint256 wrapperAmount) internal override returns (uint256) {
46-
return wstETH.unwrap(wrapperAmount);
54+
function _withdraw(uint256 wrapperAmount)
55+
internal
56+
override
57+
returns (uint256 actualWrappedAmount, uint256 unwrappedAmount)
58+
{
59+
_take(wrapperCurrency, address(this), wrapperAmount);
60+
actualWrappedAmount = wrapperAmount;
61+
unwrappedAmount = wstETH.unwrap(actualWrappedAmount);
62+
_settle(underlyingCurrency, address(this), unwrappedAmount);
4763
}
4864

4965
/// @inheritdoc BaseTokenWrapperHook
@@ -52,7 +68,7 @@ contract WstETHHook is BaseTokenWrapperHook {
5268
/// @return Amount of stETH required
5369
/// @dev Uses current stETH/wstETH exchange rate for calculation
5470
function _getWrapInputRequired(uint256 wrappedAmount) internal view override returns (uint256) {
55-
return wstETH.getStETHByWstETH(wrappedAmount);
71+
return wrappedAmount.divWadUp(wstETH.tokensPerStEth());
5672
}
5773

5874
/// @inheritdoc BaseTokenWrapperHook
@@ -63,4 +79,9 @@ contract WstETHHook is BaseTokenWrapperHook {
6379
function _getUnwrapInputRequired(uint256 underlyingAmount) internal view override returns (uint256) {
6480
return wstETH.getWstETHByStETH(underlyingAmount);
6581
}
82+
83+
/// @inheritdoc BaseTokenWrapperHook
84+
function _supportsExactOutput() internal pure override returns (bool) {
85+
return false;
86+
}
6687
}

src/interfaces/external/IWstETH.sol

+10
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,15 @@ interface IWstETH {
1111
function unwrap(uint256 _wstETHAmount) external returns (uint256);
1212
function getStETHByWstETH(uint256 _wstETHAmount) external view returns (uint256);
1313
function getWstETHByStETH(uint256 _stETHAmount) external view returns (uint256);
14+
function tokensPerStEth() external view returns (uint256);
15+
function stEthPerToken() external view returns (uint256);
1416
function stETH() external view returns (address);
1517
}
18+
19+
interface IStETH {
20+
function getSharesByPooledEth(uint256 stEthAmount) external view returns (uint256);
21+
function getPooledEthByShares(uint256 _sharesAmount) external view returns (uint256);
22+
function sharesOf(address _account) external view returns (uint256);
23+
function transferShares(address recipient, uint256 shares) external;
24+
function balanceOf(address _account) external view returns (uint256);
25+
}

test/hooks/WETHHook.t.sol

+2-2
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ contract WETHHookTest is Test, Deployers {
9494

9595
vm.startPrank(alice);
9696
vm.expectEmit(true, true, true, true);
97-
emit Transfer(address(0), address(hook), wrapAmount);
97+
emit Transfer(address(0), address(manager), wrapAmount);
9898
vm.expectEmit(true, true, true, true);
9999
emit Transfer(address(manager), address(alice), wrapAmount);
100100

@@ -175,7 +175,7 @@ contract WETHHookTest is Test, Deployers {
175175

176176
vm.startPrank(alice);
177177
vm.expectEmit(true, true, true, true);
178-
emit Transfer(address(0), address(hook), wrapAmount);
178+
emit Transfer(address(0), address(manager), wrapAmount);
179179
vm.expectEmit(true, true, true, true);
180180
emit Transfer(address(manager), address(alice), wrapAmount);
181181

0 commit comments

Comments
 (0)