diff --git a/onchain/README.md b/onchain/README.md index c3813c2..9f11af8 100644 --- a/onchain/README.md +++ b/onchain/README.md @@ -135,261 +135,16 @@ open features: - liquidation bot - shift/slide bot - ubi claim bot +- deployment on L2 +- make minStake a gov param +- prep for audit + - clean up TODOs + - clean up magic numbers + - coverage + - overflows + - reentry + - definition of severity of finding -## old lp - -``` -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.20; - -import "@uniswap-v3-periphery/libraries/PositionKey.sol"; -import "@uniswap-v3-core/libraries/FixedPoint128.sol"; -import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; -import "@aperture/uni-v3-lib/TickMath.sol"; -import "@aperture/uni-v3-lib/LiquidityAmounts.sol"; -import "@aperture/uni-v3-lib/PoolAddress.sol"; -import "@aperture/uni-v3-lib/CallbackValidation.sol"; -import "@openzeppelin/token/ERC20/IERC20.sol"; - -/** - * @title LiquidityManager - A contract that supports the harb ecosystem. It - * protects the communities liquidity while allowing a manager role to - * take strategic liqudity positions. - */ -contract LiquidityManager { - // default fee of 1% - uint24 constant FEE = uint24(10_000); - - // the address of the Uniswap V3 factory - address immutable factory; - IWETH9 immutable weth; - Harb immutable harb; - IUniswapV3Pool immutable pool; - PoolKey immutable poolKey; - bool immutable token0isWeth; - - - struct TokenPosition { - // the liquidity of the position - uint128 liquidity; - uint128 ethOwed; - // the fee growth of the aggregate position as of the last action on the individual position - uint256 feeGrowthInside0LastX128; - uint256 feeGrowthInside1LastX128; - } - - /// @dev The token ID position data - mapping(bytes26 => TokenPosition) private _positions; - - modifier checkDeadline(uint256 deadline) { - require(block.timestamp <= deadline, "Transaction too old"); - _; - } - - /// @notice Emitted when liquidity is increased for a position - /// @param liquidity The amount by which liquidity for the NFT position was increased - /// @param amount0 The amount of token0 that was paid for the increase in liquidity - /// @param amount1 The amount of token1 that was paid for the increase in liquidity - event IncreaseLiquidity(int24 indexed tickLower, int24 indexed tickUpper, uint128 liquidity, uint256 amount0, uint256 amount1); - /// @notice Emitted when liquidity is decreased for a position - /// @param liquidity The amount by which liquidity for the NFT position was decreased - /// @param ethReceived The amount of WETH that was accounted for the decrease in liquidity - event PositionLiquidated(int24 indexed tickLower, int24 indexed tickUpper, uint128 liquidity, uint256 ethReceived); - - constructor(address _factory, address _WETH9, address _harb) { - factory = _factory; - weth = IWETH9(_WETH9); - poolKey = PoolAddress.getPoolKey(_WETH9, _harb, FEE); - pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey)); - harb = Harb(_harb); - token0isWeth = _WETH9 < _harb; - } - - function posKey(int24 tickLower, int24 tickUpper) internal pure returns (bytes6 _posKey) { - bytes memory _posKeyBytes = abi.encodePacked(tickLower, tickUpper); - assembly { - _posKey := mload(add(_posKeyBytes, 6)) - } - } - - function positions(int24 tickLower, int24 tickUpper) - external - view - returns (uint128 liquidity, uint128 ethOwed, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128) - { - TokenPosition memory position = _positions[posKey(tickLower, tickUpper)]; - return (position.liquidity, position.ethOwed, position.feeGrowthInside0LastX128, position.feeGrowthInside1LastX128); - } - - function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata data) external { - CallbackValidation.verifyCallback(factory, poolKey); - - if (amount0Owed > 0) IERC20(poolKey.token0).transfer(msg.sender, amount0Owed); - if (amount1Owed > 0) IERC20(poolKey.token1).transfer(msg.sender, amount1Owed); - } - - - /// @notice Add liquidity to an initialized pool - // TODO: use uint256 amount0Min; uint256 amount1Min; if addLiquidity is called directly - function addLiquidity(int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 deadline) internal checkDeadline(deadline) { - - - (uint256 amount0, uint256 amount1) = pool.mint(address(this), tickLower, tickUpper, liquidity, abi.encode(poolKey)); - // If addLiquidity is only called after other pool operations that have checked slippage, this here is not needed - //require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, "Price slippage check"); - - - // read position and start tracking in storage - bytes32 positionKey = PositionKey.compute(address(this), tickLower, tickUpper); - (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128,,) = pool.positions(positionKey); - TokenPosition storage position = _positions[posKey(token, tickLower, tickUpper)]; - if (liquidity == 0) { - // create entry - position = TokenPosition({ - liquidity: liquidity, - ethOwed: 0, - feeGrowthInside0LastX128: feeGrowthInside0LastX128, - feeGrowthInside1LastX128: feeGrowthInside1LastX128 - }); - } else { - position.ethOwed += FullMath.mulDiv( - (token0isWeth) ? feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128 : feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128, - position.liquidity, - FixedPoint128.Q128 - ); - position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128; - position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128; - position.liquidity += liquidity; - } - emit IncreaseLiquidity(tickLower, tickUpper, liquidity, amount0, amount1); - } - - function liquidatePosition(int24 tickLower, int24 tickUpper, uint256 amount0Min, uint256 amount1Min) - internal - returns (uint256 ethReceived, uint256 liquidity) - { - // load position - TokenPosition storage position = _positions[posKey(tickLower, tickUpper)]; - - // burn and check slippage - uint256 liquidity = position.liquidity; - (uint256 amount0, uint256 amount1) = pool.burn(tickLower, tickUpper, liquidity); - require(amount0 >= amount0Min && amount1 >= amount1Min, "Price slippage check"); - // TODO: send harb fees or burn? - //harb.burn(token0isWeth ? amount1 : amount0); - - // calculate and transfer fees - bytes32 positionKey = PositionKey.compute(address(this), tickLower, tickUpper); - (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128,,) = pool.positions(positionKey); - uint256 ethOwed = position.ethOwed; - ethOwed += - FullMath.mulDiv( - (token0isWeth) ? feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128 : feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128, - liquidity, - FixedPoint128.Q128 - ); - weth.withdraw(ethOwed); - (bool sent, ) = feeRecipient.call{value: ethOwed}(""); - require(sent, "Failed to send Ether"); - - // event, cleanup and return - ethReceived = token0isWeth ? amount0 - ethOwed : amount1 - ethOwed; - emit PositionLiquidated(tickLower, tickUpper, liquidity, ethReceived); - delete position.liquidity; - delete position.ethOwed; - delete position.feeGrowthInside0LastX128; - delete position.feeGrowthInside1LastX128; - } - - // function compareTokenToEthBalance(uint256 ethAmountInPosition, uint256 tokenAmountInPosition) external view returns (bool hasMoreToken) { - // // Fetch the current sqrtPriceX96 from the pool - // (uint160 sqrtPriceX96,,,) = uniswapV3Pool.slot0(); - - // // Convert sqrtPriceX96 to a conventional price format - // // Note: The price is calculated as (sqrtPriceX96^2 / 2^192), simplified here as (price / 2^96) for the sake of example - // uint256 price = uint256(sqrtPriceX96) * uint256(sqrtPriceX96) / (1 << 96); - - // // Calculate the equivalent token amount for the ETH in the position at the current price - // // Assuming price is expressed as the amount of token per ETH - // uint256 equivalentTokenAmountForEth = ethAmountInPosition * price; - - // // Compare to the actual token amount in the position - // hasMoreToken = tokenAmountInPosition > equivalentTokenAmountForEth; - - // return hasMoreToken; - // } - - //////// - // - check if tick in range, otherwise revert - // - check if the position has more Token or more ETH, at current price - // - if more ETH, - // - calculate the amount of Token needed to be minted to bring the position to 50/50 - // - mint - // - deposit Token into pool - // - if more TOKEN - // - calculate the amount of token needed to be withdrawn from the position, to bring the position to 50/50 - // - withdraw - // - burn tokens - - - - function stretch(int24 tickLower, int24 tickUpper, uint256 deadline, uint256 amount0Min, uint256 amount1Min) external checkDeadline(deadline) { - - // Fetch the current tick from the Uniswap V3 pool - (, int24 currentTick, , , , , ) = pool.slot0(); - - // Check if current tick is within the specified range - int24 centerTick = tickLower + ((tickUpper - tickLower) / 2); - // TODO: add hysteresis - if (token0isWeth) { - require(currentTick > centerTick && currentTick <= tickUpper, "Current tick out of range for stretch"); - } else { - require(currentTick >= tickLower && currentTick < centerTick, "Current tick out of range for stretch"); - } - - (uint256 ethReceived, uint256 oldliquidity) = liquidatePosition(tickLower, tickUpper, amount0Min, amount1Min); - - uint256 liquidity; - int24 newTickLower; - int24 newTickUpper; - if (token0isWeth) { - newTickLower = tickLower; - newTickUpper = currentTick + (currentTick - tickLower); - // extend the range up - uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(tickLower); - uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(newTickUpper); - liquidity = LiquidityAmounts.getLiquidityForAmount0( - sqrtRatioAX96, sqrtRatioBX96, ethReceived - ); - // calculate amount for new liquidity - uint256 newAmount1 = LiquidityAmounts.getAmount1ForLiquidity( - sqrtRatioAX96, sqrtRatioBX96, liquidity - ); - uint256 currentBal = harb.balanceOf(address(this)); - if (currentBal < newAmount1) { - harb.mint(address(this), newAmount1 - currentBal); - } - - } else { - newTickUpper = tickUpper; - // extend the range down - uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(tickUpper); - uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(currentTick - (tickUpper - currentTick)); - liquidity = LiquidityAmounts.getLiquidityForAmount1( - sqrtRatioAX96, sqrtRatioBX96, ethReceived - ); - // calculate amount for new liquidity - uint256 newAmount0 = LiquidityAmounts.getAmount0ForLiquidity( - sqrtRatioAX96, sqrtRatioBX96, liquidity - ); - uint256 currentBal = harb.balanceOf(address(this)); - if (currentBal < newAmount0) { - harb.mint(address(this), newAmount0 - currentBal); - } - newTickLower = ... - } - addLiquidity(newTickLower, newTickUpper, liquidity, deadline); - } - -} -``` \ No newline at end of file +- find a way to account harb/eth for attacker +- NFT support of etherscan + https://etherscan.io/nft/0xe12edaab53023c75473a5a011bdb729ee73545e8/4218 \ No newline at end of file diff --git a/onchain/script/Deploy.sol b/onchain/script/Deploy.sol index 413e1eb..4e38341 100644 --- a/onchain/script/Deploy.sol +++ b/onchain/script/Deploy.sol @@ -6,7 +6,7 @@ import "@uniswap-v3-core/interfaces/IUniswapV3Factory.sol"; import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import "../src/Harb.sol"; import "../src/Stake.sol"; -import {BaseLineLP} from "../src/BaseLineLP.sol"; +import {LiquidityManager} from "../src/LiquidityManager.sol"; address constant WETH = 0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14; //Sepolia address constant V3_FACTORY = 0x0227628f3F023bb0B980b67D528571c95c6DaC1c; //Sepolia @@ -58,6 +58,7 @@ contract SepoliaScript is Script { vm.startBroadcast(privateKey); TwabController tc = TwabController(TWABC); + // in case you want to deploy an new TwabController //TwabController tc = new TwabController(60 * 60, uint32(block.timestamp)); Harb harb = new Harb("Harberger Tax", "HARB", tc); token0isWeth = address(WETH) < address(harb); @@ -67,11 +68,12 @@ contract SepoliaScript is Script { address liquidityPool = factory.createPool(WETH, address(harb), FEE); initializePoolFor1Cent(liquidityPool); harb.setLiquidityPool(liquidityPool); - BaseLineLP liquidityManager = new BaseLineLP(V3_FACTORY, WETH, address(harb)); + LiquidityManager liquidityManager = new LiquidityManager(V3_FACTORY, WETH, address(harb)); + // note: this delayed initialization is not a security issue. harb.setLiquidityManager(address(liquidityManager)); - //TODO: send some eth and call slide (bool sent, ) = address(liquidityManager).call{value: 0.1 ether}(""); require(sent, "Failed to send Ether"); + //TODO: wait few minutes and call slide vm.stopBroadcast(); } } diff --git a/onchain/src/Harb.sol b/onchain/src/Harb.sol index b63467f..ad5c353 100644 --- a/onchain/src/Harb.sol +++ b/onchain/src/Harb.sol @@ -1,5 +1,4 @@ // SPDX-License-Identifier: GPL-3.0-or-later - pragma solidity ^0.8.19; import {ERC20, IERC20, IERC20Metadata} from "@openzeppelin/token/ERC20/ERC20.sol"; @@ -9,23 +8,18 @@ import {TwabController} from "pt-v5-twab-controller/TwabController.sol"; import {Math} from "@openzeppelin/utils/math/Math.sol"; /** - * @title TWAB ERC20 Token - * @notice This contract creates an ERC20 token with balances stored in a TwabController, - * enabling time-weighted average balances for each holder. - * @dev The TwabController limits all balances including total token supply to uint96 for - * gas savings. Any mints that increase a balance past this limit will fail. + * @title Harb ERC20 Token + * @notice Implements an ERC20 token with mechanisms for minting, burning, and transferring tokens, + * integrated with a TWAB controller for time-weighted balance tracking. This token supports + * a novel economic model that finances Universal Basic Income (UBI) through a Harberger tax, + * applied to staked tokens. The liquidityManager manages token supply to stabilize market liquidity. */ contract Harb is ERC20, ERC20Permit { using Math for uint256; - address public constant TAX_POOL = address(2); - uint24 constant FEE = uint24(10_000); - uint256 immutable PERIOD_OFFSET; - uint256 immutable PERIOD_LENGTH; - - /* ============ Public Variables ============ */ - uint256 public sumTaxCollected; - uint256 public previousTotalSupply; + uint24 private constant FEE = uint24(10_000); + uint256 private immutable PERIOD_OFFSET; + uint256 private immutable PERIOD_LENGTH; //periphery contracts TwabController private immutable twabController; @@ -33,6 +27,12 @@ contract Harb is ERC20, ERC20Permit { address private stakingPool; address private liquidityPool; + address public constant TAX_POOL = address(2); + + /* ============ Public Variables ============ */ + uint256 public sumTaxCollected; + uint256 public previousTotalSupply; + struct UbiTitle { uint256 sumTaxCollected; @@ -42,8 +42,6 @@ contract Harb is ERC20, ERC20Permit { mapping(address => UbiTitle) public ubiTitles; /* ============ Errors ============ */ - - /// @notice Thrown if the some address is unexpectedly the zero address. error ZeroAddressInConstructor(); error ZeroAddressInSetter(); error AddressAlreadySet(); @@ -73,18 +71,30 @@ contract Harb is ERC20, ERC20Permit { PERIOD_LENGTH = twabController.PERIOD_LENGTH(); } + /// @notice Sets the address for the liquidityPool. Used once post-deployment to initialize the contract. + /// @dev Should be called only once right after the contract deployment to set the liquidity pool address. + /// Throws AddressAlreadySet if called more than once. + /// @param liquidityPool_ The address of the liquidity pool. function setLiquidityPool(address liquidityPool_) external { if (address(0) == liquidityPool_) revert ZeroAddressInSetter(); if (liquidityPool != address(0)) revert AddressAlreadySet(); liquidityPool = liquidityPool_; } + /// @notice Sets the address for the liquidityManager. Used once post-deployment to initialize the contract. + /// @dev Should be called only once right after the contract deployment to set the liquidity manager address. + /// Throws AddressAlreadySet if called more than once. + /// @param liquidityManager_ The address of the liquidity manager. function setLiquidityManager(address liquidityManager_) external { if (address(0) == liquidityManager_) revert ZeroAddressInSetter(); if (liquidityManager != address(0)) revert AddressAlreadySet(); liquidityManager = liquidityManager_; } + /// @notice Sets the address for the stakingPool. Used once post-deployment to initialize the contract. + /// @dev Should be called only once right after the contract deployment to set the staking pool address. + /// Throws AddressAlreadySet if called more than once. + /// @param stakingPool_ The address of the staking pool. function setStakingPool(address stakingPool_) external { if (address(0) == stakingPool_) revert ZeroAddressInSetter(); if (stakingPool != address(0)) revert AddressAlreadySet(); @@ -97,9 +107,10 @@ contract Harb is ERC20, ERC20Permit { /* ============ External Functions ============ */ - /// @notice Allows the liquidityManager to mint tokens for itself - /// @dev May be overridden to provide more granular control over minting - /// @param _amount Amount of tokens to mint + /// @notice Allows the liquidityManager to mint tokens for itself. + /// @dev Tokens minted are managed as community liquidity in the Uniswap pool to stabilize HARB prices. + /// Only callable by the Liquidity Manager. Minting rules and limits are defined externally. + /// @param _amount The number of tokens to mint. function mint(uint256 _amount) external onlyLiquidityManager { _mint(address(liquidityManager), _amount); if (previousTotalSupply == 0) { @@ -107,9 +118,10 @@ contract Harb is ERC20, ERC20Permit { } } - /// @notice Allows the liquidityManager to burn tokens from a its account - /// @dev May be overridden to provide more granular control over burning - /// @param _amount Amount of tokens to burn + /// @notice Allows the liquidityManager to burn tokens from its account, adjusting the staking pool accordingly. + /// @dev When tokens are burned, the total supply shrinks, making excess tokens in the staking pool unnecessary. + /// These excess tokens are burned to maintain the guaranteed fixed percentage of the total supply for stakers. + /// @param _amount The number of tokens to burn. function burn(uint256 _amount) external onlyLiquidityManager { _burn(address(liquidityManager), _amount); } @@ -205,11 +217,6 @@ contract Harb is ERC20, ERC20Permit { /* ============ UBI stuff ============ */ - function getUbiDue(address _account) public view returns (uint256 amountDue, uint256 lastPeriodEndAt) { - UbiTitle storage lastUbiTitle = ubiTitles[_account]; - return ubiDue(_account, lastUbiTitle.time, lastUbiTitle.sumTaxCollected); - } - function ubiDue(address _account, uint256 lastTaxClaimed, uint256 _sumTaxCollected) internal view returns (uint256 amountDue, uint256 lastPeriodEndAt) { lastPeriodEndAt = ((block.timestamp - PERIOD_OFFSET) / uint256(PERIOD_LENGTH)) * PERIOD_LENGTH + PERIOD_OFFSET - 1; @@ -226,6 +233,20 @@ contract Harb is ERC20, ERC20Permit { amountDue = taxCollectedSinceLastClaim.mulDiv(accountTwab, (totalSupplyTwab - stakeTwab - poolTwab - taxTwab), Math.Rounding.Down); } + /// @notice Calculates the UBI due to an account based on time-weighted average balances. + /// @dev Uses historic TWAB data to determine an account's proportionate share of collected taxes since last claim. + /// `lastTaxClaimed` is the timestamp of the last UBI claim. `_sumTaxCollected` is the tax collected up to the last claim. + /// @param _account The account whose UBI is being calculated. + /// @return amountDue The amount of UBI due to the account. + /// @return lastPeriodEndAt The timestamp marking the end of the last period considered for UBI calculation. + function getUbiDue(address _account) public view returns (uint256 amountDue, uint256 lastPeriodEndAt) { + UbiTitle storage lastUbiTitle = ubiTitles[_account]; + return ubiDue(_account, lastUbiTitle.time, lastUbiTitle.sumTaxCollected); + } + + /// @notice Claims the calculated UBI amount for the caller. + /// @dev Transfers the due UBI from the tax pool to the account, updating the UBI title. + /// Emits UbiClaimed event on successful transfer. function claimUbi(address _account) external returns (uint256 ubiAmountDue) { UbiTitle storage lastUbiTitle = ubiTitles[_account]; uint256 lastPeriodEndAt; diff --git a/onchain/src/BaseLineLP.sol b/onchain/src/LiquidityManager.sol similarity index 80% rename from onchain/src/BaseLineLP.sol rename to onchain/src/LiquidityManager.sol index 84e542e..87d33bf 100644 --- a/onchain/src/BaseLineLP.sol +++ b/onchain/src/LiquidityManager.sol @@ -16,14 +16,16 @@ import {Harb} from "./Harb.sol"; /** - * @title LiquidityManager - A contract that implements an automated market making strategy. - * It maintains 3 positions: - * - The floor position guarantees the capacity needed to maintain a minimum price of the HARB token It is a very tight liquidity range with enough reserve assets to buy back the circulating supply. - * - The anchor range provides liquidity around the current market price, ensuring liquid trading conditions for the token, regardless of the market environment. - * - The discovery range starts 1000 ticks above the current market price and increases from there. It consists solely of unissued tokens, which are sold as the market price increases. - * The liquidity surplus obtained from selling tokens in the discovery range is directed back into the floor and anchor positions. - */ -contract BaseLineLP { + * @title LiquidityManager for Harb Token on Uniswap V3 + * @notice Manages liquidity provisioning on Uniswap V3 for the Harb token by maintaining three distinct positions: + * - Floor Position: Ensures a minimum price support by having enough reserve assets to potentially buy back the circulating supply of Harb. + * - Anchor Position: Provides liquidity around the current market price to facilitate trading and maintain market stability. + * - Discovery Position: Expands liquidity by minting new Harb tokens as the price rises, capturing potential growth in the ecosystem. + * The contract dynamically adjusts these positions in response to market movements to maintain strategic liquidity levels and support the Harb token's price. + * It also collects and transfers fees generated from trading activities to a designated fee destination. + * @dev Utilizes Uniswap V3's concentrated liquidity feature, enabling highly efficient use of capital. + */ +contract LiquidityManager { int24 internal constant TICK_SPACING = 200; int24 internal constant ANCHOR_SPACING = 5 * TICK_SPACING; int24 internal constant DISCOVERY_SPACING = 11000; @@ -62,6 +64,11 @@ contract BaseLineLP { // TODO: add events + /// @notice Creates a liquidity manager for managing Harb token liquidity on Uniswap V3. + /// @param _factory The address of the Uniswap V3 factory. + /// @param _WETH9 The address of the WETH contract for handling ETH in trades. + /// @param _harb The address of the Harb token contract. + /// @dev Computes the Uniswap pool address for the Harb-WETH pair and sets up the initial configuration for the liquidity manager. constructor(address _factory, address _WETH9, address _harb) { factory = _factory; weth = IWETH9(_WETH9); @@ -71,6 +78,10 @@ contract BaseLineLP { token0isWeth = _WETH9 < _harb; } + /// @notice Callback function that Uniswap V3 calls for liquidity actions requiring minting or burning of tokens. + /// @param amount0Owed The amount of token0 owed for the liquidity provision. + /// @param amount1Owed The amount of token1 owed for the liquidity provision. + /// @dev This function mints Harb tokens as needed and handles WETH deposits for ETH conversions during liquidity interactions. function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external { CallbackValidation.verifyCallback(factory, poolKey); // take care of harb @@ -85,6 +96,9 @@ contract BaseLineLP { if (amount1Owed > 0) IERC20(poolKey.token1).transfer(msg.sender, amount1Owed); } + /// @notice Sets the address to which trading fees are transferred. + /// @param feeDestination_ The address that will receive the collected trading fees. + /// @dev Can only be called once to set the fee destination, further attempts will revert. function setFeeDestination(address feeDestination_) external { if (address(0) == feeDestination_) revert ZeroAddressInSetter(); if (feeDestination != address(0)) revert AddressAlreadySet(); @@ -96,6 +110,11 @@ contract BaseLineLP { } + /// @notice Calculates the Uniswap V3 tick corresponding to a given price ratio between Harb and ETH. + /// @param t0isWeth Boolean flag indicating if token0 is WETH. + /// @param tokenAmount Amount of the Harb token. + /// @param ethAmount Amount of Ethereum. + /// @return tick_ The calculated tick for the given price ratio. function tickAtPrice(bool t0isWeth, uint256 tokenAmount, uint256 ethAmount) internal pure returns (int24 tick_) { require(ethAmount > 0, "ETH amount cannot be zero"); uint160 sqrtPriceX96; @@ -117,12 +136,20 @@ contract BaseLineLP { tick_ = t0isWeth ? tick_ : -tick_; } + /// @notice Calculates the price ratio from a given Uniswap V3 tick. + /// @param tick The tick for which to calculate the price ratio. + /// @return priceRatio The price ratio corresponding to the given tick. function tickToPrice(int24 tick) public pure returns (uint256 priceRatio) { uint160 sqrtRatio = TickMath.getSqrtRatioAtTick(tick); uint256 adjustedSqrtRatio = uint256(sqrtRatio) / (1 << 48); priceRatio = adjustedSqrtRatio * adjustedSqrtRatio; } + /// @notice Internal function to mint liquidity positions in the Uniswap V3 pool. + /// @param stage The liquidity stage (floor, anchor, discovery) being adjusted. + /// @param tickLower The lower bound of the tick range for the position. + /// @param tickUpper The upper bound of the tick range for the position. + /// @param liquidity The amount of liquidity to mint at the specified range. function _mint(Stage stage, int24 tickLower, int24 tickUpper, uint128 liquidity) internal { // create position pool.mint( @@ -141,6 +168,10 @@ contract BaseLineLP { }); } + /// @notice Internal function to set or adjust the floor, anchor, and discovery positions based on current market conditions and the manager's strategy. + /// @param sqrtPriceX96 The current price, expressed as a square root value that Uniswap V3 uses. + /// @param currentTick The current market tick. + /// @dev Recalculates and realigns all liquidity positions according to the latest market data and strategic requirements. function _set(uint160 sqrtPriceX96, int24 currentTick) internal { // ### set Floor position @@ -324,8 +355,8 @@ contract BaseLineLP { return (currentTick >= averageTick - MAX_TICK_DEVIATION && currentTick <= averageTick + MAX_TICK_DEVIATION); } - // call this function when price has moved up x% - // TODO: write a bot that calls this function regularly + /// @notice Adjusts liquidity positions upward in response to an increase in the Harb token's price. + /// @dev This function should be called when significant upward price movement is detected. It recalibrates the liquidity ranges to align with the new market conditions. function shift() external { require(positions[Stage.ANCHOR].liquidity > 0, "Not initialized"); // Fetch the current tick from the Uniswap V3 pool @@ -357,7 +388,8 @@ contract BaseLineLP { _set(sqrtPriceX96, currentTick); } - + /// @notice Adjusts liquidity positions downward in response to a decrease in the Harb token's price. + /// @dev This function should be called when significant downward price movement is detected. It recalibrates the liquidity ranges to align with the new market conditions. function slide() external { // Fetch the current tick from the Uniswap V3 pool (uint160 sqrtPriceX96, int24 currentTick, , , , , ) = pool.slot0(); diff --git a/onchain/src/Stake.sol b/onchain/src/Stake.sol index 0cad5d4..9039087 100644 --- a/onchain/src/Stake.sol +++ b/onchain/src/Stake.sol @@ -1,5 +1,4 @@ // SPDX-License-Identifier: GPL-3.0-or-later - pragma solidity ^0.8.19; import {IERC20} from "@openzeppelin/token/ERC20/ERC20.sol"; @@ -12,6 +11,22 @@ import "./Harb.sol"; error ExceededAvailableStake(address receiver, uint256 stakeWanted, uint256 availableStake); error TooMuchSnatch(address receiver, uint256 stakeWanted, uint256 availableStake, uint256 smallestShare); +/** + * @title Stake Contract for Harb Token + * @notice This contract manages the staking positions for the Harb token, allowing users to stake tokens + * in exchange for a share of the total supply. Stakers can set and adjust tax rates on their stakes, + * which affect the Universal Basic Income (UBI) paid from the tax pool. + * + * The contract handles: + * - Creation of staking positions with specific tax rates. + * - Snatching of existing positions under certain conditions to consolidate stakes. + * - Calculation and payment of taxes based on stake duration and tax rate. + * - Adjustment of tax rates with protections against griefing through rapid changes. + * - Exiting of positions, either partially or fully, returning the staked assets to the owner. + * + * Tax rates and staking positions are adjustable, with a mechanism to prevent snatch-grieving by + * enforcing a minimum tax payment duration. + */ contract Stake { using Math for uint256; @@ -51,6 +66,9 @@ contract Stake { uint256 public nextPositionId; mapping(uint256 => StakingPosition) public positions; + /// @notice Initializes the stake contract with references to the Harb contract and sets the initial position ID. + /// @param _harb Address of the Harb contract which this Stake contract interacts with. + /// @dev Sets up the total supply based on the decimals of the Harb token plus a fixed offset. constructor(address _harb) { harb = Harb(_harb); @@ -63,17 +81,29 @@ contract Stake { return totalSupply * MAX_STAKE / 100; } + /// @notice Converts Harb token assets to shares of the total staking pool. + /// @param assets Number of Harb tokens to convert. + /// @return Number of shares corresponding to the input assets based on the current total supply of Harb tokens. function assetsToShares(uint256 assets) public view returns (uint256) { return assets.mulDiv(totalSupply, harb.totalSupply(), Math.Rounding.Down); - //return assets.mulDiv(totalSupply, harb.totalSupply() + 1, rounding); } + /// @notice Converts shares of the total staking pool back to Harb token assets. + /// @param shares Number of shares to convert. + /// @return The equivalent number of Harb tokens for the given shares. function sharesToAssets(uint256 shares) public view returns (uint256) { - //return shares.mulDiv(harb.totalSupply() + 1, totalSupply, rounding); // TODO: should the average total supply be used for this calculation? return shares.mulDiv(harb.totalSupply(), totalSupply, Math.Rounding.Down); } + /// @notice Combines an ERC20 permit operation with the snatch function, allowing a staking position creation in one transaction. + /// @param assets Number of Harb tokens to stake. + /// @param receiver Address that will own the new staking position. + /// @param taxRate The initial tax rate for the new staking position. + /// @param positionsToSnatch Array of position IDs that the new position will replace by snatching. + /// @param deadline Time until which the permit is valid. + /// @param v, r, s Components of the signature for the permit. + /// @return positionId The ID of the newly created staking position. function permitAndSnatch( uint256 assets, address receiver, @@ -93,13 +123,13 @@ contract Stake { return snatch(assets, receiver, taxRate, positionsToSnatch); } - /** - * TODO: deal with metatransactions: While these are generally available - * via msg.sender and msg.data, they should not be accessed in such a direct - * manner, since when dealing with meta-transactions the account sending and - * paying for execution may not be the actual sender (as far as an application - * is concerned). - */ + /// @notice Creates a new staking position by potentially snatching shares from existing positions. + /// @param assets Amount of Harb tokens to convert into a staking position. + /// @param receiver Address that will own the new staking position. + /// @param taxRate The initial tax rate for the new staking position. + /// @param positionsToSnatch Array of position IDs that the new position will replace by snatching. + /// @return positionId The ID of the newly created staking position. + /// @dev Handles staking logic, including tax rate validation and position merging or dissolving. function snatch(uint256 assets, address receiver, uint32 taxRate, uint256[] calldata positionsToSnatch) public returns (uint256 positionId) @@ -195,6 +225,10 @@ contract Stake { emit PositionCreated(positionId, sp.owner, assets, sp.share, sp.taxRate); } + /// @notice Changes the tax rate of an existing staking position. + /// @param positionId The ID of the staking position to update. + /// @param taxRate The new tax rate to apply to the position. + /// @dev Ensures that the tax rate change is valid and applies the minimum tax based on the TAX_FLOOR_DURATION. function changeTax(uint256 positionId, uint32 taxRate) public { require(taxRate < TAX_RATES.length, "tax rate out of bounds"); StakingPosition storage pos = positions[positionId]; @@ -211,6 +245,9 @@ contract Stake { emit PositionRateHiked(positionId, pos.owner, taxRate); } + /// @notice Allows the owner of a staking position to exit, returning the staked assets. + /// @param positionId The ID of the staking position to exit. + /// @dev Pays the due taxes based on the TAX_FLOOR_DURATION and returns the remaining assets to the position owner. function exitPosition(uint256 positionId) public { StakingPosition storage pos = positions[positionId]; if (pos.creationTime == 0) { @@ -224,13 +261,19 @@ contract Stake { _exitPosition(positionId, pos); } - // TODO: write a bot that calls this function regularly + /// @notice Manually triggers the tax payment for a specified staking position. + /// @param positionId The ID of the staking position for which to pay taxes. + /// @dev Calculates and pays the tax due, possibly adjusting the position's share count. function payTax(uint256 positionId) public { StakingPosition storage pos = positions[positionId]; // TODO: what if someone calls payTax and exitPosition in the same transaction? _payTax(positionId, pos, 0); } + /// @notice Calculates the Tax that is due to be paid on specific positoin + /// @param positionId The ID of the staking position for which to pay taxes. + /// @param taxFloorDuration if a minimum holding duration is applied to the position this value is > 0 in seconds. + /// @dev Calculates the tax due. function taxDue(uint256 positionId, uint256 taxFloorDuration) public view returns (uint256 amountDue) { StakingPosition storage pos = positions[positionId]; // ihet = Implied Holding Expiry Timestamp @@ -242,6 +285,7 @@ contract Stake { amountDue = assetsBefore * TAX_RATES[pos.taxRate] * elapsedTime / (365 * 24 * 60 * 60) / TAX_RATE_BASE; } + /// @dev Internal function to calculate and pay taxes for a position, adjusting shares and handling position liquidation if necessary. function _payTax(uint256 positionId, StakingPosition storage pos, uint256 taxFloorDuration) private { // ihet = Implied Holding Expiry Timestamp uint256 ihet = (block.timestamp - pos.creationTime < taxFloorDuration) @@ -274,6 +318,7 @@ contract Stake { } } + /// @dev Internal function to close a staking position, transferring the remaining Harb tokens back to the owner after tax payment. function _exitPosition(uint256 positionId, StakingPosition storage pos) private { outstandingStake -= pos.share; address owner = pos.owner; @@ -285,6 +330,7 @@ contract Stake { SafeERC20.safeTransfer(harb, owner, assets); } + /// @dev Internal function to reduce the size of a staking position by a specified number of shares, transferring the corresponding Harb tokens to the owner. function _shrinkPosition(uint256 positionId, StakingPosition storage pos, uint256 sharesToTake) private { require (sharesToTake < pos.share, "position too small"); uint256 assets = sharesToAssets(sharesToTake); diff --git a/onchain/test/BaseLineLP2.t.sol b/onchain/test/LiquidityManager.t.sol similarity index 99% rename from onchain/test/BaseLineLP2.t.sol rename to onchain/test/LiquidityManager.t.sol index e8638c9..9c6bdde 100644 --- a/onchain/test/BaseLineLP2.t.sol +++ b/onchain/test/LiquidityManager.t.sol @@ -13,7 +13,7 @@ import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import {Harb} from "../src/Harb.sol"; import {Stake, ExceededAvailableStake} from "../src/Stake.sol"; -import {BaseLineLP} from "../src/BaseLineLP.sol"; +import {LiquidityManager} from "../src/LiquidityManager.sol"; address constant TAX_POOL = address(2); @@ -27,13 +27,13 @@ contract Dummy { // This contract can be empty as it is only used to affect the nonce } -contract BaseLineLP2Test is Test { +contract LiquidityManagerTest is Test { IWETH9 weth; Harb harb; IUniswapV3Factory factory; Stake stake; - BaseLineLP lm; + LiquidityManager lm; IUniswapV3Pool pool; bool token0isWeth; address account = makeAddr("alice"); @@ -124,7 +124,7 @@ contract BaseLineLP2Test is Test { stake = new Stake(address(harb)); harb.setStakingPool(address(stake)); - lm = new BaseLineLP(factoryAddress, address(weth), address(harb)); + lm = new LiquidityManager(factoryAddress, address(weth), address(harb)); lm.setFeeDestination(feeDestination); harb.setLiquidityManager(address(lm)); vm.deal(address(lm), 10 ether); @@ -178,7 +178,7 @@ contract BaseLineLP2Test is Test { } - function getBalancesPool(BaseLineLP.Stage s) internal view returns (int24 currentTick, int24 tickLower, int24 tickUpper, uint256 ethAmount, uint256 harbAmount) { + function getBalancesPool(LiquidityManager.Stage s) internal view returns (int24 currentTick, int24 tickLower, int24 tickUpper, uint256 ethAmount, uint256 harbAmount) { (,tickLower, tickUpper) = lm.positions(s); (uint128 liquidity, , , ,) = pool.positions(keccak256(abi.encodePacked(address(lm), tickLower, tickUpper))); @@ -225,13 +225,13 @@ contract BaseLineLP2Test is Test { int24 currentTick; int24 tickLower; int24 tickUpper; - (currentTick, tickLower, tickUpper, ethFloor, harbFloor) = getBalancesPool(BaseLineLP.Stage.FLOOR); + (currentTick, tickLower, tickUpper, ethFloor, harbFloor) = getBalancesPool(LiquidityManager.Stage.FLOOR); string memory floorData = string(abi.encodePacked(intToStr(tickLower), ",", intToStr(tickUpper), ",", uintToStr(ethFloor), ",", uintToStr(harbFloor), ",")); - (,tickLower, tickUpper, ethAnchor, harbAnchor) = getBalancesPool(BaseLineLP.Stage.ANCHOR); + (,tickLower, tickUpper, ethAnchor, harbAnchor) = getBalancesPool(LiquidityManager.Stage.ANCHOR); string memory anchorData = string(abi.encodePacked(intToStr(tickLower), ",", intToStr(tickUpper), ",", uintToStr(ethAnchor), ",", uintToStr(harbAnchor), ",")); - (,tickLower, tickUpper, ethDiscovery, harbDiscovery) = getBalancesPool(BaseLineLP.Stage.DISCOVERY); + (,tickLower, tickUpper, ethDiscovery, harbDiscovery) = getBalancesPool(LiquidityManager.Stage.DISCOVERY); string memory discoveryData = string(abi.encodePacked(intToStr(tickLower), ",", intToStr(tickUpper), ",", uintToStr(ethDiscovery), ",", uintToStr(harbDiscovery), ",")); csv = string(abi.encodePacked(csv, "\n", eventName, ",", intToStr(currentTick), ",", floorData, anchorData, discoveryData)); @@ -525,7 +525,7 @@ contract BaseLineLP2Test is Test { if (f >= frequency) { (, int24 currentTick, , , , , ) = pool.slot0(); - (, int24 tickLower, int24 tickUpper) = lm.positions(BaseLineLP.Stage.ANCHOR); + (, int24 tickLower, int24 tickUpper) = lm.positions(LiquidityManager.Stage.ANCHOR); int24 midTick = token0isWeth ? tickLower + ANCHOR_SPACING : tickUpper - ANCHOR_SPACING; if (currentTick < midTick) { // Current tick is below the midpoint, so call slide()