harb/onchain/src/Stake.sol

381 lines
19 KiB
Solidity
Raw Normal View History

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
import { Kraiken } from "./Kraiken.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/ERC20.sol";
import { ERC20Permit } from "@openzeppelin/token/ERC20/extensions/ERC20Permit.sol";
import { IERC20Metadata } from "@openzeppelin/token/ERC20/extensions/IERC20Metadata.sol";
import { SafeERC20 } from "@openzeppelin/token/ERC20/utils/SafeERC20.sol";
import { Math } from "@openzeppelin/utils/math/Math.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 Kraiken Token
* @notice This contract manages the staking positions for the Kraiken token, allowing users to stake tokens
2024-07-13 18:33:47 +02:00
* 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.
2025-07-08 10:33:10 +02:00
*
2024-07-13 18:33:47 +02:00
* 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.
2025-07-08 10:33:10 +02:00
*
2024-07-13 18:33:47 +02:00
* Tax rates and staking positions are adjustable, with a mechanism to prevent snatch-grieving by
* enforcing a minimum tax payment duration.
*
* @dev Self-assessed tax implementation:
2025-08-18 00:16:09 +02:00
* - Continuous auction mechanism
* - Self-assessed valuations create prediction market
* - Tax collection and redistribution through UBI
2024-07-13 18:33:47 +02:00
*/
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;
// only 20% of the total KRAIKEN supply can be staked.
uint256 internal constant MAX_STAKE = 20; // 20% of KRAIKEN 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
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
event PositionCreated(uint256 indexed positionId, address indexed owner, uint256 kraikenDeposit, uint256 share, uint32 taxRate);
event PositionTaxPaid(uint256 indexed positionId, address indexed owner, uint256 taxPaid, uint256 newShares, uint256 taxRate);
2024-06-23 08:44:54 +02:00
event PositionRateHiked(uint256 indexed positionId, address indexed owner, uint256 newTaxRate);
event PositionShrunk(uint256 indexed positionId, address indexed owner, uint256 newShares, uint256 kraikenPayout);
event PositionRemoved(uint256 indexed positionId, address indexed owner, uint256 kraikenPayout);
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;
uint32 taxRate; // index of TAX_RATES array
2024-02-21 22:20:04 +01:00
}
Kraiken private immutable kraiken;
2025-01-23 13:21:49 +01:00
address private immutable taxReceiver;
2024-07-17 14:08:53 +02:00
uint256 public immutable totalSupply;
2024-02-21 22:20:04 +01:00
uint256 public outstandingStake;
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
// Array to keep track of total shares at each tax rate
uint256[] public totalSharesAtTaxRate;
/// @notice Initializes the stake contract with references to the Kraiken contract and sets the initial position ID.
/// @param _kraiken Address of the Kraiken contract which this Stake contract interacts with.
/// @dev Sets up the total supply based on the decimals of the Kraiken token plus a fixed offset.
constructor(address _kraiken, address _taxReceiver) {
kraiken = Kraiken(_kraiken);
2025-01-23 13:21:49 +01:00
taxReceiver = _taxReceiver;
totalSupply = 10 ** (kraiken.decimals() + DECIMAL_OFFSET);
2024-07-17 14:08:53 +02:00
// start counting somewhere
nextPositionId = 654_321;
// Initialize totalSharesAtTaxRate array
totalSharesAtTaxRate = new uint256[](TAX_RATES.length);
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 {
2024-09-17 16:16:41 +02:00
// existance of position should be checked before
2024-07-16 20:59:42 +02:00
// ihet = Implied Holding Expiry Timestamp
uint256 ihet = (block.timestamp - pos.creationTime < taxFloorDuration) ? pos.creationTime + taxFloorDuration : block.timestamp;
2024-07-16 20:59:42 +02:00
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;
2024-07-16 20:59:42 +02:00
if (taxAmountDue >= assetsBefore) {
// can not pay more tax than value of position
taxAmountDue = assetsBefore;
}
if (assetsBefore - taxAmountDue > 0) {
// if something left over, update storage
uint256 shareAfterTax = assetsToShares(assetsBefore - taxAmountDue);
uint256 deltaShare = pos.share - shareAfterTax;
totalSharesAtTaxRate[pos.taxRate] -= deltaShare;
outstandingStake -= deltaShare;
2024-07-16 20:59:42 +02:00
pos.share = shareAfterTax;
pos.lastTaxTime = uint32(block.timestamp);
emit PositionTaxPaid(positionId, pos.owner, taxAmountDue, shareAfterTax, pos.taxRate);
} else {
// if nothing left over, liquidate position
totalSharesAtTaxRate[pos.taxRate] -= pos.share;
2024-07-16 20:59:42 +02:00
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;
}
SafeERC20.safeTransfer(kraiken, taxReceiver, taxAmountDue);
2024-07-16 20:59:42 +02:00
}
/// @dev Internal function to close a staking position, transferring the remaining Kraiken tokens back to the owner after tax payment.
2024-07-16 20:59:42 +02:00
function _exitPosition(uint256 positionId, StakingPosition storage pos) private {
totalSharesAtTaxRate[pos.taxRate] -= pos.share;
2024-07-16 20:59:42 +02:00
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(kraiken, owner, assets);
2024-07-16 20:59:42 +02:00
}
/// @dev Internal function to reduce the size of a staking position by a specified number of shares, transferring the corresponding Kraiken tokens to the owner.
2024-07-16 20:59:42 +02:00
function _shrinkPosition(uint256 positionId, StakingPosition storage pos, uint256 sharesToTake) private {
2025-07-08 10:33:10 +02:00
require(sharesToTake < pos.share, "position too small");
2024-07-16 20:59:42 +02:00
uint256 assets = sharesToAssets(sharesToTake);
pos.share -= sharesToTake;
totalSharesAtTaxRate[pos.taxRate] -= sharesToTake;
2024-07-16 20:59:42 +02:00
outstandingStake -= sharesToTake;
emit PositionShrunk(positionId, pos.owner, pos.share, assets);
SafeERC20.safeTransfer(kraiken, pos.owner, assets);
2024-07-16 20:59:42 +02:00
}
/// @notice Converts Kraiken token assets to shares of the total staking pool.
/// @param assets Number of Kraiken tokens to convert.
/// @return Number of shares corresponding to the input assets based on the current total supply of Kraiken tokens.
2024-06-19 10:33:28 +02:00
function assetsToShares(uint256 assets) public view returns (uint256) {
return assets.mulDiv(totalSupply, kraiken.totalSupply(), Math.Rounding.Down);
2024-02-21 22:20:04 +01:00
}
/// @notice Converts shares of the total staking pool back to Kraiken token assets.
2024-07-13 18:33:47 +02:00
/// @param shares Number of shares to convert.
/// @return The equivalent number of Kraiken tokens for the given shares.
2024-06-19 10:33:28 +02:00
function sharesToAssets(uint256 shares) public view returns (uint256) {
return shares.mulDiv(kraiken.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 Kraiken tokens to convert into a staking position.
2024-07-13 18:33:47 +02:00
/// @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) {
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
uint256 minStake = kraiken.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-09-17 16:16:41 +02:00
_payTax(positionsToSnatch[index], lastPos, TAX_FLOOR_DURATION);
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
SafeERC20.safeTransferFrom(kraiken, msg.sender, address(this), assets);
2024-02-21 22:20:04 +01:00
// mint
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
totalSharesAtTaxRate[taxRate] += sharesWanted;
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 Kraiken tokens to stake.
2024-07-16 20:59:42 +02:00
/// @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(kraiken)).permit(receiver, address(this), assets, deadline, v, r, s);
2024-07-16 20:59:42 +02:00
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-09-17 16:16:41 +02:00
_payTax(positionId, pos, 0);
totalSharesAtTaxRate[pos.taxRate] -= pos.share;
totalSharesAtTaxRate[taxRate] += pos.share;
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 {
StakingPosition storage pos = positions[positionId];
2024-09-17 16:16:41 +02:00
if (pos.creationTime == 0) {
revert PositionNotFound(positionId, msg.sender);
}
if (pos.owner != msg.sender) {
revert NoPermission(msg.sender, pos.owner);
}
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);
_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];
2024-09-17 16:16:41 +02:00
if (pos.creationTime == 0) {
revert PositionNotFound(positionId, msg.sender);
}
2024-06-19 10:33:28 +02:00
_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
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
/// @return averageTaxRate A number between 0 and 1e18 indicating the average tax rate.
function getAverageTaxRate() external view returns (uint256 averageTaxRate) {
// Compute average tax rate weighted by shares
averageTaxRate = 0;
2025-07-08 10:33:10 +02:00
if (outstandingStake > 0) {
for (uint256 i = 0; i < TAX_RATES.length; i++) {
averageTaxRate += TAX_RATES[i] * totalSharesAtTaxRate[i];
}
averageTaxRate = averageTaxRate / outstandingStake;
// normalize tax rate
averageTaxRate = averageTaxRate * 1e18 / TAX_RATES[TAX_RATES.length - 1];
}
}
/// @notice Computes the percentage of Kraiken staked from outstanding Stake and authorized Stake.
/// @return percentageStaked A number between 0 and 1e18 indicating the percentage of Kraiken supply staked.
function getPercentageStaked() external view returns (uint256 percentageStaked) {
percentageStaked = (outstandingStake * 1e18) / authorizedStake();
}
2024-02-21 22:20:04 +01:00
}