Skip to content
Open
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
35 changes: 27 additions & 8 deletions contracts/Folio.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 ====
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down
8 changes: 8 additions & 0 deletions contracts/interfaces/IFolio.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 17 additions & 8 deletions contracts/utils/RebalancingLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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));
Expand All @@ -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 ====
Expand Down
169 changes: 169 additions & 0 deletions test/Folio.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading