Skip to content

Commit

Permalink
feat: claimInsurance, pro-rata withdraws
Browse files Browse the repository at this point in the history
  • Loading branch information
sandybradley committed Jul 17, 2024
1 parent 9818228 commit 817d829
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 106 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: test

on: workflow_dispatch
on: push

env:
FOUNDRY_PROFILE: ci
Expand Down
35 changes: 15 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,46 @@
# Fold Captive Staking ![Foundry](https://github.com/manifoldfinance/fold-staking/actions/workflows/test.yml/badge.svg?branch=master)

## Introduction
## Introduction

All the validators that are connected to the Manifold relay can ONLY connect
to the Manifold relay (for mevAuction). If there's a service outage (of the relay) Manifold needs to be able to cover the cost (of lost opportunity) for validators.
All the validators that are connected to the Manifold relay can ONLY connect to the Manifold relay (for mevAuction). If there's a service outage (of the relay), Manifold needs to be able to cover the cost (of lost opportunity) for validators.

Stakers into the `FOLDstaking.sol` contract are underwriting this risk ([captive insurance](https://forums.manifoldfinance.com/t/captive-insurance-and-fold-staking/562)) of missing out on blocks. The contract keeps track of the durations of each deposit. Rewards are paid individually to each depositor.

Staking FOLD tokens transfers LP deposit ownership to the `FOLDstaking` contract. The contract's owners (msig) require the ability to permanently claim FOLD balances in the interest of captive insurance claims.
Staking FOLD tokens transfers LP deposit ownership to the `FOLDstaking` contract. The contract's owners (msig) require the ability to permanently claim FOLD balances in the interest of captive insurance claims through the `claimInsurance` function.

In exchange, LPs are rewarder for staking (in addition to swap fees), and the compounding of their deposits' accrued fees is automated. This serves to incentivize a maximum number of compounds at optimal times with regards to gas costs.
In exchange, LPs are rewarded for staking (in addition to swap fees), and the compounding of their deposits' accrued fees is automated. This serves to incentivize a maximum number of compounds at optimal times with regards to gas costs.

Multiple deposits may be made of several V3 positions. The duration of each deposit as well as its share of the total liquidity deposited in the vault (for that pair) determines how much the reward will be (it's paid from the WETH balance of the contract).

There is no necessity for a Keeper to continuously compound rewards; however, withdrawals, after initiation, are pro-rated over 14 days if they are above a certain % of the total liquidity in the pool (borrowing from the queue design of mevETH with some small adjustment to fit).
There is no necessity for a Keeper to continuously compound rewards; however, withdrawals, after initiation, are pro-rated over 14 days if they are above a certain percentage of the total liquidity in the pool (borrowing from the queue design of mevETH with some small adjustment to fit).

## Materials and methods
## Materials and Methods

To become accustomed with the relevant contextual terrain for an undertaking of our scope, we've surveyed some existing work on the subject of "address[ing] the issue of attracting stable liquidity"
To become accustomed with the relevant contextual terrain for an undertaking of our scope, we've surveyed some existing work on the subject of "address[ing] the issue of attracting stable liquidity".

Case in point: https://docs.pangolin.exchange/faqs/understanding-sar

The formula there-in for calculating rewards has a useful property:
The formula therein for calculating rewards has a useful property:
(position stake / total staked) x (stake duration / average stake duration)

The useful property is in the second half of the expression. Division prevents an overflow from occuring (in the worst-case scenario) because, otherwise, duration would keep increasing (potentially indefinitely), and eventually cause an overflow in the result of the expression.
The useful property is in the second half of the expression. Division prevents an overflow from occurring (in the worst-case scenario) because, otherwise, duration would keep increasing (potentially indefinitely), and eventually cause an overflow in the result of the expression.

When it comes to calculating rewards, specifically, we don't take into account the stake's entire duration, looping through each week on a need-to-count basis (we divide and conquer the problem of aggregating rewards). Claiming rewards or removing liquidity resets the deposit's timestamp to the current week (reducing its total rewards).

For a separate matter, we do factor in the average stake duration. The following property is inherited from the so-called "sunshine & rainbow" design doc: "you can only have 1 position per wallet; you can always add on top of your current position, but you can’t split your position into multiple pieces."

Contrarily, Bunni, a lit protocol (*L*iquidity *I*ncentive *T*oken), wraps UNIv3 NFTs into a fungible token balance. Each balance is tightly coupled to the price range (ticks) for said NFT. As such, Bunni is its own sort of aggregator using multiple fungible token balances for one depositor.

## Analysis
## Analysis

On an individual basis, depositors to may wish to decide the price range (ticks) *for their own* UNIv3 NFT. They can do this with `FOLDstaking.sol` by creating the NFT in advance (on an external platform), then calling our `depositNFT` function, or by instructing the details for having this NFT be constructed for them through the `deposit` function which takes `DepositParams`
On an individual basis, depositors may wish to decide the price range (ticks) *for their own* UNIv3 NFT. They can do this with `FOLDstaking.sol` by creating the NFT in advance (on an external platform), then calling our `deposit` function, which takes `DepositParams`.

Choosing price ranges for the individual deposit automatically applies a vote is used to affect the deposits of stakers who show no personal preference for their own NFT. This is because *we don't force* (though we do *encourage*) our depositors to accept the responsibility of this choice.
Choosing price ranges for the individual deposit automatically applies a vote to affect the deposits of stakers who show no personal preference for their own NFT. This is because *we don't force* (though we do *encourage*) our depositors to accept the responsibility of this choice.

Instead, by calling our third `deposit` function (with the least number of parameters) they may accept the time-weighted median for the price range (which factors in the individual decisions of depositors for each pool, respectively).

It is not necessary for LPs to manually claim fees collected by a V3 pool and redeposit them to
increase the liquidity of a deposit. Uniswap is designed to handle this automatically, ensuring that fees are continuously working to enhance the earning potential of LPs.
It is not necessary for LPs to manually claim fees collected by a V3 pool and redeposit them to increase the liquidity of a deposit. Uniswap is designed to handle this automatically, ensuring that fees are continuously working to enhance the earning potential of LPs.

Bunni has a `compound` function to increase the value of share tokens (ERC20 balances that each correspond to a key, which is a pool and a price range to go with it). `FOLDstaking.sol` approaches rewards differently so there is no requirement for this.

The difference also relates to how Bunni pays rewards pro rata to depositors' contribution per price range (relative to the total liquidity for that price range). `FOLDstaking.sol` pays rewards solely based on the duration of the deposit...and the total size of the deposit (across all price ranges) relative to the total liquidity in the pool (again, across all prices).

## Observations and results

`npx hardhat test`
The difference also relates to how Bunni pays rewards pro rata to depositors' contribution per price range (relative to the total liquidity for that price range). `FOLDstaking.sol` pays rewards solely based on the duration of the deposit and the total size of the deposit (across all price ranges) relative to the total liquidity in the pool (again, across all price ranges).
61 changes: 55 additions & 6 deletions src/FoldCaptiveStaking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {TickMath} from "./libraries/TickMath.sol";
/// contracts
import {ERC20} from "lib/solmate/src/tokens/ERC20.sol";
import {Owned} from "lib/solmate/src/auth/Owned.sol";
import {WETH9} from "./contracts/WETH9.sol";
import {WETH} from "lib/solmate/src/tokens/WETH.sol";

/// @author CopyPaste
/// @title FoldCaptiveStaking
Expand All @@ -32,13 +32,15 @@ contract FoldCaptiveStaking is Owned(msg.sender) {
event FeesCollected(address indexed user, uint256 fee0Owed, uint256 fee1Owed);
event RewardsCollected(address indexed user, uint256 rewardsOwed);
event Compounded(address indexed user, uint128 liquidity, uint256 fee0Owed, uint256 fee1Owed);
event InsuranceClaimed(address indexed owner, uint256 amount0, uint256 amount1);

/// Custom Errors
error ZeroAddress();
error AlreadyInitialized();
error NotInitialized();
error ZeroLiquidity();
error WithdrawFailed();
error WithdrawProRata();

/// @param _positionManager The Canonical UniswapV3 PositionManager
/// @param _pool The FOLD Pool to Reward
Expand All @@ -55,12 +57,13 @@ contract FoldCaptiveStaking is Owned(msg.sender) {
token0 = ERC20(POOL.token0());
token1 = ERC20(POOL.token1());

WETH = WETH9(payable(_weth));
WETH9 = WETH(payable(_weth));
FOLD = ERC20(_fold);

initialized = false;
}

/// @notice Initialize the contract by minting a small initial liquidity position
function initialize() public onlyOwner {
if (initialized) {
revert AlreadyInitialized();
Expand Down Expand Up @@ -145,12 +148,12 @@ contract FoldCaptiveStaking is Owned(msg.sender) {
mapping(address user => UserInfo info) public balances;

/// @dev The Canonical WETH address
WETH9 public immutable WETH;
WETH public immutable WETH9;
ERC20 public immutable FOLD;

/// @notice Allows anyone to add funds to the contract, split among all depositors
function depositRewards() public payable isInitialized {
WETH.deposit{value: msg.value}();
WETH9.deposit{value: msg.value}();
rewardsPerLiquidity += msg.value;
emit RewardsDeposited(msg.value);
}
Expand Down Expand Up @@ -255,19 +258,23 @@ contract FoldCaptiveStaking is Owned(msg.sender) {
uint256 rewardsOwed = (rewardsPerLiquidity - balances[msg.sender].rewardDebt) * balances[msg.sender].amount
/ liquidityUnderManagement;

WETH.transfer(msg.sender, rewardsOwed);
WETH9.transfer(msg.sender, rewardsOwed);

balances[msg.sender].rewardDebt = uint128(rewardsPerLiquidity);

emit RewardsCollected(msg.sender, rewardsOwed);
}

/// @notice liquidity The Liquidity Value which the user wants to withdraw
/// @notice Withdraws liquidity from the pool
/// @param liquidity The amount of liquidity to withdraw
function withdraw(uint128 liquidity) external isInitialized {
collectFees();
collectRewards();

if (liquidity > balances[msg.sender].amount / 2) {
revert WithdrawProRata();
}

balances[msg.sender].amount -= liquidity;
liquidityUnderManagement -= uint256(liquidity);

Expand Down Expand Up @@ -319,4 +326,46 @@ contract FoldCaptiveStaking is Owned(msg.sender) {
token0FeesPerLiquidity += amount0Collected;
token1FeesPerLiquidity += amount1Collected;
}

/// @notice Allows the owner to claim insurance in case of relay outage
/// @param liquidity The amount of liquidity to claim
function claimInsurance(uint128 liquidity) external onlyOwner {
collectPositionFees();
collectRewards();

if (liquidity > liquidityUnderManagement / 2) {
revert WithdrawProRata();
}

liquidityUnderManagement -= uint256(liquidity);

INonfungiblePositionManager.DecreaseLiquidityParams memory decreaseParams = INonfungiblePositionManager
.DecreaseLiquidityParams({
tokenId: TOKEN_ID,
liquidity: liquidity,
amount0Min: 0,
amount1Min: 0,
deadline: block.timestamp + 1 minutes
});

(uint256 amount0, uint256 amount1) = positionManager.decreaseLiquidity(decreaseParams);

INonfungiblePositionManager.CollectParams memory collectParams = INonfungiblePositionManager.CollectParams({
tokenId: TOKEN_ID,
recipient: address(this),
amount0Max: uint128(amount0),
amount1Max: uint128(amount1)
});

(uint256 amount0Collected, uint256 amount1Collected) = positionManager.collect(collectParams);

if (amount0Collected != amount0 || amount1Collected != amount1) {
revert WithdrawFailed();
}

token0.transfer(owner, amount0);
token1.transfer(owner, amount1);

emit InsuranceClaimed(owner, amount0, amount1);
}
}
76 changes: 0 additions & 76 deletions src/contracts/WETH9.sol

This file was deleted.

3 changes: 2 additions & 1 deletion test/BaseCaptiveTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ contract BaseCaptiveTest is Test {
error NotInitialized();
error ZeroLiquidity();
error WithdrawFailed();
error WithdrawProRata();

FoldCaptiveStaking public foldCaptiveStaking;

INonfungiblePositionManager public positionManager =
INonfungiblePositionManager(0xC36442b4a4522E871399CD717aBDD847Ab11FE88);
IUniswapV3Pool public pool = IUniswapV3Pool(0x5eCEf3b72Cb00DBD8396EBAEC66E0f87E9596e97);
WETH9 public weth = WETH9(payable(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2));
WETH public weth = WETH(payable(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2));
ERC20 public fold = ERC20(0xd084944d3c05CD115C09d072B9F44bA3E0E45921);

address public User01 = address(0x1);
Expand Down
75 changes: 73 additions & 2 deletions test/UnitTests.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ contract UnitTests is BaseCaptiveTest {
assertEq(token0FeeDebt, 0);
assertEq(token1FeeDebt, 0);

foldCaptiveStaking.withdraw(liq / 2);
foldCaptiveStaking.withdraw(liq / 4);

(amount,,,) = foldCaptiveStaking.balances(User01);

assertEq(amount, 0);
assertEq(amount, liq / 4);
}

function testFeesAccrue() public {
Expand Down Expand Up @@ -213,6 +213,77 @@ contract UnitTests is BaseCaptiveTest {
assertGt(weth.balanceOf(User01), initialBalance);

(uint128 liq,,,) = foldCaptiveStaking.balances(User01);
foldCaptiveStaking.withdraw(liq / 3);
}

function testClaimInsurance() public {
testAddLiquidity();

// Owner claims insurance
uint128 liquidityToClaim = uint128(foldCaptiveStaking.liquidityUnderManagement() / 4);

address owner = foldCaptiveStaking.owner();
vm.startPrank(owner);
uint256 initialToken0Balance = fold.balanceOf(owner);
uint256 initialToken1Balance = weth.balanceOf(owner);

foldCaptiveStaking.claimInsurance(liquidityToClaim);

assertGt(fold.balanceOf(owner), initialToken0Balance);
assertGt(weth.balanceOf(owner), initialToken1Balance);

vm.stopPrank();
}

function testProRataWithdrawals() public {
testAddLiquidity();

(uint128 liq,,,) = foldCaptiveStaking.balances(User01);

// Attempt to withdraw more than allowed amount
vm.expectRevert(WithdrawProRata.selector);
foldCaptiveStaking.withdraw(liq);

// Pro-rated withdrawal
foldCaptiveStaking.withdraw(liq / 2);
(uint128 amount,,,) = foldCaptiveStaking.balances(User01);
assertEq(amount, liq / 2);
}

function testZeroDeposit() public {
vm.expectRevert();
foldCaptiveStaking.deposit(0, 0, 0);
// (uint128 amount,,,) = foldCaptiveStaking.balances(User01);
// assertEq(amount, 0);
}

function testReentrancy() public {
testAddLiquidity();

// Create a reentrancy attack contract and attempt to exploit the staking contract
ReentrancyAttack attack = new ReentrancyAttack(payable(address(foldCaptiveStaking)));
fold.transfer(address(attack), 1 ether);
weth.transfer(address(attack), 1 ether);

vm.expectRevert();
attack.attack();
}
}

// Reentrancy attack contract
contract ReentrancyAttack {
FoldCaptiveStaking public staking;

constructor(address payable _staking) {
staking = FoldCaptiveStaking(_staking);
}

function attack() public {
staking.deposit(1 ether, 1 ether, 0);
staking.withdraw(1);
}

receive() external payable {
staking.withdraw(1);
}
}

0 comments on commit 817d829

Please sign in to comment.