2024-02-21 22:20:04 +01:00
// SPDX-License-Identifier: GPL-3.0-or-later
2024-03-28 19:55:01 +01:00
pragma solidity ^ 0 . 8 . 19 ;
2024-02-21 22:20:04 +01:00
2024-03-12 20:22:10 +01:00
import { IERC20 } from " @openzeppelin/token/ERC20/ERC20.sol " ;
2024-03-12 15:29:59 +01:00
import " @openzeppelin/token/ERC20/extensions/IERC20Metadata.sol " ;
2024-04-11 07:28:54 +02:00
import " @openzeppelin/token/ERC20/extensions/ERC20Permit.sol " ;
2024-03-12 20:22:10 +01:00
import { SafeERC20 } from " @openzeppelin/token/ERC20/utils/SafeERC20.sol " ;
2024-03-12 15:29:59 +01:00
import { Math } from " @openzeppelin/utils/math/Math.sol " ;
import " ./Harb.sol " ;
2024-02-21 22:20:04 +01:00
2024-03-14 17:31:16 +01:00
error ExceededAvailableStake ( address receiver , uint256 stakeWanted , uint256 availableStake ) ;
2024-06-07 11:22:22 +02:00
error TooMuchSnatch ( address receiver , uint256 stakeWanted , uint256 availableStake , uint256 smallestShare ) ;
2024-03-14 17:31:16 +01:00
2024-07-13 18:33:47 +02:00
/**
* @ 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 .
* /
2024-06-19 10:33:28 +02:00
contract Stake {
2024-03-12 15:29:59 +01:00
using Math for uint256 ;
2024-02-21 22:20:04 +01:00
2024-07-16 19:47:39 +02:00
// the offset between the "precision" of the representation of shares and assets
// see https://docs.openzeppelin.com/contracts/4.x/erc4626 for reason and details
2024-03-12 15:29:59 +01:00
uint256 internal DECIMAL_OFFSET = 5 + 2 ;
2024-07-16 19:47:39 +02:00
// only 20% of the total HARB supply can be staked.
2024-03-12 20:22:10 +01:00
uint256 internal constant MAX_STAKE = 20 ; // 20% of HARB supply
2024-02-23 22:01:23 +01:00
uint256 internal constant TAX_FLOOR_DURATION = 60 * 60 * 24 * 3 ; //this duration is the minimum basis for fee calculation, regardless of actual holding time.
2024-07-16 19:47:39 +02:00
// the tax rates are discrete to prevent users from snatching by micro incroments of tax
2024-06-13 10:50:09 +02:00
uint256 [ ] public TAX_RATES = [ 1 , 3 , 5 , 8 , 12 , 18 , 24 , 30 , 40 , 50 , 60 , 80 , 100 , 130 , 180 , 250 , 320 , 420 , 540 , 700 , 920 , 1200 , 1600 , 2000 , 2600 , 3400 , 4400 , 5700 , 7500 , 9700 ] ;
2024-07-16 19:47:39 +02:00
// this is the base for the values in the array above: e.g. 1/100 = 1%
uint256 internal constant TAX_RATE_BASE = 100 ;
2024-02-21 22:20:04 +01:00
/**
* @ dev Attempted to deposit more assets than the max amount for ` receiver ` .
* /
2024-03-12 20:22:10 +01:00
2024-02-21 22:20:04 +01:00
error TaxTooLow ( address receiver , uint64 taxRateWanted , uint64 taxRateMet , uint256 positionId ) ;
2024-06-21 15:57:23 +02:00
error StakeTooLow ( address receiver , uint256 assets , uint256 minStake ) ;
2024-02-21 22:20:04 +01:00
error NoPermission ( address requester , address owner ) ;
2024-06-19 10:33:28 +02:00
error PositionNotFound ( uint256 positionId , address requester ) ;
2024-02-21 22:20:04 +01:00
2024-06-23 08:44:54 +02:00
event PositionCreated ( uint256 indexed positionId , address indexed owner , uint256 harbDeposit , uint256 share , uint32 taxRate ) ;
event PositionTaxPaid ( uint256 indexed positionId , address indexed owner , uint256 taxPaid , uint256 newShares , uint256 taxRate ) ;
event PositionRateHiked ( uint256 indexed positionId , address indexed owner , uint256 newTaxRate ) ;
event PositionShrunk ( uint256 indexed positionId , address indexed owner , uint256 newShares , uint256 harbPayout ) ;
event PositionRemoved ( uint256 indexed positionId , address indexed owner , uint256 harbPayout ) ;
2024-03-18 12:42:30 +01:00
2024-02-21 22:20:04 +01:00
struct StakingPosition {
2024-02-23 22:01:23 +01:00
uint256 share ;
2024-02-21 22:20:04 +01:00
address owner ;
2024-02-23 22:01:23 +01:00
uint32 creationTime ;
uint32 lastTaxTime ;
2024-03-12 20:22:10 +01:00
uint32 taxRate ; // e.g. value of 60 = 60% tax per year
2024-02-21 22:20:04 +01:00
}
2024-06-13 10:50:09 +02:00
Harb private immutable harb ;
2024-02-23 22:01:23 +01:00
address private immutable taxPool ;
2024-07-17 14:08:53 +02:00
uint256 public immutable totalSupply ;
2024-02-21 22:20:04 +01:00
uint256 public outstandingStake ;
2024-03-18 12:42:30 +01:00
uint256 public nextPositionId ;
2024-07-17 14:08:53 +02:00
2024-03-14 12:40:57 +01:00
mapping ( uint256 => StakingPosition ) public positions ;
2024-02-21 22:20:04 +01:00
2024-07-13 18:33:47 +02:00
/// @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.
2024-06-13 10:50:09 +02:00
constructor ( address _harb ) {
harb = Harb ( _harb ) ;
totalSupply = 10 ** ( harb . decimals ( ) + DECIMAL_OFFSET ) ;
taxPool = Harb ( _harb ) . TAX_POOL ( ) ;
2024-07-17 14:08:53 +02:00
// start counting somewhere
2024-06-13 08:28:42 +02:00
nextPositionId = 654321 ;
2024-02-21 22:20:04 +01:00
}
2024-03-12 20:22:10 +01:00
function authorizedStake ( ) private view returns ( uint256 ) {
2024-03-12 11:38:16 +01:00
return totalSupply * MAX_STAKE / 100 ;
}
2024-07-16 20:59:42 +02:00
/// @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 )
? pos . creationTime + taxFloorDuration
: block . timestamp ;
uint256 elapsedTime = ihet - pos . lastTaxTime ;
uint256 assetsBefore = sharesToAssets ( pos . share ) ;
uint256 taxAmountDue = assetsBefore * TAX_RATES [ pos . taxRate ] * elapsedTime / ( 365 * 24 * 60 * 60 ) / TAX_RATE_BASE ;
if ( taxAmountDue >= assetsBefore ) {
// can not pay more tax than value of position
taxAmountDue = assetsBefore ;
}
SafeERC20 . safeTransfer ( harb , taxPool , taxAmountDue ) ;
if ( assetsBefore - taxAmountDue > 0 ) {
// if something left over, update storage
uint256 shareAfterTax = assetsToShares ( assetsBefore - taxAmountDue ) ;
outstandingStake -= pos . share - shareAfterTax ;
pos . share = shareAfterTax ;
pos . lastTaxTime = uint32 ( block . timestamp ) ;
emit PositionTaxPaid ( positionId , pos . owner , taxAmountDue , shareAfterTax , pos . taxRate ) ;
} else {
// if nothing left over, liquidate position
outstandingStake -= pos . share ;
emit PositionTaxPaid ( positionId , pos . owner , taxAmountDue , 0 , pos . taxRate ) ;
emit PositionRemoved ( positionId , pos . owner , 0 ) ;
delete pos . owner ;
delete pos . creationTime ;
delete pos . share ;
}
}
/// @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 ;
uint256 assets = sharesToAssets ( pos . share ) ;
emit PositionRemoved ( positionId , owner , assets ) ;
delete pos . owner ;
delete pos . creationTime ;
delete pos . share ;
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 ) ;
pos . share -= sharesToTake ;
outstandingStake -= sharesToTake ;
emit PositionShrunk ( positionId , pos . owner , pos . share , assets ) ;
SafeERC20 . safeTransfer ( harb , pos . owner , assets ) ;
}
2024-07-13 18:33:47 +02:00
/// @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.
2024-06-19 10:33:28 +02:00
function assetsToShares ( uint256 assets ) public view returns ( uint256 ) {
return assets . mulDiv ( totalSupply , harb . totalSupply ( ) , Math . Rounding . Down ) ;
2024-02-21 22:20:04 +01:00
}
2024-07-13 18:33:47 +02:00
/// @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.
2024-06-19 10:33:28 +02:00
function sharesToAssets ( uint256 shares ) public view returns ( uint256 ) {
return shares . mulDiv ( harb . totalSupply ( ) , totalSupply , Math . Rounding . Down ) ;
2024-02-21 22:20:04 +01:00
}
2024-07-13 18:33:47 +02:00
/// @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.
2024-03-12 20:22:10 +01:00
function snatch ( uint256 assets , address receiver , uint32 taxRate , uint256 [ ] calldata positionsToSnatch )
public
2024-03-18 12:42:30 +01:00
returns ( uint256 positionId )
2024-03-12 20:22:10 +01:00
{
2024-02-21 22:20:04 +01:00
// check lower boundary
2024-06-19 10:33:28 +02:00
uint256 sharesWanted = assetsToShares ( assets ) ;
2024-06-13 10:50:09 +02:00
{
2024-06-19 10:33:28 +02:00
// check that position size is at least minStake
2024-06-13 10:50:09 +02:00
// to prevent excessive fragmentation, increasing snatch cost
2024-07-17 14:08:53 +02:00
uint256 minStake = harb . minStake ( ) ;
2024-06-21 15:57:23 +02:00
if ( assets < minStake ) {
revert StakeTooLow ( receiver , assets , minStake ) ;
2024-06-13 10:50:09 +02:00
}
2024-02-21 22:20:04 +01:00
}
2024-06-13 10:50:09 +02:00
require ( taxRate < TAX_RATES . length , " tax rate out of bounds " ) ;
2024-02-21 22:20:04 +01:00
2024-06-07 11:22:22 +02:00
uint256 smallestPositionShare = totalSupply ;
2024-06-07 12:33:20 +02:00
uint256 availableStake = authorizedStake ( ) - outstandingStake ;
2024-06-07 11:22:22 +02:00
2024-06-13 08:28:42 +02:00
if ( positionsToSnatch . length >= 2 ) {
// run through all but last positions to snatch
2024-06-07 12:33:20 +02:00
for ( uint256 i = 0 ; i < positionsToSnatch . length - 1 ; i ++ ) {
StakingPosition storage pos = positions [ positionsToSnatch [ i ] ] ;
if ( pos . creationTime == 0 ) {
2024-06-19 10:33:28 +02:00
revert PositionNotFound ( positionsToSnatch [ i ] , receiver ) ;
2024-06-07 12:33:20 +02:00
}
// check that tax lower
if ( taxRate <= pos . taxRate ) {
2024-06-21 15:57:23 +02:00
revert TaxTooLow ( receiver , taxRate , pos . taxRate , positionsToSnatch [ i ] ) ;
2024-06-07 12:33:20 +02:00
}
if ( pos . share < smallestPositionShare ) {
smallestPositionShare = pos . share ;
}
// dissolve position
_payTax ( positionsToSnatch [ i ] , pos , 0 ) ;
_exitPosition ( positionsToSnatch [ i ] , pos ) ;
2024-06-07 11:22:22 +02:00
}
2024-06-13 08:28:42 +02:00
}
availableStake = authorizedStake ( ) - outstandingStake ;
2024-03-12 20:22:10 +01:00
2024-06-13 08:28:42 +02:00
if ( positionsToSnatch . length > 0 ) {
// handle last position, either shrink or snatch
2024-06-07 11:22:22 +02:00
uint256 index = positionsToSnatch . length - 1 ;
2024-06-09 16:06:41 +02:00
StakingPosition storage lastPos = positions [ positionsToSnatch [ index ] ] ;
if ( lastPos . creationTime == 0 ) {
2024-06-19 10:33:28 +02:00
revert PositionNotFound ( positionsToSnatch [ index ] , receiver ) ;
2024-06-07 11:22:22 +02:00
}
// check that tax lower
2024-06-09 16:06:41 +02:00
if ( taxRate <= lastPos . taxRate ) {
2024-06-21 15:57:23 +02:00
revert TaxTooLow ( receiver , taxRate , lastPos . taxRate , positionsToSnatch [ index ] ) ;
2024-06-07 11:22:22 +02:00
}
2024-06-09 16:06:41 +02:00
if ( lastPos . share < smallestPositionShare ) {
smallestPositionShare = lastPos . share ;
2024-06-07 11:22:22 +02:00
}
// dissolve position
2024-06-09 16:06:41 +02:00
_payTax ( positionsToSnatch [ index ] , lastPos , 0 ) ;
2024-06-13 08:28:42 +02:00
if ( availableStake > sharesWanted ) {
revert TooMuchSnatch ( receiver , sharesWanted , availableStake , smallestPositionShare ) ;
}
uint256 lastSharesNeeded = sharesWanted - availableStake ;
if ( lastSharesNeeded > lastPos . share * 80 / 100 ) {
_exitPosition ( positionsToSnatch [ index ] , lastPos ) ;
} else {
_shrinkPosition ( positionsToSnatch [ index ] , lastPos , lastSharesNeeded ) ;
}
2024-06-07 11:22:22 +02:00
}
2024-06-13 08:28:42 +02:00
availableStake = authorizedStake ( ) - outstandingStake ;
2024-06-07 11:22:22 +02:00
2024-02-21 22:20:04 +01:00
if ( sharesWanted > availableStake ) {
revert ExceededAvailableStake ( receiver , sharesWanted , availableStake ) ;
}
2024-06-07 11:22:22 +02:00
// avoid greeving where more positions are freed than needed.
if ( availableStake - sharesWanted > smallestPositionShare ) {
revert TooMuchSnatch ( receiver , sharesWanted , availableStake , smallestPositionShare ) ;
}
2024-02-21 22:20:04 +01:00
// transfer
2024-06-13 10:50:09 +02:00
SafeERC20 . safeTransferFrom ( harb , msg . sender , address ( this ) , assets ) ;
2024-02-21 22:20:04 +01:00
// mint
2024-03-18 12:42:30 +01:00
positionId = nextPositionId ++ ;
StakingPosition storage sp = positions [ positionId ] ;
2024-03-12 11:38:16 +01:00
sp . share = sharesWanted ;
2024-02-21 22:20:04 +01:00
sp . owner = receiver ;
2024-03-12 12:27:47 +01:00
sp . lastTaxTime = uint32 ( block . timestamp ) ;
sp . creationTime = uint32 ( block . timestamp ) ;
sp . taxRate = taxRate ;
2024-02-21 22:20:04 +01:00
outstandingStake += sharesWanted ;
2024-06-23 08:44:54 +02:00
emit PositionCreated ( positionId , sp . owner , assets , sp . share , sp . taxRate ) ;
2024-02-21 22:20:04 +01:00
}
2024-07-16 20:59:42 +02:00
/// @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 ,
uint32 taxRate ,
uint256 [ ] calldata positionsToSnatch ,
// address owner,
// address spender,
// uint256 value,
uint256 deadline ,
uint8 v ,
bytes32 r ,
bytes32 s
) external
returns ( uint256 positionId )
{
ERC20Permit ( address ( harb ) ) . permit ( receiver , address ( this ) , assets , deadline , v , r , s ) ;
return snatch ( assets , receiver , taxRate , positionsToSnatch ) ;
}
2024-07-13 18:33:47 +02:00
/// @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.
2024-07-16 20:59:42 +02:00
function changeTax ( uint256 positionId , uint32 taxRate ) external {
2024-06-13 10:50:09 +02:00
require ( taxRate < TAX_RATES . length , " tax rate out of bounds " ) ;
2024-06-19 10:33:28 +02:00
StakingPosition storage pos = positions [ positionId ] ;
if ( pos . creationTime == 0 ) {
revert PositionNotFound ( positionId , msg . sender ) ;
}
2024-04-15 07:08:13 +02:00
if ( pos . owner != msg . sender ) {
revert NoPermission ( msg . sender , pos . owner ) ;
}
// to prevent snatch-and-change grieving attack, pay TAX_FLOOR_DURATION
2024-06-13 10:50:09 +02:00
require ( taxRate > pos . taxRate , " tax too low to snatch " ) ;
2024-06-19 10:33:28 +02:00
_payTax ( positionId , pos , TAX_FLOOR_DURATION ) ;
2024-04-15 07:08:13 +02:00
pos . taxRate = taxRate ;
2024-07-13 14:56:13 +02:00
emit PositionRateHiked ( positionId , pos . owner , taxRate ) ;
2024-04-15 07:08:13 +02:00
}
2024-07-13 18:33:47 +02:00
/// @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.
2024-07-16 20:59:42 +02:00
function exitPosition ( uint256 positionId ) external {
2024-03-18 12:42:30 +01:00
StakingPosition storage pos = positions [ positionId ] ;
2024-06-19 10:33:28 +02:00
if ( pos . creationTime == 0 ) {
revert PositionNotFound ( positionId , msg . sender ) ;
}
2024-03-12 20:22:10 +01:00
if ( pos . owner != msg . sender ) {
2024-03-12 15:29:59 +01:00
revert NoPermission ( msg . sender , pos . owner ) ;
2024-02-21 22:20:04 +01:00
}
2024-03-12 11:38:16 +01:00
// to prevent snatch-and-exit grieving attack, pay TAX_FLOOR_DURATION
2024-04-15 07:08:13 +02:00
_payTax ( positionId , pos , TAX_FLOOR_DURATION ) ;
2024-03-18 12:42:30 +01:00
_exitPosition ( positionId , pos ) ;
2024-02-21 22:20:04 +01:00
}
2024-03-12 20:22:10 +01:00
2024-07-13 18:33:47 +02:00
/// @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.
2024-07-16 20:59:42 +02:00
function payTax ( uint256 positionId ) external {
2024-06-19 10:33:28 +02:00
StakingPosition storage pos = positions [ positionId ] ;
_payTax ( positionId , pos , 0 ) ;
2024-02-21 22:20:04 +01:00
}
2024-07-13 18:33:47 +02:00
/// @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.
2024-06-19 10:33:28 +02:00
function taxDue ( uint256 positionId , uint256 taxFloorDuration ) public view returns ( uint256 amountDue ) {
StakingPosition storage pos = positions [ positionId ] ;
2024-03-12 15:29:59 +01:00
// ihet = Implied Holding Expiry Timestamp
2024-03-12 20:22:10 +01:00
uint256 ihet = ( block . timestamp - pos . creationTime < taxFloorDuration )
? pos . creationTime + taxFloorDuration
: block . timestamp ;
2024-03-12 15:29:59 +01:00
uint256 elapsedTime = ihet - pos . lastTaxTime ;
2024-06-19 10:33:28 +02:00
uint256 assetsBefore = sharesToAssets ( pos . share ) ;
2024-06-09 16:06:41 +02:00
amountDue = assetsBefore * TAX_RATES [ pos . taxRate ] * elapsedTime / ( 365 * 24 * 60 * 60 ) / TAX_RATE_BASE ;
2024-03-12 15:29:59 +01:00
}
2024-07-17 14:08:53 +02:00
2024-02-21 22:20:04 +01:00
}