Skip to content

Commit f953506

Browse files
authored
[yAudit-03] Fix swap fee accrual on realized gain (#306)
* fix: accrue swap fees from realized gain * test(invariants): enforce strict handler decoding
1 parent ceb065d commit f953506

18 files changed

Lines changed: 159 additions & 162 deletions

File tree

src/contracts/AbstractARM.sol

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu
428428
amountOut = convertedAmountIn * config.buyPrice / PRICE_SCALE;
429429
}
430430

431-
_validateAndConsumeSwapLiquidity(config, isBuySide, outToken, amountOut);
431+
_validateAndConsumeSwapLiquidity(config, isBuySide, outToken, amountIn, amountOut);
432432

433433
// Transfer the input tokens from the caller to this ARM contract
434434
inToken.transferFrom(msg.sender, address(this), amountIn);
@@ -468,7 +468,7 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu
468468
amountIn = convertedAmountOut * PRICE_SCALE / config.buyPrice + 3;
469469
}
470470

471-
_validateAndConsumeSwapLiquidity(config, isBuySide, outToken, amountOut);
471+
_validateAndConsumeSwapLiquidity(config, isBuySide, outToken, amountIn, amountOut);
472472

473473
// Transfer the input tokens from the caller to this ARM contract
474474
inToken.transferFrom(msg.sender, address(this), amountIn);
@@ -518,16 +518,18 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu
518518
/// @dev Validate swap reserves and consume the per-base liquidity limit.
519519
/// @param isBuySide True when the ARM buys base asset and pays out liquidity asset.
520520
/// @param outToken Swap output token address.
521+
/// @param amountIn Swap input token amount.
521522
/// @param amountOut Swap output token amount.
522523
function _validateAndConsumeSwapLiquidity(
523524
BaseAssetConfig storage config,
524525
bool isBuySide,
525526
IERC20 outToken,
527+
uint256 amountIn,
526528
uint256 amountOut
527529
) private {
528530
uint256 remaining;
529531
if (isBuySide) {
530-
_accrueSwapFee(config.buyPrice, config.crossPrice, amountOut);
532+
_accrueSwapFee(config, amountIn, amountOut);
531533
remaining = config.buyLiquidityRemaining;
532534
if (amountOut > remaining) revert InsufficientLiquidity();
533535
unchecked {
@@ -581,13 +583,14 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu
581583
return baseDecimals > liquidityAssetDecimals ? amount * 1e12 : amount / 1e12;
582584
}
583585

584-
/// @dev Accrue fees on discounted buy-side swaps using the recognized NAV gain.
585-
/// @param buyPrice Price the ARM paid for the base asset.
586-
/// @param crossPrice Price used to value the base asset in totalAssets().
586+
/// @dev Accrue fees on buy-side swaps using the gain realized from the settled input and output amounts.
587+
/// @param config Base asset configuration used to value the input received by the ARM.
588+
/// @param amountIn Base asset amount received by the ARM.
587589
/// @param amountOut Liquidity asset amount paid out by the ARM.
588-
function _accrueSwapFee(uint256 buyPrice, uint256 crossPrice, uint256 amountOut) internal {
589-
uint256 feeMultiplier = (crossPrice - buyPrice) * uint256(fee) * PRICE_SCALE / (buyPrice * FEE_SCALE);
590-
feesAccrued = SafeCast.toUint128(feesAccrued + amountOut * feeMultiplier / PRICE_SCALE);
590+
function _accrueSwapFee(BaseAssetConfig storage config, uint256 amountIn, uint256 amountOut) internal {
591+
uint256 realizedAssets = _convertToAssets(config, amountIn) * config.crossPrice / PRICE_SCALE;
592+
uint256 gain = realizedAssets > amountOut ? realizedAssets - amountOut : 0;
593+
feesAccrued = SafeCast.toUint128(feesAccrued + gain * uint256(fee) / FEE_SCALE);
591594
}
592595

593596
////////////////////////////////////////////////////

test/fork/EthenaARM/SwapExactTokensForTokens.t.sol

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,7 @@ contract Fork_Concrete_EthenaARM_swapExactTokensForTokens_Test_ is Fork_Shared_T
9090
// Precompute expected amount out
9191
uint256 traderate = _buyPrice();
9292
uint256 expectedAmountOut = (susde.convertToAssets(AMOUNT_IN) * traderate) / 1e36;
93-
uint256 expectedFee =
94-
expectedAmountOut * _swapFeeMultiplier(_buyPrice(), _crossPrice(), ethenaARM.fee()) / PRICE_SCALE;
93+
uint256 expectedFee = _expectedBuySideFee(AMOUNT_IN, expectedAmountOut);
9594

9695
// Expected events
9796
vm.expectEmit({emitter: address(susde)});
@@ -112,9 +111,7 @@ contract Fork_Concrete_EthenaARM_swapExactTokensForTokens_Test_ is Fork_Shared_T
112111
assertEq(obtained[1], expectedAmountOut, "Obtained USDe amount should match expected output");
113112
assertEq(usdeBalanceAfter, usdeBalanceBefore + expectedAmountOut, "USDe balance should have increased");
114113
assertEq(susdeBalanceBefore, susdeBalanceAfter + AMOUNT_IN, "SUSDe balance should have decreased");
115-
assertEq(
116-
ethenaARM.feesAccrued() - feesAccruedBefore, expectedFee, "Fees accrued should match output multiplier"
117-
);
114+
assertEq(ethenaARM.feesAccrued() - feesAccruedBefore, expectedFee, "Fees accrued should match realized gain");
118115
}
119116

120117
function test_swapExactTokensForTokens_SUSDE_To_USDE_WithOutstandingWithdrawals_Sig1() public {

test/fork/EthenaARM/shared/Shared.sol

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,10 @@ abstract contract Fork_Shared_Test is Base_Test_ {
120120
crossPrice = crossPriceMem;
121121
}
122122

123-
function _swapFeeMultiplier(uint256 buyPrice, uint256 crossPrice, uint256 fee) internal view returns (uint256) {
124-
uint256 priceScale = PRICE_SCALE;
125-
if (buyPrice == 0 || fee == 0) return 0;
126-
return (crossPrice - buyPrice) * fee * priceScale / (buyPrice * FEE_SCALE);
123+
function _expectedBuySideFee(uint256 amountIn, uint256 amountOut) internal view returns (uint256) {
124+
uint256 realizedAssets = susde.convertToAssets(amountIn) * _crossPrice() / PRICE_SCALE;
125+
uint256 gain = realizedAssets > amountOut ? realizedAssets - amountOut : 0;
126+
return gain * ethenaARM.fee() / FEE_SCALE;
127127
}
128128

129129
function _ignite() internal virtual {

test/fork/MultiAssetARM/SwapExactTokensForTokens.t.sol

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,7 @@ contract Fork_Concrete_MultiAssetARM_swapExactTokensForTokens_Test_ is Fork_Shar
162162
function _swapBuy(IERC20 token) internal {
163163
uint256 buyPrice = _buyPrice(token);
164164
uint256 expectedAmountOut = _convertToAssets(token, AMOUNT_IN) * buyPrice / PRICE_SCALE;
165-
uint256 expectedFee =
166-
expectedAmountOut * _swapFeeMultiplier(buyPrice, _crossPrice(token), arm.fee()) / PRICE_SCALE;
165+
uint256 expectedFee = _expectedBuySideFee(token, AMOUNT_IN, expectedAmountOut);
167166

168167
uint256 wethBefore = weth.balanceOf(address(this));
169168
uint256 baseBefore = token.balanceOf(address(this));

test/fork/MultiAssetARM/SwapTokensForExactTokens.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ contract Fork_Concrete_MultiAssetARM_swapTokensForExactTokens_Test_ is Fork_Shar
153153
function _swapBuyExact(IERC20 token) internal {
154154
uint256 buyPrice = _buyPrice(token);
155155
uint256 expectedAmountIn = _convertToShares(token, AMOUNT_OUT) * PRICE_SCALE / buyPrice + 3;
156-
uint256 expectedFee = AMOUNT_OUT * _swapFeeMultiplier(buyPrice, _crossPrice(token), arm.fee()) / PRICE_SCALE;
156+
uint256 expectedFee = _expectedBuySideFee(token, expectedAmountIn, AMOUNT_OUT);
157157

158158
uint256 wethBefore = weth.balanceOf(address(this));
159159
uint256 baseBefore = token.balanceOf(address(this));

test/fork/MultiAssetARM/shared/Shared.sol

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -313,9 +313,10 @@ abstract contract Fork_Shared_Test is Base_Test_ {
313313
return _adapter(token).convertToShares(assets);
314314
}
315315

316-
function _swapFeeMultiplier(uint256 buyPrice, uint256 crossPrice, uint256 fee) internal pure returns (uint256) {
317-
if (buyPrice == 0 || fee == 0) return 0;
318-
return (crossPrice - buyPrice) * fee * PRICE_SCALE / (buyPrice * FEE_SCALE);
316+
function _expectedBuySideFee(IERC20 token, uint256 amountIn, uint256 amountOut) internal view returns (uint256) {
317+
uint256 realizedAssets = _convertToAssets(token, amountIn) * _crossPrice(token) / PRICE_SCALE;
318+
uint256 gain = realizedAssets > amountOut ? realizedAssets - amountOut : 0;
319+
return gain * arm.fee() / FEE_SCALE;
319320
}
320321

321322
//////////////////////////////////////////////////////

test/fork/PaxosARM/SwapExactTokensForTokens.t.sol

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ contract Fork_Concrete_PaxosARM_swapExactTokensForTokens_Test_ is Fork_Shared_Te
6969
function _swapBuy(IERC20 token) internal {
7070
uint256 buyPrice = _buyPrice(token);
7171
uint256 expectedAmountOut = AMOUNT_IN * buyPrice / PRICE_SCALE;
72-
uint256 expectedFee =
73-
expectedAmountOut * _swapFeeMultiplier(buyPrice, _crossPrice(token), arm.fee()) / PRICE_SCALE;
72+
uint256 expectedFee = _expectedBuySideFee(token, AMOUNT_IN, expectedAmountOut);
7473

7574
uint256 usdcBefore = usdc.balanceOf(address(this));
7675
uint256 baseBefore = token.balanceOf(address(this));

test/fork/PaxosARM/SwapTokensForExactTokens.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ contract Fork_Concrete_PaxosARM_swapTokensForExactTokens_Test_ is Fork_Shared_Te
2222
function test_swapTokensForExactTokens_Pyusd_To_Usdc() public {
2323
uint256 buyPrice = _buyPrice(pyusd);
2424
uint256 expectedAmountIn = AMOUNT_OUT * PRICE_SCALE / buyPrice + 3;
25-
uint256 expectedFee = AMOUNT_OUT * _swapFeeMultiplier(buyPrice, _crossPrice(pyusd), arm.fee()) / PRICE_SCALE;
25+
uint256 expectedFee = _expectedBuySideFee(pyusd, expectedAmountIn, AMOUNT_OUT);
2626

2727
uint256 usdcBefore = usdc.balanceOf(address(this));
2828
uint256 baseBefore = pyusd.balanceOf(address(this));

test/fork/PaxosARM/shared/Shared.sol

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,9 @@ abstract contract Fork_Shared_Test is Base_Test_ {
223223
return PaxosAssetAdapter(adapter);
224224
}
225225

226-
function _swapFeeMultiplier(uint256 buyPrice, uint256 crossPrice, uint256 fee) internal pure returns (uint256) {
227-
if (buyPrice == 0 || fee == 0) return 0;
228-
return (crossPrice - buyPrice) * fee * PRICE_SCALE / (buyPrice * FEE_SCALE);
226+
function _expectedBuySideFee(IERC20 token, uint256 amountIn, uint256 amountOut) internal view returns (uint256) {
227+
uint256 realizedAssets = amountIn * _crossPrice(token) / PRICE_SCALE;
228+
uint256 gain = realizedAssets > amountOut ? realizedAssets - amountOut : 0;
229+
return gain * arm.fee() / FEE_SCALE;
229230
}
230231
}

0 commit comments

Comments
 (0)