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
88 changes: 52 additions & 36 deletions spot-contracts/contracts/RolloverVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { IVault } from "./_interfaces/IVault.sol";
import { IRolloverVault } from "./_interfaces/IRolloverVault.sol";
import { IERC20Burnable } from "./_interfaces/IERC20Burnable.sol";
import { TokenAmount, RolloverData, SubscriptionParams } from "./_interfaces/CommonTypes.sol";
import { UnauthorizedCall, UnauthorizedTransferOut, UnexpectedDecimals, UnexpectedAsset, OutOfBounds, UnacceptableSwap, InsufficientDeployment, DeployedCountOverLimit, InvalidPerc, InsufficientLiquidity } from "./_interfaces/ProtocolErrors.sol";
import { UnauthorizedCall, UnauthorizedTransferOut, UnexpectedDecimals, UnexpectedAsset, OutOfBounds, UnacceptableSwap, InsufficientDeployment, DeployedCountOverLimit, InsufficientLiquidity } from "./_interfaces/ProtocolErrors.sol";

import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol";
Expand Down Expand Up @@ -84,6 +84,10 @@ contract RolloverVault is
/// @dev The maximum number of deployed assets that can be held in this vault at any given time.
uint8 public constant MAX_DEPLOYED_COUNT = 47;

// Replicating value used here:
// https://github.com/buttonwood-protocol/tranche/blob/main/contracts/BondController.sol
uint256 private constant TRANCHE_RATIO_GRANULARITY = 1000;

/// @dev Immature redemption may result in some dust tranches when balances are not perfectly divisible by the tranche ratio.
/// Based on current the implementation of `computeRedeemableTrancheAmounts`,
/// the dust balances which remain after immature redemption will be *at most* {TRANCHE_RATIO_GRANULARITY} or 1000.
Expand Down Expand Up @@ -128,16 +132,24 @@ contract RolloverVault is
/// @return The address of the keeper.
address public keeper;

/// @notice The enforced minimum absolute balance of underlying tokens to be held by the vault.
/// @dev On deployment only the delta greater than this balance is deployed.
/// `minUnderlyingBal` is enforced on deployment and swapping operations which reduce the underlying balance.
/// This parameter ensures that the vault's tvl is never too low, which guards against the "share" manipulation attack.
uint256 public minUnderlyingBal;

/// @notice The enforced minimum percentage of the vault's value to be held as underlying tokens.
/// @dev The percentage minimum is enforced after swaps which reduce the vault's underlying token liquidity.
/// This ensures that the vault has sufficient liquid underlying tokens for upcoming rollovers.
uint256 public minUnderlyingPerc;
//--------------------------------------------------------------------------
// The reserved liquidity is the subset of the vault's underlying tokens that it
// does not deploy for rolling over (or used for swaps) and simply holds.
// The existence of sufficient reserved liquidity ensures that
// a) The vault's TVL never goes too low and guards against the "share" manipulation attack.
// b) Not all of the vault's liquidity is locked up in tranches.

/// @notice The absolute amount of underlying tokens, reserved.
/// @custom:oz-upgrades-renamed-from minUnderlyingBal
uint256 public reservedUnderlyingBal;

/// @notice The percentage of the vault's "neutrally" subscribed TVL, reserved.
/// @dev A neutral subscription state implies the vault's TVL is exactly enough to
/// rollover over the entire supply of perp tokens.
/// NOTE: A neutral subscription ratio of 1.0 is distinct from a deviation ratio (dr) of 1.0.
/// For more details, refer to the fee policy documentation.
/// @custom:oz-upgrades-renamed-from minUnderlyingPerc
uint256 public reservedSubscriptionPerc;

//--------------------------------------------------------------------------
// Modifiers
Expand Down Expand Up @@ -190,8 +202,8 @@ contract RolloverVault is

// setting initial parameter values
minDeploymentAmt = 0;
minUnderlyingBal = 0;
minUnderlyingPerc = ONE / 3; // 33%
reservedUnderlyingBal = 0;
reservedSubscriptionPerc = 0;

// sync underlying
_syncAsset(underlying);
Expand Down Expand Up @@ -247,19 +259,16 @@ contract RolloverVault is
minDeploymentAmt = minDeploymentAmt_;
}

