Skip to content

Commit

Permalink
Feat/sandy improvements (#4)
Browse files Browse the repository at this point in the history
* feat: add events and checks

* chore: forge fmt

* chore: custom errors

* feat: claimInsurance, pro-rata withdraws

* chore: update test rpc

* uncomment lines

---------

Co-authored-by: ControlCplusControlV <[email protected]>
  • Loading branch information
sandybradley and ControlCplusControlV authored Jul 23, 2024
1 parent dcb984f commit 08419d0
Show file tree
Hide file tree
Showing 8 changed files with 455 additions and 358 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@
/coverage
/coverage.json
package-lock.json

out

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).
155 changes: 126 additions & 29 deletions src/FoldCaptiveStaking.sol
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/// SPDX-License-Identifier: SSPL-1.-0
/// SPDX-License-Identifier: SSPL-1.0
pragma solidity 0.8.25;

/// Interfaces
Expand All @@ -13,34 +13,62 @@ 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
/// @notice Staking contract for managing FOLD token liquidity on Uniswap V3
contract FoldCaptiveStaking is Owned(msg.sender) {
/*//////////////////////////////////////////////////////////////
INITIALIZATION
//////////////////////////////////////////////////////////////*/
bool public initialized;

// Events
event Initialized();
event Deposit(address indexed user, uint256 amount0, uint256 amount1);
event Withdraw(address indexed user, uint128 liquidity);
event RewardsDeposited(uint256 amount);
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
/// @param _weth The address of WETH on the deployed chain
/// @param _fold The address of Fold on the deployed chain
constructor(address _positionManager, address _pool, address _weth, address _fold) {
if (_positionManager == address(0) || _pool == address(0) || _weth == address(0) || _fold == address(0)) {
revert ZeroAddress();
}

positionManager = INonfungiblePositionManager(_positionManager);
POOL = IUniswapV3Pool(_pool);

token0 = ERC20(POOL.token0());
token1 = ERC20(POOL.token1());

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

initialized = false;
}

function initialize() public {
/// @notice Initialize the contract by minting a small initial liquidity position
function initialize() public onlyOwner {
if (initialized) {
revert AlreadyInitialized();
}

// We must mint the pool a small dust LP position, which also prevents share attacks
// So this is our "minimum shares"
INonfungiblePositionManager.MintParams memory params = INonfungiblePositionManager.MintParams({
Expand All @@ -62,16 +90,21 @@ contract FoldCaptiveStaking is Owned(msg.sender) {

uint128 liquidity;
(TOKEN_ID, liquidity,,) = positionManager.mint(params);
require(liquidity > 0, "ZERO Liquidity");
if (liquidity == 0) {
revert ZeroLiquidity();
}

liquidityUnderManagement += uint256(liquidity);

initialized = true;
emit Initialized();
}

modifier isInitialized {
require(initialized, "NO INIT");
_;
modifier isInitialized() {
if (!initialized) {
revert NotInitialized();
}
_;
}

/*//////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -115,23 +148,29 @@ 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() isInitialized payable public {
WETH.deposit{value: msg.value}();
rewardsPerLiquidity += msg.value;
function depositRewards() public payable isInitialized {
WETH9.deposit{value: msg.value}();
rewardsPerLiquidity += msg.value;
emit RewardsDeposited(msg.value);
}

receive() external payable {
depositRewards();
}

/*//////////////////////////////////////////////////////////////
MANAGEMENT
//////////////////////////////////////////////////////////////*/

/// @param amount0 The amount of token0 to deposit
/// @notice Allows a user to deposit liquidity into the pool
/// @param amount0 The amount of token0 to deposit
/// @param amount1 The amount of token1 to deposit
/// @param slippage Slippage on deposit out of 1e18
function deposit(uint256 amount0, uint256 amount1, uint256 slippage) isInitialized external {
function deposit(uint256 amount0, uint256 amount1, uint256 slippage) external isInitialized {
collectFees();
collectRewards();

Expand Down Expand Up @@ -159,16 +198,18 @@ contract FoldCaptiveStaking is Owned(msg.sender) {

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

emit Deposit(msg.sender, amount0, amount1);
}

/// @notice Compounds User Earned Fees back into their position
function compound() isInitialized public {
/// @notice Compounds User Earned Fees back into their position
function compound() public isInitialized {
collectPositionFees();

uint256 fee0Owed = (token0FeesPerLiquidity - balances[msg.sender].token0FeeDebt) * balances[msg.sender].amount
/ liquidityUnderManagement;
uint256 fee1Owed = token1FeesPerLiquidity
- balances[msg.sender].token1FeeDebt * balances[msg.sender].amount / liquidityUnderManagement;
uint256 fee1Owed = (token1FeesPerLiquidity - balances[msg.sender].token1FeeDebt) * balances[msg.sender].amount
/ liquidityUnderManagement;

INonfungiblePositionManager.IncreaseLiquidityParams memory params = INonfungiblePositionManager
.IncreaseLiquidityParams({
Expand All @@ -190,40 +231,50 @@ contract FoldCaptiveStaking is Owned(msg.sender) {

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

emit Compounded(msg.sender, liquidity, fee0Owed, fee1Owed);
}

/// @notice User-specific function to collect fees on the singular position
function collectFees() isInitialized public {
function collectFees() public isInitialized {
collectPositionFees();

uint256 fee0Owed = (token0FeesPerLiquidity - balances[msg.sender].token0FeeDebt) * balances[msg.sender].amount
/ liquidityUnderManagement;
uint256 fee1Owed = (token1FeesPerLiquidity
- balances[msg.sender].token1FeeDebt) * balances[msg.sender].amount / liquidityUnderManagement;

uint256 fee1Owed = (token1FeesPerLiquidity - balances[msg.sender].token1FeeDebt) * balances[msg.sender].amount
/ liquidityUnderManagement;

token0.transfer(msg.sender, fee0Owed);
token1.transfer(msg.sender, fee1Owed);

balances[msg.sender].token0FeeDebt = uint128(token0FeesPerLiquidity);
balances[msg.sender].token1FeeDebt = uint128(token1FeesPerLiquidity);

emit FeesCollected(msg.sender, fee0Owed, fee1Owed);
}

/// @notice User-specific Rewards for Protocol Rewards
function collectRewards() isInitialized public {
function collectRewards() public isInitialized {
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
function withdraw(uint128 liquidity) isInitialized external {
/// @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 All @@ -247,10 +298,14 @@ contract FoldCaptiveStaking is Owned(msg.sender) {

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

require(amount0Collected == amount0 && amount1Collected == amount1, "WITHDRAW: FAIL");
if (amount0Collected != amount0 || amount1Collected != amount1) {
revert WithdrawFailed();
}

token0.transfer(msg.sender, amount0);
token1.transfer(msg.sender, amount1);

emit Withdraw(msg.sender, liquidity);
}

/*//////////////////////////////////////////////////////////////
Expand All @@ -271,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);
}
}
Loading

0 comments on commit 08419d0

Please sign in to comment.