// 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); 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 PositionRemoved(uint256 indexed positionId, uint256 share, uint32 lastTaxTime); 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 // run through all suggested positions to snatch for (uint256 i = 0; i < positionsToSnatch.length; 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); } // dissolve position // TODO: what if someone calls payTax and exitPosition in the same transaction? _payTax(pos, 0); _exitPosition(positionsToSnatch[i], pos); // TODO: exit positions partially, if needed // TODO: avoid greeving where more positions are freed than needed. } // try to make a new position in the free space and hope it is big enough uint256 availableStake = authorizedStake() - outstandingStake; if (sharesWanted > availableStake) { revert ExceededAvailableStake(receiver, sharesWanted, availableStake); } // 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 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(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(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(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); 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 // TODO: emit event outstandingStake -= pos.share; 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); } }