/// @notice Updates the minimum underlying balance requirement (Absolute number of underlying tokens).
/// @param minUnderlyingBal_ The new minimum underlying balance.
function updateMinUnderlyingBal(uint256 minUnderlyingBal_) external onlyKeeper {
minUnderlyingBal = minUnderlyingBal_;
/// @notice Updates absolute reserved underlying balance.
/// @param reservedUnderlyingBal_ The new reserved underlying balance.
function updateReservedUnderlyingBal(uint256 reservedUnderlyingBal_) external onlyKeeper {
reservedUnderlyingBal = reservedUnderlyingBal_;
}

/// @notice Updates the minimum underlying percentage requirement (Expressed as a percentage).
/// @param minUnderlyingPerc_ The new minimum underlying percentage.
function updateMinUnderlyingPerc(uint256 minUnderlyingPerc_) external onlyKeeper {
if (minUnderlyingPerc_ > ONE) {
revert InvalidPerc();
}
minUnderlyingPerc = minUnderlyingPerc_;
/// @notice Updates the reserved subscription percentage.
/// @param reservedSubscriptionPerc_ The new reserved subscription percentage.
function updateReservedSubscriptionPerc(uint256 reservedSubscriptionPerc_) external onlyKeeper {
reservedSubscriptionPerc = reservedSubscriptionPerc_;
}

//--------------------------------------------------------------------------
Expand All @@ -274,20 +283,19 @@ contract RolloverVault is

/// @inheritdoc IVault
/// @dev Its safer to call `recover` before `deploy` so the full available balance can be deployed.
/// The vault holds `minUnderlyingBal` as underlying tokens and deploys the rest.
/// The vault holds the reserved balance of underlying tokens and deploys the rest.
/// Reverts if no funds are rolled over or enforced deployment threshold is not reached.
function deploy() public override nonReentrant whenNotPaused {
IERC20Upgradeable underlying_ = underlying;
IPerpetualTranche perp_ = perp;

// `minUnderlyingBal` worth of underlying liquidity is excluded from the usable balance
uint256 usableBal = underlying_.balanceOf(address(this));
if (usableBal <= minUnderlyingBal) {
revert InsufficientLiquidity();
}
usableBal -= minUnderlyingBal;
// We calculate the usable underlying balance.
uint256 underlyingBal = underlying_.balanceOf(address(this));
uint256 reservedBal = _totalReservedBalance(perp_.getTVL(), perp_.getDepositTrancheRatio());
uint256 usableBal = (underlyingBal > reservedBal) ? underlyingBal - reservedBal : 0;

// We ensure that at-least `minDeploymentAmt` amount of underlying tokens are deployed
// (i.e used productively for rollovers).
if (usableBal <= minDeploymentAmt) {
revert InsufficientDeployment();
}
Expand Down Expand Up @@ -469,13 +477,12 @@ contract RolloverVault is
// NOTE: In case this operation mints slightly more perps than that are required for the swap,
// The vault continues to hold the perp dust until the subsequent `swapPerpsForUnderlying` or manual `recover(perp)`.

// If vault liquidity has reduced, revert if it reduced too much.
// - Absolute balance is strictly greater than `minUnderlyingBal`.
// - Ratio of the balance to the vault's TVL is strictly greater than `minUnderlyingPerc`.
// We ensure that the vault's underlying token liquidity
// remains above the reserved level after swap.
uint256 underlyingBalPost = underlying_.balanceOf(address(this));
if (
underlyingBalPost < underlyingBalPre &&
(underlyingBalPost <= minUnderlyingBal || underlyingBalPost.mulDiv(ONE, s.vaultTVL) <= minUnderlyingPerc)
(underlyingBalPost < underlyingBalPre) &&
(underlyingBalPost <= _totalReservedBalance((s.perpTVL + underlyingAmtIn), s.seniorTR))
) {
revert InsufficientLiquidity();
}
Expand Down Expand Up @@ -973,4 +980,13 @@ contract RolloverVault is
(uint256 trancheClaim, uint256 trancheSupply) = tranche.getTrancheCollateralization(collateralToken);
return trancheClaim.mulDiv(trancheAmt, trancheSupply, MathUpgradeable.Rounding.Up);
}

/// @dev Computes the balance of underlying tokens to NOT be used for any operation.
function _totalReservedBalance(uint256 perpTVL, uint256 seniorTR) private view returns (uint256) {
return
MathUpgradeable.max(
reservedUnderlyingBal,
perpTVL.mulDiv(TRANCHE_RATIO_GRANULARITY - seniorTR, seniorTR).mulDiv(reservedSubscriptionPerc, ONE)
);
}
}
28 changes: 14 additions & 14 deletions spot-contracts/test/rollover-vault/RolloverVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ describe("RolloverVault", function () {
});

it("should set initial param values", async function () {
expect(await vault.minUnderlyingPerc()).to.eq(toPercFixedPtAmt("0.33333333"));
expect(await vault.minDeploymentAmt()).to.eq("0");
expect(await vault.minUnderlyingBal()).to.eq("0");
expect(await vault.reservedUnderlyingBal()).to.eq("0");
expect(await vault.reservedSubscriptionPerc()).to.eq("0");
});

it("should initialize lists", async function () {
Expand Down Expand Up @@ -291,15 +291,15 @@ describe("RolloverVault", function () {
});
});

describe("#updateMinDeploymentAmt", function () {
describe("#updateReservedUnderlyingBal", function () {
let tx: Transaction;
beforeEach(async function () {
await vault.connect(deployer).updateKeeper(await otherUser.getAddress());
});

describe("when triggered by non-keeper", function () {
it("should revert", async function () {
await expect(vault.connect(deployer).updateMinDeploymentAmt(0)).to.be.revertedWithCustomError(
await expect(vault.connect(deployer).updateReservedUnderlyingBal(0)).to.be.revertedWithCustomError(
vault,
"UnauthorizedCall",
);
Expand All @@ -308,24 +308,24 @@ describe("RolloverVault", function () {

describe("when triggered by keeper", function () {
beforeEach(async function () {
tx = await vault.connect(otherUser).updateMinDeploymentAmt(toFixedPtAmt("1000"));
tx = await vault.connect(otherUser).updateReservedUnderlyingBal(toFixedPtAmt("1000"));
await tx;
});
it("should update the min deployment amount", async function () {
expect(await vault.minDeploymentAmt()).to.eq(toFixedPtAmt("1000"));
expect(await vault.reservedUnderlyingBal()).to.eq(toFixedPtAmt("1000"));
});
});
});

describe("#updateMinUnderlyingBal", function () {
describe("#updateReservedUnderlyingBal", function () {
let tx: Transaction;
beforeEach(async function () {
await vault.connect(deployer).updateKeeper(await otherUser.getAddress());
});

describe("when triggered by non-keeper", function () {
it("should revert", async function () {
await expect(vault.connect(deployer).updateMinUnderlyingBal(0)).to.be.revertedWithCustomError(
await expect(vault.connect(deployer).updateReservedUnderlyingBal(0)).to.be.revertedWithCustomError(
vault,
"UnauthorizedCall",
);
Expand All @@ -334,24 +334,24 @@ describe("RolloverVault", function () {

describe("when triggered by keeper", function () {
beforeEach(async function () {
tx = await vault.connect(otherUser).updateMinUnderlyingBal(toFixedPtAmt("1000"));
tx = await vault.connect(otherUser).updateReservedUnderlyingBal(toFixedPtAmt("1000"));
await tx;
});
it("should update the min underlying balance", async function () {
expect(await vault.minUnderlyingBal()).to.eq(toFixedPtAmt("1000"));
expect(await vault.reservedUnderlyingBal()).to.eq(toFixedPtAmt("1000"));
});
});
});

describe("#updateMinUnderlyingPerc", function () {
describe("#updateReservedSubscriptionPerc", function () {
let tx: Transaction;
beforeEach(async function () {
await vault.connect(deployer).updateKeeper(await otherUser.getAddress());
});

describe("when triggered by non-keeper", function () {
it("should revert", async function () {
await expect(vault.connect(deployer).updateMinUnderlyingPerc(0)).to.be.revertedWithCustomError(
await expect(vault.connect(deployer).updateReservedSubscriptionPerc(0)).to.be.revertedWithCustomError(
vault,
"UnauthorizedCall",
);
Expand All @@ -360,11 +360,11 @@ describe("RolloverVault", function () {

describe("when triggered by keeper", function () {
beforeEach(async function () {
tx = await vault.connect(otherUser).updateMinUnderlyingPerc(toPercFixedPtAmt("0.1"));
tx = await vault.connect(otherUser).updateReservedSubscriptionPerc(toPercFixedPtAmt("0.1"));
await tx;
});
it("should update the min underlying balance", async function () {
expect(await vault.minUnderlyingPerc()).to.eq(toPercFixedPtAmt("0.1"));
expect(await vault.reservedSubscriptionPerc()).to.eq(toPercFixedPtAmt("0.1"));
});
});
});
Expand Down
14 changes: 7 additions & 7 deletions spot-contracts/test/rollover-vault/RolloverVault_deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,13 @@ describe("RolloverVault", function () {
describe("#deploy", function () {
describe("when usable balance is zero", function () {
it("should revert", async function () {
await expect(vault.deploy()).to.be.revertedWithCustomError(vault, "InsufficientLiquidity");
await expect(vault.deploy()).to.be.revertedWithCustomError(vault, "InsufficientDeployment");
});
});

describe("when minUnderlyingBal is not set", function () {
describe("when reservedUnderlyingBal is not set", function () {
beforeEach(async function () {
await vault.updateMinUnderlyingBal(toFixedPtAmt("0"));
await vault.updateReservedUnderlyingBal(toFixedPtAmt("0"));
});

describe("when usable balance is lower than the min deployment", function () {
Expand All @@ -140,18 +140,18 @@ describe("RolloverVault", function () {
});
});

describe("when minUnderlyingBal is set", function () {
describe("when reservedUnderlyingBal is set", function () {
beforeEach(async function () {
await vault.updateMinUnderlyingBal(toFixedPtAmt("25"));
await vault.updateReservedUnderlyingBal(toFixedPtAmt("25"));
});

describe("when usable balance is lower than the minUnderlyingBal", function () {
describe("when usable balance is lower than the reservedUnderlyingBal", function () {
beforeEach(async function () {
await collateralToken.transfer(vault.address, toFixedPtAmt("20"));
await vault.updateMinDeploymentAmt(toFixedPtAmt("1"));
});
it("should revert", async function () {
await expect(vault.deploy()).to.be.revertedWithCustomError(vault, "InsufficientLiquidity");
await expect(vault.deploy()).to.be.revertedWithCustomError(vault, "InsufficientDeployment");
});
});

Expand Down
10 changes: 5 additions & 5 deletions spot-contracts/test/rollover-vault/RolloverVault_swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,8 @@ describe("RolloverVault", function () {

describe("when absolute liquidity is too low", function () {
beforeEach(async function () {
await vault.updateMinUnderlyingBal(toFixedPtAmt("1000"));
await vault.updateMinUnderlyingPerc(0);
await vault.updateReservedUnderlyingBal(toFixedPtAmt("1000"));
await vault.updateReservedSubscriptionPerc(0);
});
it("should be reverted", async function () {
await expect(vault.swapUnderlyingForPerps(toFixedPtAmt("50"))).to.be.revertedWithCustomError(
Expand All @@ -317,15 +317,15 @@ describe("RolloverVault", function () {

describe("when percentage of liquidity is too low", function () {
beforeEach(async function () {
await vault.updateMinUnderlyingBal(0);
await vault.updateMinUnderlyingPerc(toPercFixedPtAmt("0.40"));
await vault.updateReservedUnderlyingBal(0);
await vault.updateReservedSubscriptionPerc(toPercFixedPtAmt("0.25"));
});
it("should be reverted", async function () {
await expect(vault.swapUnderlyingForPerps(toFixedPtAmt("100"))).to.be.revertedWithCustomError(
vault,
"InsufficientLiquidity",
);
await expect(vault.swapUnderlyingForPerps(toFixedPtAmt("99"))).not.to.be.reverted;
await expect(vault.swapUnderlyingForPerps(toFixedPtAmt("50"))).not.to.be.reverted;
});
});

Expand Down