diff --git a/contracts/Folio.sol b/contracts/Folio.sol index 166123b9..b8614fb2 100644 --- a/contracts/Folio.sol +++ b/contracts/Folio.sol @@ -14,7 +14,7 @@ import { ITrustedFillerRegistry, IBaseTrustedFiller } from "@reserve-protocol/tr import { RebalancingLib } from "@utils/RebalancingLib.sol"; import { FolioLib } from "@utils/FolioLib.sol"; -import { AUCTION_WARMUP, AUCTION_LAUNCHER, D18, ERC20_STORAGE_LOCATION, REBALANCE_MANAGER, MAX_MINT_FEE, MAX_FOLIO_FEE, MIN_AUCTION_LENGTH, MAX_AUCTION_LENGTH, RESTRICTED_AUCTION_BUFFER, ONE_DAY } from "@utils/Constants.sol"; +import { AUCTION_WARMUP, AUCTION_LAUNCHER, D18, D27, ERC20_STORAGE_LOCATION, REBALANCE_MANAGER, MAX_MINT_FEE, MAX_FOLIO_FEE, MIN_AUCTION_LENGTH, MAX_AUCTION_LENGTH, RESTRICTED_AUCTION_BUFFER, ONE_DAY } from "@utils/Constants.sol"; import { Versioned } from "@utils/Versioned.sol"; import { IFolioDAOFeeRegistry } from "@interfaces/IFolioDAOFeeRegistry.sol"; @@ -189,10 +189,13 @@ contract Folio is // === 6.0.0 === bool public tradeAllowlistEnabled; EnumerableSet.AddressSet private tradeTokenAllowlist; + uint256 public folioFeeForSelf; // D18{1} fraction of fee-recipient shares to burn FeeRecipient[] public immutableFeeRecipients; + ActiveTrustedFillInfo private activeTrustedFillInfo; + /// Any external call to the Folio that relies on accurate share accounting must pre-hook poke modifier sync() { _poke(); @@ -263,10 +266,10 @@ contract Folio is /// Check if the Folio state can be relied upon to be complete /// @dev Safety check for consuming protocols to check for synchronous and asynchronous state changes /// @dev Consuming protocols SHOULD call this function and ensure it returns (false, false) before - /// strongly relying on the Folio state. The asyncStateChangeActive check can be DoS'd for the current block. + /// strongly relying on the Folio state. The async flag remains set until the active trusted fill is closed. function stateChangeActive() external view returns (bool syncStateChangeActive, bool asyncStateChangeActive) { syncStateChangeActive = _reentrancyGuardEntered(); - asyncStateChangeActive = address(activeTrustedFill) != address(0) && activeTrustedFill.swapActive(); + asyncStateChangeActive = address(activeTrustedFill) != address(0); } // ==== Allowlist ==== @@ -855,6 +858,13 @@ contract Folio is SafeERC20.forceApprove(sellToken, address(filler), sellAmount); filler.initialize(address(this), sellToken, buyToken, sellAmount, buyAmount); + + activeTrustedFillInfo.sellToken = address(sellToken); + activeTrustedFillInfo.buyToken = address(buyToken); + activeTrustedFillInfo.sellAmount = sellAmount; + + // D27{buyTok/sellTok} = {buyTok} * D27 / {sellTok} + activeTrustedFillInfo.floorPrice = Math.max(1, Math.mulDiv(buyAmount, D27, sellAmount, Math.Rounding.Floor)); activeTrustedFill = filler; emit AuctionTrustedFillCreated(auctionId, address(filler)); @@ -942,7 +952,7 @@ contract Folio is if ( address(activeTrustedFill) != address(0) && - (activeTrustedFill.sellToken() == token || activeTrustedFill.buyToken() == token) + (activeTrustedFillInfo.sellToken == address(token) || activeTrustedFillInfo.buyToken == address(token)) ) { amount += token.balanceOf(address(activeTrustedFill)); } @@ -1169,15 +1179,24 @@ contract Folio is function _closeTrustedFill(bool _emergency) internal { if (address(activeTrustedFill) != address(0)) { if (!_emergency) { - address sellToken = address(activeTrustedFill.sellToken()); - if (RebalancingLib.closeTrustedFill(auctions[nextAuctionId - 1], activeTrustedFill)) { - _removeFromBasket(sellToken); + (bool shouldRemoveFromBasket, bool shouldDisableTrustedFillerRegistry) = RebalancingLib + .closeTrustedFill(auctions[nextAuctionId - 1], activeTrustedFill, activeTrustedFillInfo); + + if (trustedFillerEnabled && shouldDisableTrustedFillerRegistry) { + _setTrustedFillerRegistry(address(trustedFillerRegistry), false); + } + + if (shouldRemoveFromBasket) { + _removeFromBasket(activeTrustedFillInfo.sellToken); } } else { - activeTrustedFill.emergencyCloseFiller(); + try activeTrustedFill.emergencyCloseFiller() {} catch { + // do not risk bricking Folio + } } delete activeTrustedFill; + delete activeTrustedFillInfo; } } diff --git a/contracts/interfaces/IFolio.sol b/contracts/interfaces/IFolio.sol index 28219bc5..82ad581d 100644 --- a/contracts/interfaces/IFolio.sol +++ b/contracts/interfaces/IFolio.sol @@ -210,6 +210,14 @@ interface IFolio { mapping(address token => uint256) traded; // {tok} } + /// Active trusted fill details cached by the Folio (storage) + struct ActiveTrustedFillInfo { + address sellToken; + address buyToken; + uint256 sellAmount; // {sellTok} + uint256 floorPrice; // D27{buyTok/sellTok} + } + /// Used to mark old storage slots now deprecated struct DeprecatedStruct { bytes32 EMPTY; diff --git a/contracts/utils/RebalancingLib.sol b/contracts/utils/RebalancingLib.sol index 27bed1c7..b44acc8d 100644 --- a/contracts/utils/RebalancingLib.sol +++ b/contracts/utils/RebalancingLib.sol @@ -392,13 +392,16 @@ library RebalancingLib { /// Close a trusted fill /// @param auction The current ongoing auction /// @param activeTrustedFill The active trusted fill to close + /// @param activeTrustedFillInfo The trusted fill metadata recorded by Folio when the fill was created /// @return shouldRemoveFromBasket If true, the auction's sell token should be removed from the basket after close + /// @return shouldDisableTrustedFillerRegistry If true, the trusted filler registry should be disabled function closeTrustedFill( IFolio.Auction storage auction, - IBaseTrustedFiller activeTrustedFill - ) external returns (bool shouldRemoveFromBasket) { - IERC20 sellToken = activeTrustedFill.sellToken(); - IERC20 buyToken = activeTrustedFill.buyToken(); + IBaseTrustedFiller activeTrustedFill, + IFolio.ActiveTrustedFillInfo storage activeTrustedFillInfo + ) external returns (bool shouldRemoveFromBasket, bool shouldDisableTrustedFillerRegistry) { + IERC20 sellToken = IERC20(activeTrustedFillInfo.sellToken); + IERC20 buyToken = IERC20(activeTrustedFillInfo.buyToken); uint256 sellBalBefore = sellToken.balanceOf(address(this)); // {sellTok} uint256 buyBalBefore = buyToken.balanceOf(address(this)); // {buyTok} @@ -410,8 +413,9 @@ library RebalancingLib { uint256 sellReturned = sellBalAfter > sellBalBefore ? sellBalAfter - sellBalBefore : 0; // {sellTok} - uint256 sellAmount = activeTrustedFill.sellAmount(); - uint256 sold = sellAmount > sellReturned ? sellAmount - sellReturned : 0; + uint256 sold = activeTrustedFillInfo.sellAmount > sellReturned + ? activeTrustedFillInfo.sellAmount - sellReturned + : 0; // {buyTok} uint256 buyBalAfter = buyToken.balanceOf(address(this)); @@ -421,9 +425,14 @@ library RebalancingLib { auction.traded[address(sellToken)] += sold; auction.traded[address(buyToken)] += bought; - // no event, cannot rely on executing in same block as fill occurred + // no AuctionBid event, cannot rely on executing in same block as fill occurred - return sellBalAfter == 0; + // Round in favor of no false positives. Known this permits tiny price violations. + shouldDisableTrustedFillerRegistry = + sold != 0 && + Math.mulDiv(bought, D27, sold, Math.Rounding.Ceil) < activeTrustedFillInfo.floorPrice; + + return (sellBalAfter == 0, shouldDisableTrustedFillerRegistry); } // ==== Internal ==== diff --git a/test/Folio.t.sol b/test/Folio.t.sol index a0336159..95a7c582 100644 --- a/test/Folio.t.sol +++ b/test/Folio.t.sol @@ -14,6 +14,7 @@ import { ITransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/tran import { FolioDeployerV2 } from "test/utils/upgrades/FolioDeployerV2.sol"; import { MockEIP712 } from "test/utils/MockEIP712.sol"; import { MockDonatingBidder } from "test/utils/MockDonatingBidder.sol"; +import { MockDishonestTrustedFiller } from "utils/MockDishonestTrustedFiller.sol"; import { MockBidder } from "utils/MockBidder.sol"; import "./base/BaseTest.sol"; @@ -2170,6 +2171,174 @@ contract FolioTest is BaseTest { assertEq(USDT.balanceOf(address(folio)), amt * 100, "wrong folio usdt balance after close"); } + function test_trustedFillCircuitBreakerUsesCachedMetadata() public { + _openTrustedFillAuction(); + + MockDishonestTrustedFiller dishonestFiller = new MockDishonestTrustedFiller(); + trustedFillerRegistry.addTrustedFiller(dishonestFiller); + + (uint256 fillSellAmount, uint256 fillBuyAmount, ) = folio.getBid(0, USDC, IERC20(address(USDT)), D6_TOKEN_10K); + IBaseTrustedFiller fill = folio.createTrustedFill( + 0, + USDC, + IERC20(address(USDT)), + address(dishonestFiller), + bytes32(block.timestamp) + ); + + (, bool asyncStateChangeActive) = folio.stateChangeActive(); + assertTrue(asyncStateChangeActive, "trusted fill should be active"); + + // Cached token addresses must be used while the filler view functions revert. + folio.totalAssets(); + + uint256 sold = fillSellAmount / 2; + uint256 bought = Math.mulDiv(sold, fillBuyAmount, fillSellAmount, Math.Rounding.Ceil) - 1; + MockERC20(address(USDC)).burn(address(fill), sold); + MockERC20(address(USDT)).mint(address(fill), bought); + + vm.expectEmit(false, false, false, true, address(folio)); + emit IFolio.TrustedFillerRegistrySet(address(trustedFillerRegistry), false); + folio.poke(); + + assertFalse(folio.trustedFillerEnabled(), "trusted fills should be disabled"); + (, asyncStateChangeActive) = folio.stateChangeActive(); + assertFalse(asyncStateChangeActive, "trusted fill should be cleared"); + } + + function test_trustedFillAtFloorDoesNotTriggerCircuitBreaker() public { + _openTrustedFillAuction(); + + (uint256 fillSellAmount, uint256 fillBuyAmount, ) = folio.getBid(0, USDC, IERC20(address(USDT)), D6_TOKEN_10K); + IBaseTrustedFiller fill = folio.createTrustedFill( + 0, + USDC, + IERC20(address(USDT)), + cowswapFiller, + bytes32(block.timestamp) + ); + + uint256 sold = fillSellAmount / 2; + uint256 bought = Math.mulDiv(sold, fillBuyAmount, fillSellAmount, Math.Rounding.Ceil); + MockERC20(address(USDC)).burn(address(fill), sold); + MockERC20(address(USDT)).mint(address(fill), bought); + + folio.poke(); + + assertTrue(folio.trustedFillerEnabled(), "trusted fills should remain enabled"); + } + + function test_trustedFillAboveFloorDoesNotTriggerCircuitBreaker() public { + _openTrustedFillAuction(); + + (uint256 fillSellAmount, uint256 fillBuyAmount, ) = folio.getBid(0, USDC, IERC20(address(USDT)), D6_TOKEN_10K); + IBaseTrustedFiller fill = folio.createTrustedFill( + 0, + USDC, + IERC20(address(USDT)), + cowswapFiller, + bytes32(block.timestamp) + ); + + uint256 sold = fillSellAmount / 2; + uint256 bought = Math.mulDiv(sold, fillBuyAmount, fillSellAmount, Math.Rounding.Ceil) + 1; + MockERC20(address(USDC)).burn(address(fill), sold); + MockERC20(address(USDT)).mint(address(fill), bought); + + folio.poke(); + + assertTrue(folio.trustedFillerEnabled(), "trusted fills should remain enabled"); + } + + function test_trustedFillWithZeroSoldDoesNotTriggerCircuitBreaker() public { + _openTrustedFillAuction(); + + folio.createTrustedFill(0, USDC, IERC20(address(USDT)), cowswapFiller, bytes32(block.timestamp)); + folio.poke(); + + assertTrue(folio.trustedFillerEnabled(), "trusted fills should remain enabled"); + } + + function test_stateChangeActiveUntilTrustedFillIsClosed() public { + _openTrustedFillAuction(); + + folio.createTrustedFill(0, USDC, IERC20(address(USDT)), cowswapFiller, bytes32(block.timestamp)); + + vm.roll(block.number + 1); + (, bool asyncStateChangeActive) = folio.stateChangeActive(); + assertTrue(asyncStateChangeActive, "trusted fill should remain active across blocks"); + + folio.poke(); + (, asyncStateChangeActive) = folio.stateChangeActive(); + assertFalse(asyncStateChangeActive, "trusted fill should be cleared"); + } + + function test_emergencyCloseTrustedFillCatchesRevert() public { + _openTrustedFillAuction(); + + MockDishonestTrustedFiller dishonestFiller = new MockDishonestTrustedFiller(); + trustedFillerRegistry.addTrustedFiller(dishonestFiller); + + IBaseTrustedFiller fill = folio.createTrustedFill( + 0, + USDC, + IERC20(address(USDT)), + address(dishonestFiller), + bytes32(block.timestamp) + ); + MockDishonestTrustedFiller(address(fill)).setEmergencyCloseShouldRevert(true); + + vm.prank(owner); + folio.emergencyCloseTrustedFill(); + + (, bool asyncStateChangeActive) = folio.stateChangeActive(); + assertFalse(asyncStateChangeActive, "trusted fill should be cleared"); + assertTrue(folio.trustedFillerEnabled(), "trusted fills should remain enabled"); + assertGt(USDC.balanceOf(address(fill)), 0, "failed emergency close should leave filler funds in place"); + } + + function test_emergencyCloseTrustedFillSuccessLeavesRegistryEnabled() public { + _openTrustedFillAuction(); + + IBaseTrustedFiller fill = folio.createTrustedFill( + 0, + USDC, + IERC20(address(USDT)), + cowswapFiller, + bytes32(block.timestamp) + ); + + vm.roll(block.number + 1); + vm.prank(owner); + folio.emergencyCloseTrustedFill(); + + (, bool asyncStateChangeActive) = folio.stateChangeActive(); + assertFalse(asyncStateChangeActive, "trusted fill should be cleared"); + assertTrue(folio.trustedFillerEnabled(), "trusted fills should remain enabled"); + assertEq(USDC.balanceOf(address(fill)), 0, "sell tokens should return to the Folio"); + } + + function _openTrustedFillAuction() internal { + weights[0] = SELL; + + assets.push(address(USDT)); + weights.push(BUY); + prices.push(FULL_PRICE_RANGE_6); + + uint256 len = assets.length; + IFolio.TokenRebalanceParams[] memory tokens = new IFolio.TokenRebalanceParams[](len); + for (uint256 i; i < len; i++) { + tokens[i] = IFolio.TokenRebalanceParams(assets[i], weights[i], prices[i], type(uint256).max, true); + } + + vm.prank(dao); + startRebalance(folio, tokens, limits, AUCTION_LAUNCHER_WINDOW, MAX_TTL); + + vm.prank(auctionLauncher); + folio.openAuction(1, assets, weights, prices, NATIVE_LIMITS, AUCTION_LENGTH); + vm.warp(block.timestamp + AUCTION_WARMUP); + } + function test_auctionIsValidSignature() public { // Sell USDC weights[0] = SELL; diff --git a/test/utils/MockDishonestTrustedFiller.sol b/test/utils/MockDishonestTrustedFiller.sol new file mode 100644 index 00000000..f85b95bc --- /dev/null +++ b/test/utils/MockDishonestTrustedFiller.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import { IBaseTrustedFiller } from "@reserve-protocol/trusted-fillers/contracts/interfaces/IBaseTrustedFiller.sol"; +import { IERC20, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract MockDishonestTrustedFiller is IBaseTrustedFiller { + using SafeERC20 for IERC20; + + address private fillCreator; + IERC20 private fillSellToken; + IERC20 private fillBuyToken; + + bool public emergencyCloseShouldRevert; + bool public isClosed; + + modifier onlyFillCreator() { + require(msg.sender == fillCreator, "unauthorized"); + _; + } + + function initialize(address _creator, IERC20 _sellToken, IERC20 _buyToken, uint256 _sellAmount, uint256) external { + require(fillCreator == address(0), "already initialized"); + + fillCreator = _creator; + fillSellToken = _sellToken; + fillBuyToken = _buyToken; + + _sellToken.safeTransferFrom(_creator, address(this), _sellAmount); + } + + function version() external pure returns (uint256) { + return 2; + } + + function buyToken() external pure returns (IERC20) { + revert("untrusted view"); + } + + function sellToken() external pure returns (IERC20) { + revert("untrusted view"); + } + + function sellAmount() external pure returns (uint256) { + revert("untrusted view"); + } + + function swapActive() external pure returns (bool) { + revert("untrusted view"); + } + + function setEmergencyCloseShouldRevert(bool shouldRevert) external { + emergencyCloseShouldRevert = shouldRevert; + } + + function closeFiller() external onlyFillCreator { + _closeFiller(); + } + + function emergencyCloseFiller() external onlyFillCreator { + require(!emergencyCloseShouldRevert, "emergency close failed"); + _closeFiller(); + } + + function rescueToken(IERC20 token) external { + require(isClosed, "not closed"); + _rescueToken(token); + } + + function setPartiallyFillable(bool) external pure {} + + function isValidSignature(bytes32, bytes calldata) external pure returns (bytes4) { + return this.isValidSignature.selector; + } + + function _closeFiller() internal { + isClosed = true; + _rescueToken(fillSellToken); + _rescueToken(fillBuyToken); + } + + function _rescueToken(IERC20 token) internal { + uint256 tokenBalance = token.balanceOf(address(this)); + if (tokenBalance != 0) { + token.safeTransfer(fillCreator, tokenBalance); + } + } +}