// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import {IERC20} from "@openzeppelin/token/ERC20/ERC20.sol"; import "@openzeppelin/token/ERC20/extensions/IERC20Metadata.sol"; import "@openzeppelin/token/ERC20/extensions/ERC20Permit.sol"; import {SafeERC20} from "@openzeppelin/token/ERC20/utils/SafeERC20.sol"; import {Math} from "@openzeppelin/utils/math/Math.sol"; import "./interfaces/IStake.sol"; import "./Harb.sol"; error ExceededAvailableStake(address receiver, uint256 stakeWanted, uint256 availableStake); error TooMuchSnatch(address receiver, uint256 stakeWanted, uint256 availableStake, uint256 smallestShare); contract Stake is IStake { using Math for uint256; uint256 internal DECIMAL_OFFSET = 5 + 2; uint256 internal constant MAX_STAKE = 20; // 20% of HARB supply uint256 internal constant MAX_TAX = 1000; // max 1000% tax per year uint256 internal constant TAX_RATE_BASE = 100; uint256 internal constant TAX_FLOOR_DURATION = 60 * 60 * 24 * 3; //this duration is the minimum basis for fee calculation, regardless of actual holding time. /** * @dev Attempted to deposit more assets than the max amount for `receiver`. */ error TaxTooLow(address receiver, uint64 taxRateWanted, uint64 taxRateMet, uint256 positionId); error SharesTooLow(address receiver, uint256 assets, uint256 sharesWanted, uint256 minStake); error NoPermission(address requester, address owner); error PositionNotFound(); event PositionCreated(uint256 indexed positionId, address indexed owner, uint256 share, uint32 creationTime, uint32 taxRate); event TaxPaid(uint256 indexed positionId, address indexed owner, uint256 taxAmount); event PositionRemoved(uint256 indexed positionId, uint256 share, uint32 lastTaxTime); event PositionShrunk(uint256 indexed positionId, uint256 share, uint32 lastTaxTime, uint256 sharesTaken); struct StakingPosition { uint256 share; address owner; uint32 creationTime; uint32 lastTaxTime; uint32 taxRate; // e.g. value of 60 = 60% tax per year } uint256 public immutable totalSupply; IERC20Metadata private immutable tokenContract; address private immutable taxPool; uint256 public outstandingStake; uint256 public nextPositionId; uint256 public minStake; mapping(uint256 => StakingPosition) public positions; constructor(address _tokenContract) { tokenContract = IERC20Metadata(_tokenContract); totalSupply = 10 ** (tokenContract.decimals() + DECIMAL_OFFSET); taxPool = Harb(_tokenContract).TAX_POOL(); } function dormantSupply() public view override returns (uint256) { return totalSupply * (100 - MAX_STAKE) / 100; } function authorizedStake() private view returns (uint256) { return totalSupply * MAX_STAKE / 100; } function assetsToShares(uint256 assets, Math.Rounding rounding) private view returns (uint256) { return assets.mulDiv(totalSupply, tokenContract.totalSupply(), rounding); //return assets.mulDiv(totalSupply, tokenContract.totalSupply() + 1, rounding); } function sharesToAssets(uint256 shares, Math.Rounding rounding) private view returns (uint256) { //return shares.mulDiv(tokenContract.totalSupply() + 1, totalSupply, rounding); return shares.mulDiv(tokenContract.totalSupply(), totalSupply, rounding); } 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(tokenContract)).permit(receiver, address(this), assets, deadline, v, r, s); 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). */ function snatch(uint256 assets, address receiver, uint32 taxRate, uint256[] calldata positionsToSnatch) public returns (uint256 positionId) { // check lower boundary uint256 sharesWanted = assetsToShares(assets, Math.Rounding.Down); if (sharesWanted < minStake) { revert SharesTooLow(receiver, assets, sharesWanted, minStake); } // TODO: check that position size is multiple of minStake uint256 smallestPositionShare = totalSupply; // run through all suggested positions to snatch for (uint256 i = 0; i < positionsToSnatch.length - 1; i++) { StakingPosition storage pos = positions[positionsToSnatch[i]]; if (pos.creationTime == 0) { //TODO: revert PositionNotFound(); } // check that tax lower if (taxRate <= pos.taxRate) { revert TaxTooLow(receiver, taxRate, pos.taxRate, i); } if (pos.share < smallestPositionShare) { smallestPositionShare = pos.share; } // dissolve position // TODO: what if someone calls payTax and exitPosition in the same transaction? _payTax(positionsToSnatch[i], pos, 0); _exitPosition(positionsToSnatch[i], pos); } uint256 availableStake = authorizedStake() - outstandingStake; // handle last position if (positionsToSnatch.length > 0) { uint256 index = positionsToSnatch.length - 1; StakingPosition storage pos = positions[positionsToSnatch[index]]; if (pos.creationTime == 0) { //TODO: revert PositionNotFound(); } // check that tax lower if (taxRate <= pos.taxRate) { revert TaxTooLow(receiver, taxRate, pos.taxRate, index); } if (pos.share < smallestPositionShare) { smallestPositionShare = pos.share; } // dissolve position _payTax(positionsToSnatch[index], pos, 0); uint256 lastBitNeeded = sharesWanted - availableStake; _shrinkPosition(positionsToSnatch[index], pos, lastBitNeeded); availableStake += lastBitNeeded; } if (sharesWanted > availableStake) { revert ExceededAvailableStake(receiver, sharesWanted, availableStake); } // avoid greeving where more positions are freed than needed. if (availableStake - sharesWanted > smallestPositionShare) { revert TooMuchSnatch(receiver, sharesWanted, availableStake, smallestPositionShare); } // transfer SafeERC20.safeTransferFrom(tokenContract, msg.sender, address(this), assets); // mint positionId = nextPositionId++; StakingPosition storage sp = positions[positionId]; sp.share = sharesWanted; sp.owner = receiver; sp.lastTaxTime = uint32(block.timestamp); sp.creationTime = uint32(block.timestamp); sp.taxRate = taxRate; outstandingStake += sharesWanted; emit PositionCreated(positionId, sp.owner, sp.share, sp.creationTime, sp.taxRate); } function changeTax(uint256 positionID, uint32 taxRate) public { StakingPosition storage pos = positions[positionID]; if (pos.owner != msg.sender) { revert NoPermission(msg.sender, pos.owner); } // to prevent snatch-and-change grieving attack, pay TAX_FLOOR_DURATION require(taxRate > pos.taxRate); _payTax(positionID, pos, TAX_FLOOR_DURATION); pos.taxRate = taxRate; } function exitPosition(uint256 positionId) public { StakingPosition storage pos = positions[positionId]; if (pos.owner != msg.sender) { revert NoPermission(msg.sender, pos.owner); } // to prevent snatch-and-exit grieving attack, pay TAX_FLOOR_DURATION _payTax(positionId, pos, TAX_FLOOR_DURATION); _exitPosition(positionId, pos); } 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); } function taxDue(uint256 positionID, uint256 taxFloorDuration) public view returns (uint256 amountDue) { StakingPosition storage pos = positions[positionID]; // 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, Math.Rounding.Down); amountDue = assetsBefore * pos.taxRate * elapsedTime / (365 * 24 * 60 * 60) / TAX_RATE_BASE; } 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, Math.Rounding.Down); uint256 taxAmountDue = assetsBefore * 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(tokenContract, taxPool, taxAmountDue); emit TaxPaid(positionID, pos.owner, taxAmountDue); if (assetsBefore - taxAmountDue > 0) { // if something left over, update storage pos.share = assetsToShares(assetsBefore - taxAmountDue, Math.Rounding.Down); pos.lastTaxTime = uint32(block.timestamp); } else { // if nothing left over, liquidate position outstandingStake -= pos.share; emit PositionRemoved(positionID, pos.share, pos.lastTaxTime); delete pos.owner; delete pos.creationTime; } } function _exitPosition(uint256 positionId, StakingPosition storage pos) private { outstandingStake -= pos.share; address owner = pos.owner; uint256 assets = sharesToAssets(pos.share, Math.Rounding.Down); emit PositionRemoved(positionId, pos.share, pos.lastTaxTime); delete pos.owner; delete pos.creationTime; SafeERC20.safeTransfer(tokenContract, owner, assets); } function _shrinkPosition(uint256 positionId, StakingPosition storage pos, uint256 sharesToTake) private { require (sharesToTake > pos.share, "position too small"); pos.share -= sharesToTake; uint256 assets = sharesToAssets(sharesToTake, Math.Rounding.Down); emit PositionShrunk(positionId, pos.share, pos.lastTaxTime, sharesToTake); SafeERC20.safeTransfer(tokenContract, pos.owner, assets); } }