feature/simulations (#11)
this pull request: - creates a unit test that can take any scenario file (default: `out/scenario.json` and play it back on the deployment - during the playback a debug trace generated in `timeSeries.csv` - extracts the sentimenter into a separate upgradeable contract Co-authored-by: JulesCrown <admin@noip.localhost> Co-authored-by: giteadmin <gite@admin.com> Reviewed-on: http://gitea.loseyourip.com:4000/dark-meme-society/harb/pulls/11
This commit is contained in:
parent
38e1b65b94
commit
bb34d0725f
15 changed files with 1159 additions and 451 deletions
5
onchain/.gitignore
vendored
5
onchain/.gitignore
vendored
|
|
@ -13,5 +13,8 @@ docs/
|
|||
# Dotenv file
|
||||
.env
|
||||
.secret
|
||||
.swp
|
||||
|
||||
/broadcast/
|
||||
/broadcast/
|
||||
|
||||
tags
|
||||
|
|
|
|||
|
|
@ -110,6 +110,10 @@ address: 0xCc7467616bBDB574D04C7e9d2B0982c59F33D43c
|
|||
|
||||
## Deployment on Base Sepolia
|
||||
|
||||
### Multisig
|
||||
|
||||
address: 0xf6a3eef9088A255c32b6aD2025f83E57291D9011
|
||||
|
||||
### Harberg
|
||||
|
||||
address: 0x54838DC097E7fC4736B801bF1c1FCf1597348265
|
||||
|
|
@ -130,3 +134,40 @@ open features:
|
|||
|
||||
todos:
|
||||
- write unit test for capital exit function
|
||||
- would anchorLiquidityShare affect capitalInefficiency?
|
||||
- could the UBI pool run dry?
|
||||
- what happens if discovery runs out?
|
||||
|
||||
## Simulation data:
|
||||
|
||||
```json
|
||||
{ "VWAP":0,
|
||||
"comEthBal":1234,
|
||||
"comHarbBal":0,
|
||||
"comStakeShare":0,
|
||||
"liquidity":[{
|
||||
"liquidity":1234,
|
||||
"tickLower":-123,
|
||||
"tickUpper":124
|
||||
}],
|
||||
"startTime":1234,
|
||||
"txns":[{
|
||||
"action":5,"timeOffset":0,"x":0,"y":""},
|
||||
{"action":0,"ethAmount":1,"x":0,"y":""},
|
||||
{"action":5,"timeOffset":0,"x":0,"y":""},
|
||||
{"action":0,"ethAmount":2,"x":0,"y":""},
|
||||
{"action":5,"timeOffset":0,"x":0,"y":""},
|
||||
{"action":0,"ethAmount":4,"x":0,"y":""},
|
||||
{"action":2,"harbAmount":3000,"tax":10,"y":""},
|
||||
{"action":5,"timeOffset":0,"x":0,"y":""},
|
||||
{"action":4,"positionId":654321,"x":0,"y":""},
|
||||
{"action":2,"harbAmount":5000,"tax":20,"y":""},
|
||||
{"action":0,"ethAmount":8,"x":0,"y":""},
|
||||
{"action":5,"timeOffset":0,"x":0,"y":""},
|
||||
{"action":1,"harbAmount":20000,"x":0,"y":""},
|
||||
{"action":5,"timeOffset":0,"x":0,"y":""},
|
||||
{"action":4,"positionId":654321,"x":0,"y":""},
|
||||
{"action":2,"harbAmount":8000,"tax":29,"y":""}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -6,57 +6,27 @@ import "@uniswap-v3-core/interfaces/IUniswapV3Factory.sol";
|
|||
import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
|
||||
import "../src/Harberg.sol";
|
||||
import "../src/Stake.sol";
|
||||
import "../src/Sentimenter.sol";
|
||||
import "../src/helpers/UniswapHelpers.sol";
|
||||
import {LiquidityManager} from "../src/LiquidityManager.sol";
|
||||
import {ERC1967Proxy} from "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol";
|
||||
|
||||
uint24 constant FEE = uint24(10_000);
|
||||
|
||||
contract DeployScript is Script {
|
||||
using UniswapHelpers for IUniswapV3Pool;
|
||||
|
||||
bool token0isWeth;
|
||||
address feeDest;
|
||||
address weth;
|
||||
address v3Factory;
|
||||
address twabc;
|
||||
|
||||
|
||||
function sqrt(uint256 y) internal pure returns (uint256 z) {
|
||||
if (y > 3) {
|
||||
z = y;
|
||||
uint256 x = y / 2 + 1;
|
||||
while (x < z) {
|
||||
z = x;
|
||||
x = (y / x + x) / 2;
|
||||
}
|
||||
} else if (y != 0) {
|
||||
z = 1;
|
||||
}
|
||||
// z is now the integer square root of y, or the closest integer to the square root of y.
|
||||
}
|
||||
|
||||
function initializePoolFor1Cent(address _pool) public {
|
||||
uint256 price;
|
||||
if (token0isWeth) {
|
||||
// ETH as token0, so we are setting the price of 1 ETH in terms of token1 (USD cent)
|
||||
price = 3000 * 10**20; // 1 ETH = 3700 USD, scaled by 10^18 for precision
|
||||
} else {
|
||||
// Token (valued at 1 USD cent) as token0, ETH as token1
|
||||
// We invert the logic to represent the price of 1 token in terms of ETH
|
||||
price = uint256(10**16) / 3700; // Adjust for 18 decimal places
|
||||
}
|
||||
|
||||
uint160 sqrtPriceX96 = uint160(sqrt(price) * 2**96 / 10**9); // Adjust sqrt value to 96-bit precision
|
||||
|
||||
console.log(uint160(sqrt(3000 * 10**20) * 2**96 / 10**9));
|
||||
|
||||
// Initialize pool with the calculated sqrtPriceX96
|
||||
IUniswapV3Pool(_pool).initialize(sqrtPriceX96);
|
||||
}
|
||||
|
||||
function run() public {
|
||||
string memory seedPhrase = vm.readFile(".secret");
|
||||
uint256 privateKey = vm.deriveKey(seedPhrase, 0);
|
||||
vm.startBroadcast(privateKey);
|
||||
address sender = vm.addr(privateKey);
|
||||
console.log(sender);
|
||||
|
||||
TwabController tc;
|
||||
if (twabc == address(0)) {
|
||||
|
|
@ -71,9 +41,12 @@ contract DeployScript is Script {
|
|||
harb.setStakingPool(address(stake));
|
||||
IUniswapV3Factory factory = IUniswapV3Factory(v3Factory);
|
||||
address liquidityPool = factory.createPool(weth, address(harb), FEE);
|
||||
initializePoolFor1Cent(liquidityPool);
|
||||
IUniswapV3Pool(liquidityPool).initializePoolFor1Cent(token0isWeth);
|
||||
harb.setLiquidityPool(liquidityPool);
|
||||
LiquidityManager liquidityManager = new LiquidityManager(v3Factory, weth, address(harb));
|
||||
Sentimenter sentimenter = new Sentimenter();
|
||||
bytes memory params = abi.encodeWithSignature("initialize(address,address)", address(harb),address(stake));
|
||||
ERC1967Proxy proxy = new ERC1967Proxy(address(sentimenter), params);
|
||||
LiquidityManager liquidityManager = new LiquidityManager(v3Factory, weth, address(harb), address(proxy));
|
||||
liquidityManager.setFeeDestination(feeDest);
|
||||
// note: this delayed initialization is not a security issue.
|
||||
harb.setLiquidityManager(address(liquidityManager));
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {Math} from "@openzeppelin/utils/math/Math.sol";
|
|||
import {ABDKMath64x64} from "@abdk/ABDKMath64x64.sol";
|
||||
import "./interfaces/IWETH9.sol";
|
||||
import {Harberg} from "./Harberg.sol";
|
||||
|
||||
import {Sentimenter} from "./Sentimenter.sol";
|
||||
|
||||
/**
|
||||
* @title LiquidityManager for Harberg Token on Uniswap V3
|
||||
|
|
@ -42,14 +42,6 @@ contract LiquidityManager {
|
|||
uint128 internal constant DISCOVERY_DEPTH = 200; // 500 // 500%
|
||||
// only working with UNI V3 1% fee tier pools
|
||||
uint24 internal constant FEE = uint24(10_000);
|
||||
// ANCHOR_LIQ_SHARE is the mininum share of total ETH in control
|
||||
// that will be left to put into anchor positon.
|
||||
uint256 internal constant MIN_ANCHOR_LIQ_SHARE = 5; // 5 = 5%
|
||||
uint256 internal constant MAX_ANCHOR_LIQ_SHARE = 25;
|
||||
// virtual liabilities that are added to push the calculated floor price down artificially,
|
||||
// creating a security margin for attacks on liquidity
|
||||
uint256 internal constant MIN_CAPITAL_INEFFICIENCY = 70;
|
||||
uint256 internal constant MAX_CAPITAL_INEFFICIENCY = 200;
|
||||
// used to double-check price with uni oracle
|
||||
uint32 internal constant PRICE_STABILITY_INTERVAL = 300; // 5 minutes in seconds
|
||||
int24 internal constant MAX_TICK_DEVIATION = 50; // how much is that?
|
||||
|
|
@ -58,6 +50,7 @@ contract LiquidityManager {
|
|||
address private immutable factory;
|
||||
IWETH9 private immutable weth;
|
||||
Harberg private immutable harb;
|
||||
Sentimenter private immutable sentimenter;
|
||||
IUniswapV3Pool private immutable pool;
|
||||
bool private immutable token0isWeth;
|
||||
PoolKey private poolKey;
|
||||
|
|
@ -76,16 +69,12 @@ contract LiquidityManager {
|
|||
mapping(Stage => TokenPosition) public positions;
|
||||
// the address where liquidity fees will be sent
|
||||
address public feeDestination;
|
||||
// the minimum share of ETH that will be put into the anchor
|
||||
uint256 public anchorLiquidityShare;
|
||||
// the higher the inefficiency, the more conservative the positioning of floor
|
||||
uint256 public capitalInefficiency;
|
||||
|
||||
error ZeroAddressInSetter();
|
||||
error AddressAlreadySet();
|
||||
|
||||
event EthScarcity(int24 currentTick, uint256 ethBalance, uint256 outstandingSupply, uint256 vwap, uint256 capitalInefficiency, uint256 anchorLiquidityShare, int24 vwapTick);
|
||||
event EthAbundance(int24 currentTick, uint256 ethBalance, uint256 outstandingSupply, uint256 vwap, uint256 capitalInefficiency, uint256 anchorLiquidityShare, int24 vwapTick);
|
||||
event EthScarcity(int24 currentTick, uint256 ethBalance, uint256 outstandingSupply, uint256 vwap, uint256 sentiment, int24 vwapTick);
|
||||
event EthAbundance(int24 currentTick, uint256 ethBalance, uint256 outstandingSupply, uint256 vwap, uint256 sentiment, int24 vwapTick);
|
||||
|
||||
/// @dev Function modifier to ensure that the caller is the feeDestination
|
||||
modifier onlyFeeDestination() {
|
||||
|
|
@ -98,15 +87,14 @@ contract LiquidityManager {
|
|||
/// @param _WETH9 The address of the WETH contract for handling ETH in trades.
|
||||
/// @param _harb The address of the Harberg token contract.
|
||||
/// @dev Computes the Uniswap pool address for the Harberg-WETH pair and sets up the initial configuration for the liquidity manager.
|
||||
constructor(address _factory, address _WETH9, address _harb) {
|
||||
constructor(address _factory, address _WETH9, address _harb, address _sentimenter) {
|
||||
factory = _factory;
|
||||
weth = IWETH9(_WETH9);
|
||||
poolKey = PoolAddress.getPoolKey(_WETH9, _harb, FEE);
|
||||
pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));
|
||||
harb = Harberg(_harb);
|
||||
token0isWeth = _WETH9 < _harb;
|
||||
anchorLiquidityShare = MAX_ANCHOR_LIQ_SHARE;
|
||||
capitalInefficiency = MIN_CAPITAL_INEFFICIENCY; // starting at 95% fuzzer passes tests
|
||||
sentimenter = Sentimenter(_sentimenter);
|
||||
}
|
||||
|
||||
/// @notice Callback function that Uniswap V3 calls for liquidity actions requiring minting or burning of tokens.
|
||||
|
|
@ -137,18 +125,6 @@ contract LiquidityManager {
|
|||
feeDestination = feeDestination_;
|
||||
}
|
||||
|
||||
function setAnchorLiquidityShare(uint256 anchorLiquidityShare_) external onlyFeeDestination {
|
||||
require(anchorLiquidityShare_ >= MIN_ANCHOR_LIQ_SHARE, "anchor liquidity share too low.");
|
||||
require(anchorLiquidityShare_ <= MAX_ANCHOR_LIQ_SHARE, "anchor liquidity share too high.");
|
||||
anchorLiquidityShare = anchorLiquidityShare_;
|
||||
}
|
||||
|
||||
function setCapitalInfefficiency(uint256 capitalInefficiency_) external onlyFeeDestination {
|
||||
require(capitalInefficiency_ >= MIN_CAPITAL_INEFFICIENCY, "capital inefficiency is too low.");
|
||||
require(capitalInefficiency_ <= MAX_CAPITAL_INEFFICIENCY, "capital inefficiency is too high.");
|
||||
capitalInefficiency = capitalInefficiency_;
|
||||
}
|
||||
|
||||
function setMinStakeSupplyFraction(uint256 mssf_) external onlyFeeDestination {
|
||||
harb.setMinStakeSupplyFraction(mssf_);
|
||||
}
|
||||
|
|
@ -231,15 +207,18 @@ contract LiquidityManager {
|
|||
/// @notice Internal function to set or adjust the floor, anchor, and discovery positions based on current market conditions and the manager's strategy.
|
||||
/// @param currentTick The current market tick.
|
||||
/// @dev Recalculates and realigns all liquidity positions according to the latest market data and strategic requirements.
|
||||
function _set(int24 currentTick) internal {
|
||||
function _set(int24 currentTick, uint256 sentiment) internal {
|
||||
|
||||
// estimate the lower tick of the anchor
|
||||
int24 vwapTick;
|
||||
uint256 outstandingSupply = harb.outstandingSupply();
|
||||
uint256 ethBalance = (address(this).balance + weth.balanceOf(address(this)));
|
||||
uint256 floorEthBalance = ethBalance * (100 - anchorLiquidityShare) / 100;
|
||||
// this enforces an floor liquidity share of 75% to 95 %;
|
||||
uint256 floorEthBalance = (3 * ethBalance / 4) + (2 * sentiment * ethBalance / 10**19);
|
||||
if (outstandingSupply > 0) {
|
||||
vwapTick = tickAtPrice(token0isWeth, outstandingSupply * capitalInefficiency / 100 , floorEthBalance);
|
||||
// this enables a "capital inefficiency" of 70% to 170%;
|
||||
uint256 balancedCapital = (7 * outstandingSupply / 10) + (outstandingSupply * sentiment / 10**18);
|
||||
vwapTick = tickAtPrice(token0isWeth, balancedCapital, floorEthBalance);
|
||||
} else {
|
||||
vwapTick = token0isWeth ? currentTick + ANCHOR_SPACING : currentTick - ANCHOR_SPACING;
|
||||
}
|
||||
|
|
@ -311,15 +290,17 @@ contract LiquidityManager {
|
|||
uint256 vwapX96 = 0;
|
||||
uint256 requiredEthForBuyback = 0;
|
||||
if (cumulativeVolume > 0) {
|
||||
vwapX96 = cumulativeVolumeWeightedPriceX96 * capitalInefficiency / 100 / cumulativeVolume; // in harb/eth
|
||||
vwapX96 = cumulativeVolumeWeightedPriceX96 / cumulativeVolume; // in harb/eth
|
||||
vwapX96 = (7 * vwapX96 / 10) + (vwapX96 * sentiment / 10**18);
|
||||
requiredEthForBuyback = outstandingSupply.mulDiv(vwapX96, (1 << 96));
|
||||
}
|
||||
// make a new calculation of the vwapTick, having updated outstandingSupply
|
||||
if (floorEthBalance < requiredEthForBuyback) {
|
||||
// not enough ETH, find a lower price
|
||||
requiredEthForBuyback = floorEthBalance;
|
||||
vwapTick = tickAtPrice(token0isWeth, outstandingSupply * capitalInefficiency / 100 , requiredEthForBuyback);
|
||||
emit EthScarcity(currentTick, ethBalance, outstandingSupply, vwapX96, capitalInefficiency, anchorLiquidityShare, vwapTick);
|
||||
uint256 balancedCapital = (7 * outstandingSupply / 10) + (outstandingSupply * sentiment / 10**18);
|
||||
vwapTick = tickAtPrice(token0isWeth, balancedCapital , requiredEthForBuyback);
|
||||
emit EthScarcity(currentTick, ethBalance, outstandingSupply, vwapX96, sentiment, vwapTick);
|
||||
} else if (vwapX96 == 0) {
|
||||
requiredEthForBuyback = floorEthBalance;
|
||||
vwapTick = currentTick;
|
||||
|
|
@ -328,7 +309,7 @@ contract LiquidityManager {
|
|||
vwapTick = tickAtPriceRatio(int128(int256(vwapX96 >> 32)));
|
||||
// convert to pool tick
|
||||
vwapTick = token0isWeth ? -vwapTick : vwapTick;
|
||||
emit EthAbundance(currentTick, ethBalance, outstandingSupply, vwapX96, capitalInefficiency, anchorLiquidityShare, vwapTick);
|
||||
emit EthAbundance(currentTick, ethBalance, outstandingSupply, vwapX96, sentiment, vwapTick);
|
||||
}
|
||||
// move floor below anchor, if needed
|
||||
if (token0isWeth) {
|
||||
|
|
@ -451,7 +432,7 @@ contract LiquidityManager {
|
|||
|
||||
/// @notice Adjusts liquidity positions in response to an increase or decrease in the Harberg token's price.
|
||||
/// @dev This function should be called when significant price movement is detected. It recalibrates the liquidity ranges to align with the new market conditions.
|
||||
function recenter() external {
|
||||
function recenter() external returns (bool isUp, uint256 sentiment) {
|
||||
if (recenterAccess != address(0)) {
|
||||
require(msg.sender == recenterAccess, "access denied");
|
||||
}
|
||||
|
|
@ -460,7 +441,7 @@ contract LiquidityManager {
|
|||
// check slippage with oracle
|
||||
require(_isPriceStable(currentTick), "price deviated from oracle");
|
||||
|
||||
bool isUp = false;
|
||||
isUp = false;
|
||||
// check how price moved
|
||||
if (positions[Stage.ANCHOR].liquidity > 0) {
|
||||
// get the anchor position
|
||||
|
|
@ -484,8 +465,16 @@ contract LiquidityManager {
|
|||
if (isUp) {
|
||||
harb.setPreviousTotalSupply(harb.totalSupply());
|
||||
}
|
||||
|
||||
try sentimenter.getSentiment() returns (uint256 currentSentiment) {
|
||||
sentiment = (currentSentiment > 10**18) ? 10**18 : currentSentiment;
|
||||
} catch {
|
||||
//sentiment = 10**18 / 2;
|
||||
sentiment = 0;
|
||||
}
|
||||
|
||||
// set new positions
|
||||
_set(currentTick);
|
||||
_set(currentTick, sentiment);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
79
onchain/src/Sentimenter.sol
Normal file
79
onchain/src/Sentimenter.sol
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import {Harberg} from "./Harberg.sol";
|
||||
import {Stake} from "./Stake.sol";
|
||||
import {UUPSUpgradeable} from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol";
|
||||
import {Initializable} from "@openzeppelin/proxy/utils/Initializable.sol";
|
||||
|
||||
contract Sentimenter is Initializable, UUPSUpgradeable {
|
||||
|
||||
Harberg private harberg;
|
||||
Stake private stake;
|
||||
|
||||
/**
|
||||
* @dev The caller account is not authorized to perform an operation.
|
||||
*/
|
||||
error UnauthorizedAccount(address account);
|
||||
|
||||
function initialize(address _harberg, address _stake) initializer public {
|
||||
_changeAdmin(msg.sender);
|
||||
harberg = Harberg(_harberg);
|
||||
stake = Stake(_stake);
|
||||
}
|
||||
/**
|
||||
* @dev Throws if called by any account other than the admin.
|
||||
*/
|
||||
modifier onlyAdmin() {
|
||||
_checkAdmin();
|
||||
_;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @dev Throws if the sender is not the admin.
|
||||
*/
|
||||
function _checkAdmin() internal view virtual {
|
||||
if (_getAdmin() != msg.sender) {
|
||||
revert UnauthorizedAccount(msg.sender);
|
||||
}
|
||||
}
|
||||
function _authorizeUpgrade(address newImplementation) internal override onlyAdmin {}
|
||||
|
||||
function calculateSentiment(uint256 averageTaxRate, uint256 percentageStaked) public pure returns (uint256 sentimentValue) {
|
||||
uint256 deltaS = 10**18 - percentageStaked;
|
||||
|
||||
if (percentageStaked > 92 * 10**16) {
|
||||
// Rapid drop for high percentageStaked values
|
||||
uint256 penalty = (deltaS * deltaS * deltaS * averageTaxRate) / (20 * 10**48);
|
||||
sentimentValue = penalty / 2;
|
||||
} else {
|
||||
// Linearly decreasing sentiment value with rising percentageStaked
|
||||
uint256 baseSentiment = 10**18 - (percentageStaked * 10**18) / (92 * 10**16); // Decreases from 10**18 to 0
|
||||
|
||||
// Apply penalty based on averageTaxRate
|
||||
if (averageTaxRate <= 10**16) {
|
||||
sentimentValue = baseSentiment; // No penalty for low averageTaxRate
|
||||
} else if (averageTaxRate <= 5 * 10**16) {
|
||||
uint256 ratePenalty = (averageTaxRate - 10**16) * baseSentiment / (4 * 10**16);
|
||||
sentimentValue = baseSentiment > ratePenalty ? baseSentiment - ratePenalty : 0;
|
||||
} else {
|
||||
sentimentValue = 10**18; // High averageTaxRate results in maximum sentiment value (low sentiment)
|
||||
}
|
||||
}
|
||||
|
||||
return sentimentValue;
|
||||
}
|
||||
|
||||
|
||||
/// @notice Computes the staker sentiment based on the proportion of the authorized stake that is currently staked.
|
||||
/// @return sentiment A number between 0 and 200 indicating the market sentiment.
|
||||
function getSentiment() external returns (uint256 sentiment) {
|
||||
uint256 percentageStaked = stake.getPercentageStaked();
|
||||
uint256 averageTaxRate = stake.getAverageTaxRate();
|
||||
sentiment = calculateSentiment(averageTaxRate, percentageStaked);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -60,7 +60,7 @@ contract Stake {
|
|||
address owner;
|
||||
uint32 creationTime;
|
||||
uint32 lastTaxTime;
|
||||
uint32 taxRate; // e.g. value of 60 = 60% tax per year
|
||||
uint32 taxRate; // index of TAX_RATES array
|
||||
}
|
||||
|
||||
Harberg private immutable harberg;
|
||||
|
|
@ -72,6 +72,9 @@ contract Stake {
|
|||
|
||||
mapping(uint256 => StakingPosition) public positions;
|
||||
|
||||
// Array to keep track of total shares at each tax rate
|
||||
uint256[] public totalSharesAtTaxRate;
|
||||
|
||||
/// @notice Initializes the stake contract with references to the Harberg contract and sets the initial position ID.
|
||||
/// @param _harberg Address of the Harberg contract which this Stake contract interacts with.
|
||||
/// @dev Sets up the total supply based on the decimals of the Harberg token plus a fixed offset.
|
||||
|
|
@ -81,6 +84,8 @@ contract Stake {
|
|||
taxPool = Harberg(_harberg).TAX_POOL();
|
||||
// start counting somewhere
|
||||
nextPositionId = 654321;
|
||||
// Initialize totalSharesAtTaxRate array
|
||||
totalSharesAtTaxRate = new uint256[](TAX_RATES.length);
|
||||
}
|
||||
|
||||
function authorizedStake() private view returns (uint256) {
|
||||
|
|
@ -104,12 +109,15 @@ contract Stake {
|
|||
if (assetsBefore - taxAmountDue > 0) {
|
||||
// if something left over, update storage
|
||||
uint256 shareAfterTax = assetsToShares(assetsBefore - taxAmountDue);
|
||||
outstandingStake -= pos.share - shareAfterTax;
|
||||
uint256 deltaShare = pos.share - shareAfterTax;
|
||||
totalSharesAtTaxRate[pos.taxRate] -= deltaShare;
|
||||
outstandingStake -= deltaShare;
|
||||
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;
|
||||
outstandingStake -= pos.share;
|
||||
emit PositionTaxPaid(positionId, pos.owner, taxAmountDue, 0, pos.taxRate);
|
||||
emit PositionRemoved(positionId, pos.owner, 0);
|
||||
|
|
@ -122,6 +130,7 @@ contract Stake {
|
|||
|
||||
/// @dev Internal function to close a staking position, transferring the remaining Harberg tokens back to the owner after tax payment.
|
||||
function _exitPosition(uint256 positionId, StakingPosition storage pos) private {
|
||||
totalSharesAtTaxRate[pos.taxRate] -= pos.share;
|
||||
outstandingStake -= pos.share;
|
||||
address owner = pos.owner;
|
||||
uint256 assets = sharesToAssets(pos.share);
|
||||
|
|
@ -137,6 +146,7 @@ contract Stake {
|
|||
require (sharesToTake < pos.share, "position too small");
|
||||
uint256 assets = sharesToAssets(sharesToTake);
|
||||
pos.share -= sharesToTake;
|
||||
totalSharesAtTaxRate[pos.taxRate] -= sharesToTake;
|
||||
outstandingStake -= sharesToTake;
|
||||
emit PositionShrunk(positionId, pos.owner, pos.share, assets);
|
||||
SafeERC20.safeTransfer(harberg, pos.owner, assets);
|
||||
|
|
@ -252,6 +262,7 @@ contract Stake {
|
|||
sp.creationTime = uint32(block.timestamp);
|
||||
sp.taxRate = taxRate;
|
||||
|
||||
totalSharesAtTaxRate[taxRate] += sharesWanted;
|
||||
outstandingStake += sharesWanted;
|
||||
emit PositionCreated(positionId, sp.owner, assets, sp.share, sp.taxRate);
|
||||
}
|
||||
|
|
@ -299,6 +310,8 @@ contract Stake {
|
|||
// to prevent snatch-and-change grieving attack, pay TAX_FLOOR_DURATION
|
||||
require(taxRate > pos.taxRate, "tax too low to snatch");
|
||||
_payTax(positionId, pos, 0);
|
||||
totalSharesAtTaxRate[pos.taxRate] -= pos.share;
|
||||
totalSharesAtTaxRate[taxRate] += pos.share;
|
||||
pos.taxRate = taxRate;
|
||||
emit PositionRateHiked(positionId, pos.owner, taxRate);
|
||||
}
|
||||
|
|
@ -345,5 +358,25 @@ contract Stake {
|
|||
amountDue = assetsBefore * TAX_RATES[pos.taxRate] * elapsedTime / (365 * 24 * 60 * 60) / TAX_RATE_BASE;
|
||||
}
|
||||
|
||||
/// @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;
|
||||
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 Harberg staked from outstanding Stake and authorized Stake.
|
||||
/// @return percentageStaked A number between 0 and 1e18 indicating the percentage of Harberg supply staked.
|
||||
function getPercentageStaked() external view returns (uint256 percentageStaked) {
|
||||
percentageStaked = (outstandingStake * 1e18) / authorizedStake();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
78
onchain/src/helpers/UniswapHelpers.sol
Normal file
78
onchain/src/helpers/UniswapHelpers.sol
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
128
onchain/test/Sentimenter.t.sol
Normal file
128
onchain/test/Sentimenter.t.sol
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import "forge-std/Test.sol";
|
||||
import "forge-std/console.sol";
|
||||
import {TwabController} from "pt-v5-twab-controller/TwabController.sol";
|
||||
import "../src/Harberg.sol";
|
||||
import {TooMuchSnatch, Stake} from "../src/Stake.sol";
|
||||
import "../src/Sentimenter.sol";
|
||||
import {ERC1967Proxy} from "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol";
|
||||
import {MockSentimenter} from "./mocks/MockSentimenter.sol";
|
||||
|
||||
contract SentimenterTest is Test {
|
||||
TwabController tc;
|
||||
Harberg harberg;
|
||||
Stake stake;
|
||||
Sentimenter sentimenter;
|
||||
address liquidityPool;
|
||||
address liquidityManager;
|
||||
address taxPool;
|
||||
|
||||
function setUp() public {
|
||||
tc = new TwabController(60 * 60, uint32(block.timestamp));
|
||||
harberg = new Harberg("HARB", "HARB", tc);
|
||||
taxPool = harberg.TAX_POOL();
|
||||
stake = new Stake(address(harberg));
|
||||
harberg.setStakingPool(address(stake));
|
||||
liquidityPool = makeAddr("liquidityPool");
|
||||
harberg.setLiquidityPool(liquidityPool);
|
||||
liquidityManager = makeAddr("liquidityManager");
|
||||
harberg.setLiquidityManager(liquidityManager);
|
||||
// deploy upgradeable tuner contract
|
||||
Sentimenter _sentimenter = new Sentimenter();
|
||||
bytes memory params = abi.encodeWithSignature("initialize(address,address)", address(harberg),address(stake));
|
||||
ERC1967Proxy proxy = new ERC1967Proxy(address(_sentimenter), params);
|
||||
sentimenter = Sentimenter(address(proxy));
|
||||
}
|
||||
|
||||
function doSnatch(address staker, uint256 amount, uint32 taxRate) private returns (uint256 positionId) {
|
||||
vm.startPrank(staker);
|
||||
harberg.approve(address(stake), amount);
|
||||
uint256[] memory empty;
|
||||
positionId = stake.snatch(amount, staker, taxRate, empty);
|
||||
vm.stopPrank();
|
||||
}
|
||||
|
||||
function testSentiment() public {
|
||||
uint256 smallstake = 0.3e17;
|
||||
uint256 stakeOneThird = 1 ether;
|
||||
uint256 stakeTwoThird = 2 ether;
|
||||
address staker = makeAddr("staker");
|
||||
|
||||
// Mint and distribute tokens
|
||||
vm.startPrank(liquidityManager);
|
||||
// mint all the tokens we will need in the test
|
||||
harberg.mint((smallstake + stakeOneThird + stakeTwoThird) * 5);
|
||||
// send 20% of that to staker
|
||||
harberg.transfer(staker, (smallstake + stakeOneThird + stakeTwoThird) * 2);
|
||||
vm.stopPrank();
|
||||
|
||||
// Setup initial stakers
|
||||
uint256 positionId1 = doSnatch(staker, smallstake, 0);
|
||||
|
||||
uint256 sentiment;
|
||||
sentiment = sentimenter.getSentiment();
|
||||
// 0.99 - horrible sentiment
|
||||
assertApproxEqRel(sentiment, 9.9e17, 1e16);
|
||||
|
||||
vm.prank(staker);
|
||||
stake.exitPosition(positionId1);
|
||||
uint256 positionId2 = doSnatch(staker, stakeOneThird, 2);
|
||||
|
||||
sentiment = sentimenter.getSentiment();
|
||||
|
||||
// 0.64 - depressive sentiment
|
||||
assertApproxEqRel(sentiment, 6.4e17, 1e16);
|
||||
|
||||
vm.prank(staker);
|
||||
stake.exitPosition(positionId2);
|
||||
positionId1 = doSnatch(staker, stakeOneThird, 10);
|
||||
positionId2 = doSnatch(staker, stakeTwoThird, 11);
|
||||
|
||||
sentiment = sentimenter.getSentiment();
|
||||
|
||||
// 0.00018 - feaking good sentiment
|
||||
assertApproxEqRel(sentiment, 1.8e14, 1e17);
|
||||
|
||||
vm.startPrank(staker);
|
||||
stake.exitPosition(positionId1);
|
||||
stake.exitPosition(positionId2);
|
||||
vm.stopPrank();
|
||||
positionId1 = doSnatch(staker, stakeOneThird, 29);
|
||||
positionId2 = doSnatch(staker, stakeTwoThird, 29);
|
||||
|
||||
sentiment = sentimenter.getSentiment();
|
||||
// 0.024 - pretty good sentiment
|
||||
assertApproxEqRel(sentiment, 2.4e16, 2e16);
|
||||
|
||||
vm.startPrank(staker);
|
||||
stake.exitPosition(positionId1);
|
||||
stake.exitPosition(positionId2);
|
||||
vm.stopPrank();
|
||||
positionId2 = doSnatch(staker, stakeTwoThird, 15);
|
||||
|
||||
sentiment = sentimenter.getSentiment();
|
||||
|
||||
// 0.17 - positive sentiment
|
||||
assertApproxEqRel(sentiment, 1.7e17, 2e16);
|
||||
|
||||
vm.startPrank(staker);
|
||||
stake.exitPosition(positionId2);
|
||||
vm.stopPrank();
|
||||
|
||||
positionId1 = doSnatch(staker, stakeOneThird, 15);
|
||||
|
||||
sentiment = sentimenter.getSentiment();
|
||||
|
||||
// 0.4 - OK sentiment
|
||||
assertApproxEqRel(sentiment, 3.9e17, 2e16);
|
||||
}
|
||||
|
||||
function testContractUpgrade() public {
|
||||
address newSent = address(new MockSentimenter());
|
||||
sentimenter.upgradeTo(newSent);
|
||||
uint256 sentiment = sentimenter.getSentiment();
|
||||
assertEq(sentiment, 1234567890123456789, "should have been upgraded");
|
||||
}
|
||||
}
|
||||
230
onchain/test/Simulations.t.sol
Normal file
230
onchain/test/Simulations.t.sol
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import "forge-std/Test.sol";
|
||||
import "@aperture/uni-v3-lib/TickMath.sol";
|
||||
import {LiquidityAmounts} from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
|
||||
import "../src/interfaces/IWETH9.sol";
|
||||
import {WETH} from "solmate/tokens/WETH.sol";
|
||||
import {TwabController} from "pt-v5-twab-controller/TwabController.sol";
|
||||
import {PoolAddress, PoolKey} from "@aperture/uni-v3-lib/PoolAddress.sol";
|
||||
import "@uniswap-v3-core/interfaces/IUniswapV3Factory.sol";
|
||||
import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
|
||||
import {Harberg} from "../src/Harberg.sol";
|
||||
import "../src/helpers/UniswapHelpers.sol";
|
||||
import {Stake, ExceededAvailableStake} from "../src/Stake.sol";
|
||||
import {LiquidityManager} from "../src/LiquidityManager.sol";
|
||||
import {UniswapTestBase} from "./helpers/UniswapTestBase.sol";
|
||||
import {CSVHelper} from "./helpers/CSVHelper.sol";
|
||||
import {CSVManager} from "./helpers/CSVManager.sol";
|
||||
import "../src/Sentimenter.sol";
|
||||
|
||||
address constant TAX_POOL = address(2);
|
||||
// default fee of 1%
|
||||
uint24 constant FEE = uint24(10_000);
|
||||
|
||||
contract SimulationsTest is UniswapTestBase, CSVManager {
|
||||
using UniswapHelpers for IUniswapV3Pool;
|
||||
using CSVHelper for *;
|
||||
|
||||
IUniswapV3Factory factory;
|
||||
Stake stakingPool;
|
||||
LiquidityManager lm;
|
||||
address feeDestination = makeAddr("fees");
|
||||
uint256 supplyOnRecenter;
|
||||
uint256 timeOnRecenter;
|
||||
int256 supplyChange;
|
||||
|
||||
struct Position {
|
||||
uint256 liquidity;
|
||||
int32 tickLower;
|
||||
int32 tickUpper;
|
||||
}
|
||||
|
||||
enum ActionType {
|
||||
Buy,
|
||||
Sell,
|
||||
Snatch,
|
||||
Unstake,
|
||||
PayTax,
|
||||
Recenter,
|
||||
Mint,
|
||||
Burn
|
||||
}
|
||||
|
||||
struct Action {
|
||||
uint256 kind; // buy, sell, snatch, unstake, paytax, recenter, mint, burn
|
||||
uint256 amount1; // x , x , x , x , x , x , ,
|
||||
uint256 amount2; // , , x , , , , ,
|
||||
string position; // , , x , , , , ,
|
||||
}
|
||||
|
||||
struct Scenario {
|
||||
uint256 VWAP;
|
||||
uint256 comEthBal;
|
||||
uint256 comHarbBal;
|
||||
uint256 comStakeShare;
|
||||
Position[] liquidity; // the positions are floor, anchor, liquidity, [comPos1, comPos2 ...]
|
||||
uint256 time;
|
||||
Action[] txns;
|
||||
}
|
||||
|
||||
function setUp() public {
|
||||
factory = UniswapHelpers.deployUniswapFactory();
|
||||
|
||||
weth = IWETH9(address(new WETH()));
|
||||
TwabController tc = new TwabController(60 * 60, uint32(block.timestamp));
|
||||
harberg = new Harberg("Harberg", "HRB", tc);
|
||||
pool = IUniswapV3Pool(factory.createPool(address(weth), address(harberg), FEE));
|
||||
|
||||
token0isWeth = address(weth) < address(harberg);
|
||||
pool.initializePoolFor1Cent(token0isWeth);
|
||||
|
||||
stakingPool = new Stake(address(harberg));
|
||||
harberg.setStakingPool(address(stakingPool));
|
||||
Sentimenter senti = new Sentimenter();
|
||||
senti.initialize(address(harberg), address(stakingPool));
|
||||
lm = new LiquidityManager(address(factory), address(weth), address(harberg), address(senti));
|
||||
lm.setFeeDestination(feeDestination);
|
||||
vm.prank(feeDestination);
|
||||
harberg.setLiquidityManager(address(lm));
|
||||
harberg.setLiquidityPool(address(pool));
|
||||
vm.deal(address(lm), 1 ether);
|
||||
timeOnRecenter = block.timestamp;
|
||||
initializeTimeSeriesCSV();
|
||||
}
|
||||
|
||||
function buy(uint256 amountEth) internal {
|
||||
performSwap(amountEth, true);
|
||||
}
|
||||
|
||||
function sell(uint256 amountHarb) internal {
|
||||
performSwap(amountHarb, false);
|
||||
}
|
||||
|
||||
receive() external payable {}
|
||||
|
||||
|
||||
function writeCsv() public {
|
||||
writeCSVToFile("./out/timeSeries.csv"); // Write CSV to file
|
||||
}
|
||||
|
||||
function recenter() internal {
|
||||
// have some time pass to record prices in uni oracle
|
||||
uint256 timeBefore = block.timestamp;
|
||||
vm.warp(timeBefore + 5 minutes);
|
||||
|
||||
// uint256
|
||||
// uint256 supplyDelta = currentSupply - supplyOnRecenter;
|
||||
// uint256 timeDelta = block.timestamp - timeOnRecenter;
|
||||
// uint256 growthPerYear = supplyDelta * 52 weeks / timeDelta;
|
||||
// // console.log("supplyOnLastRecenter");
|
||||
// // console.log(supplyOnRecenter);
|
||||
// // console.log("currentSupply");
|
||||
// // console.log(currentSupply);
|
||||
// uint256 growthPercentage = growthPerYear * 100 >= currentSupply ? 101 : growthPerYear * 100 / currentSupply;
|
||||
// supplyOnRecenter = currentSupply;
|
||||
|
||||
timeOnRecenter = block.timestamp;
|
||||
uint256 supplyBefore = harberg.totalSupply();
|
||||
lm.recenter();
|
||||
supplyChange = int256(harberg.totalSupply()) - int256(supplyBefore);
|
||||
// TODO: update supplyChangeOnLastRecenter
|
||||
|
||||
// have some time pass to record prices in uni oracle
|
||||
timeBefore = block.timestamp;
|
||||
vm.warp(timeBefore + 5 minutes);
|
||||
}
|
||||
|
||||
function getPriceInHarb(uint160 sqrtPriceX96) internal returns (uint256 price) {
|
||||
uint256 sqrtPrice = uint256(sqrtPriceX96);
|
||||
|
||||
if (token0isWeth) {
|
||||
// WETH is token0, price = (sqrtPriceX96 / 2^96)^2
|
||||
price = (sqrtPrice * sqrtPrice) / (1 << 192);
|
||||
} else {
|
||||
// WETH is token1, price = (2^96 / sqrtPriceX96)^2
|
||||
price = ((1 << 192) * 1e18) / (sqrtPrice * sqrtPrice);
|
||||
}
|
||||
}
|
||||
|
||||
function recordState() internal {
|
||||
|
||||
uint160 sqrtPriceX96;
|
||||
uint256 outstandingStake = stakingPool.outstandingStake();
|
||||
|
||||
(sqrtPriceX96, , , , , , ) = pool.slot0();
|
||||
|
||||
string memory newRow = string(abi.encodePacked(CSVHelper.uintToStr(block.timestamp),
|
||||
",", CSVHelper.uintToStr(getPriceInHarb(sqrtPriceX96)),
|
||||
",", CSVHelper.uintToStr(harberg.totalSupply() / 1e18),
|
||||
",", CSVHelper.intToStr(supplyChange / 1e18),
|
||||
",", CSVHelper.uintToStr(outstandingStake * 500 / 1e25)
|
||||
));
|
||||
if (outstandingStake > 0) {
|
||||
uint256 sentiment;
|
||||
uint256 avgTaxRate;
|
||||
//(sentiment, avgTaxRate) = stakingPool.getSentiment();
|
||||
newRow = string.concat(newRow,
|
||||
",", CSVHelper.uintToStr(avgTaxRate),
|
||||
",", CSVHelper.uintToStr(sentiment),
|
||||
",", CSVHelper.uintToStr(harberg.sumTaxCollected() / 1e18)
|
||||
);
|
||||
} else {
|
||||
newRow = string.concat(newRow, ", 0, 100, 95, 25, 0");
|
||||
}
|
||||
appendCSVRow(newRow); // Append the new row to the CSV
|
||||
}
|
||||
|
||||
function stake(uint256 harbAmount, uint32 taxRateIndex) internal returns (uint256) {
|
||||
vm.startPrank(account);
|
||||
harberg.approve(address(stakingPool), harbAmount);
|
||||
uint256[] memory empty;
|
||||
uint256 posId = stakingPool.snatch(harbAmount, account, taxRateIndex, empty);
|
||||
vm.stopPrank();
|
||||
return posId;
|
||||
}
|
||||
|
||||
function unstake(uint256 positionId) internal {
|
||||
vm.prank(account);
|
||||
stakingPool.exitPosition(positionId);
|
||||
}
|
||||
|
||||
function handleAction(Action memory action) internal {
|
||||
if (action.kind == uint256(ActionType.Buy)) {
|
||||
buy(action.amount1 * 10**18);
|
||||
} else if (action.kind == uint256(ActionType.Sell)) {
|
||||
sell(action.amount1 * 10**18);
|
||||
} else if (action.kind == uint256(ActionType.Snatch)) {
|
||||
stake(action.amount1 ** 10**18, uint32(action.amount2));
|
||||
} else if (action.kind == uint256(ActionType.Unstake)) {
|
||||
unstake(action.amount1);
|
||||
} else if (action.kind == uint256(ActionType.PayTax)) {
|
||||
stakingPool.payTax(action.amount1);
|
||||
} else if (action.kind == uint256(ActionType.Recenter)) {
|
||||
uint256 timeBefore = block.timestamp;
|
||||
vm.warp(timeBefore + action.amount1);
|
||||
recenter();
|
||||
}
|
||||
}
|
||||
|
||||
function testGeneration() public {
|
||||
string memory json = vm.readFile("out/scenarios.json");
|
||||
bytes memory data = vm.parseJson(json);
|
||||
Scenario memory scenario = abi.decode(data, (Scenario));
|
||||
|
||||
vm.deal(account, scenario.comEthBal * 10**18);
|
||||
vm.prank(account);
|
||||
weth.deposit{value: address(account).balance}();
|
||||
|
||||
for (uint256 i = 0; i < scenario.txns.length; i++) {
|
||||
handleAction(scenario.txns[i]);
|
||||
recordState();
|
||||
}
|
||||
|
||||
//writeCsv();
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -31,6 +31,20 @@ contract StakeTest is Test {
|
|||
harberg.setLiquidityManager(liquidityManager);
|
||||
}
|
||||
|
||||
function assertPosition(uint256 positionId, uint256 expectedShares, uint32 expectedTaxRate) private view {
|
||||
(uint256 shares, , , , uint32 taxRate) = stakingPool.positions(positionId);
|
||||
assertEq(shares, expectedShares, "Incorrect share amount for new position");
|
||||
assertEq(taxRate, expectedTaxRate, "Incorrect tax rate for new position");
|
||||
}
|
||||
|
||||
function verifyPositionShrunkOrRemoved(uint256 positionId, uint256 initialStake) private view {
|
||||
(uint256 remainingShare, , , , ) = stakingPool.positions(positionId);
|
||||
uint256 expectedInitialShares = stakingPool.assetsToShares(initialStake);
|
||||
bool positionRemoved = remainingShare == 0;
|
||||
bool positionShrunk = remainingShare < expectedInitialShares;
|
||||
|
||||
assertTrue(positionRemoved || positionShrunk, "Position was not correctly shrunk or removed");
|
||||
}
|
||||
|
||||
function testBasicStaking() public {
|
||||
// Setup
|
||||
|
|
@ -122,8 +136,8 @@ contract StakeTest is Test {
|
|||
vm.stopPrank();
|
||||
|
||||
// Setup initial stakers
|
||||
uint256 positionId1 = setupStaker(firstStaker, initialStake1, 1);
|
||||
uint256 positionId2 = setupStaker(secondStaker, initialStake2, 5);
|
||||
uint256 positionId1 = doSnatch(firstStaker, initialStake1, 1);
|
||||
uint256 positionId2 = doSnatch(secondStaker, initialStake2, 5);
|
||||
|
||||
// Snatch setup
|
||||
vm.startPrank(newStaker);
|
||||
|
|
@ -146,7 +160,7 @@ contract StakeTest is Test {
|
|||
vm.stopPrank();
|
||||
}
|
||||
|
||||
function setupStaker(address staker, uint256 amount, uint32 taxRate) private returns (uint256 positionId) {
|
||||
function doSnatch(address staker, uint256 amount, uint32 taxRate) private returns (uint256 positionId) {
|
||||
vm.startPrank(staker);
|
||||
harberg.approve(address(stakingPool), amount);
|
||||
uint256[] memory empty;
|
||||
|
|
@ -154,21 +168,97 @@ contract StakeTest is Test {
|
|||
vm.stopPrank();
|
||||
}
|
||||
|
||||
function assertPosition(uint256 positionId, uint256 expectedShares, uint32 expectedTaxRate) private view {
|
||||
(uint256 shares, , , , uint32 taxRate) = stakingPool.positions(positionId);
|
||||
assertEq(shares, expectedShares, "Incorrect share amount for new position");
|
||||
assertEq(taxRate, expectedTaxRate, "Incorrect tax rate for new position");
|
||||
function bp(uint256 val) internal pure returns (uint256) {
|
||||
return val / 1e15;
|
||||
}
|
||||
|
||||
function verifyPositionShrunkOrRemoved(uint256 positionId, uint256 initialStake) private view {
|
||||
(uint256 remainingShare, , , , ) = stakingPool.positions(positionId);
|
||||
uint256 expectedInitialShares = stakingPool.assetsToShares(initialStake);
|
||||
bool positionRemoved = remainingShare == 0;
|
||||
bool positionShrunk = remainingShare < expectedInitialShares;
|
||||
|
||||
assertTrue(positionRemoved || positionShrunk, "Position was not correctly shrunk or removed");
|
||||
function denormTR(uint256 normalizedTaxRate) internal pure returns (uint256) {
|
||||
return normalizedTaxRate * 97;
|
||||
}
|
||||
|
||||
function testAvgTaxRateAndPercentageStaked() public {
|
||||
uint256 smallstake = 0.3e17;
|
||||
uint256 stakeOneThird = 1 ether;
|
||||
uint256 stakeTwoThird = 2 ether;
|
||||
address staker = makeAddr("staker");
|
||||
|
||||
// Mint and distribute tokens
|
||||
vm.startPrank(liquidityManager);
|
||||
// mint all the tokens we will need in the test
|
||||
harberg.mint((smallstake + stakeOneThird + stakeTwoThird) * 5);
|
||||
// send 20% of that to staker
|
||||
harberg.transfer(staker, (smallstake + stakeOneThird + stakeTwoThird) * 2);
|
||||
vm.stopPrank();
|
||||
|
||||
// Setup initial stakers
|
||||
uint256 positionId1 = doSnatch(staker, smallstake, 0);
|
||||
|
||||
uint256 avgTaxRate;
|
||||
uint256 percentageStaked;
|
||||
avgTaxRate = stakingPool.getAverageTaxRate();
|
||||
percentageStaked = stakingPool.getPercentageStaked();
|
||||
|
||||
// let this be about 10 basis points of tax rate
|
||||
assertApproxEqRel(bp(denormTR(avgTaxRate)), 10, 1e17);
|
||||
assertApproxEqRel(bp(percentageStaked), 10, 1e17);
|
||||
|
||||
vm.prank(staker);
|
||||
stakingPool.exitPosition(positionId1);
|
||||
uint256 positionId2 = doSnatch(staker, stakeOneThird, 2);
|
||||
|
||||
avgTaxRate = stakingPool.getAverageTaxRate();
|
||||
percentageStaked = stakingPool.getPercentageStaked();
|
||||
|
||||
assertApproxEqRel(bp(denormTR(avgTaxRate)), 50, 1e17);
|
||||
assertApproxEqRel(bp(percentageStaked), 300, 1e17);
|
||||
|
||||
vm.prank(staker);
|
||||
stakingPool.exitPosition(positionId2);
|
||||
positionId1 = doSnatch(staker, stakeOneThird, 10);
|
||||
positionId2 = doSnatch(staker, stakeTwoThird, 11);
|
||||
|
||||
avgTaxRate = stakingPool.getAverageTaxRate();
|
||||
percentageStaked = stakingPool.getPercentageStaked();
|
||||
|
||||
assertApproxEqRel(bp(denormTR(avgTaxRate)), 730, 1e17);
|
||||
assertApproxEqRel(bp(percentageStaked), 1000, 1e17);
|
||||
|
||||
vm.startPrank(staker);
|
||||
stakingPool.exitPosition(positionId1);
|
||||
stakingPool.exitPosition(positionId2);
|
||||
vm.stopPrank();
|
||||
positionId1 = doSnatch(staker, stakeOneThird, 29);
|
||||
positionId2 = doSnatch(staker, stakeTwoThird, 29);
|
||||
|
||||
avgTaxRate = stakingPool.getAverageTaxRate();
|
||||
assertApproxEqRel(bp(denormTR(avgTaxRate)), 97000, 1e17);
|
||||
|
||||
vm.startPrank(staker);
|
||||
stakingPool.exitPosition(positionId1);
|
||||
stakingPool.exitPosition(positionId2);
|
||||
vm.stopPrank();
|
||||
positionId2 = doSnatch(staker, stakeTwoThird, 15);
|
||||
|
||||
avgTaxRate = stakingPool.getAverageTaxRate();
|
||||
percentageStaked = stakingPool.getPercentageStaked();
|
||||
|
||||
assertApproxEqRel(bp(denormTR(avgTaxRate)), 2500, 1e17);
|
||||
assertApproxEqRel(bp(percentageStaked), 660, 1e17);
|
||||
|
||||
vm.startPrank(staker);
|
||||
stakingPool.exitPosition(positionId2);
|
||||
vm.stopPrank();
|
||||
|
||||
positionId1 = doSnatch(staker, stakeOneThird, 15);
|
||||
|
||||
avgTaxRate = stakingPool.getAverageTaxRate();
|
||||
percentageStaked = stakingPool.getPercentageStaked();
|
||||
|
||||
assertApproxEqRel(bp(denormTR(avgTaxRate)), 2500, 1e17);
|
||||
assertApproxEqRel(bp(percentageStaked), 330, 1e17);
|
||||
}
|
||||
|
||||
|
||||
function testRevert_SharesTooLow() public {
|
||||
address staker = makeAddr("staker");
|
||||
vm.startPrank(liquidityManager);
|
||||
|
|
@ -195,7 +285,7 @@ contract StakeTest is Test {
|
|||
harberg.transfer(newStaker, 1 ether);
|
||||
vm.stopPrank();
|
||||
|
||||
uint256 positionId = setupStaker(existingStaker, 1 ether, 5); // Existing staker with tax rate 5
|
||||
uint256 positionId = doSnatch(existingStaker, 1 ether, 5); // Existing staker with tax rate 5
|
||||
|
||||
vm.startPrank(newStaker);
|
||||
harberg.transfer(newStaker, 1 ether);
|
||||
|
|
@ -219,7 +309,7 @@ contract StakeTest is Test {
|
|||
harberg.transfer(ambitiousStaker, 1 ether);
|
||||
vm.stopPrank();
|
||||
|
||||
uint256 positionId = setupStaker(staker, 2 ether, 10);
|
||||
uint256 positionId = doSnatch(staker, 2 ether, 10);
|
||||
|
||||
vm.startPrank(ambitiousStaker);
|
||||
harberg.approve(address(stakingPool), 1 ether);
|
||||
|
|
|
|||
90
onchain/test/helpers/CSVHelper.sol
Normal file
90
onchain/test/helpers/CSVHelper.sol
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
/**
|
||||
* @title CSVHelper
|
||||
* @dev Library for managing CSV data in Solidity, including converting values to strings and writing CSV data.
|
||||
*/
|
||||
library CSVHelper {
|
||||
/**
|
||||
* @notice Creates a standard CSV header for liquidity position data.
|
||||
* @return The CSV header as a string.
|
||||
*/
|
||||
function createPositionsHeader() internal pure returns (string memory) {
|
||||
return "precedingAction, currentTick, floorTickLower, floorTickUpper, floorEth, floorHarb, anchorTickLower, anchorTickUpper, anchorEth, anchorHarb, discoveryTickLower, discoveryTickUpper, discoveryEth, discoveryHarb";
|
||||
}
|
||||
|
||||
function createTimeSeriesHeader() internal pure returns (string memory) {
|
||||
return "time, price, harbTotalSupply, supplyChange, stakeOutstandingShares, avgTaxRate, sentiment, taxCollected";
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Appends new CSV data to the existing CSV string.
|
||||
* @param csv The current CSV string.
|
||||
* @param newRow The new row to append.
|
||||
* @return The updated CSV string.
|
||||
*/
|
||||
function appendRow(string memory csv, string memory newRow) internal pure returns (string memory) {
|
||||
return string(abi.encodePacked(csv, "\n", newRow));
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Converts a `uint256` to a string.
|
||||
* @param _i The integer to convert.
|
||||
* @return The string representation of the integer.
|
||||
*/
|
||||
function uintToStr(uint256 _i) internal pure returns (string memory) {
|
||||
if (_i == 0) {
|
||||
return "0";
|
||||
}
|
||||
uint256 j = _i;
|
||||
uint256 len;
|
||||
while (j != 0) {
|
||||
len++;
|
||||
j /= 10;
|
||||
}
|
||||
bytes memory bstr = new bytes(len);
|
||||
uint256 k = len;
|
||||
while (_i != 0) {
|
||||
k = k - 1;
|
||||
uint8 temp = (48 + uint8(_i - _i / 10 * 10));
|
||||
bstr[k] = bytes1(temp);
|
||||
_i /= 10;
|
||||
}
|
||||
return string(bstr);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Converts an `int256` to a string.
|
||||
* @param _i The integer to convert.
|
||||
* @return The string representation of the integer.
|
||||
*/
|
||||
function intToStr(int256 _i) internal pure returns (string memory) {
|
||||
if (_i == 0) {
|
||||
return "0";
|
||||
}
|
||||
bool negative = _i < 0;
|
||||
uint256 absValue = uint256(negative ? -_i : _i);
|
||||
uint256 len;
|
||||
uint256 j = absValue;
|
||||
while (j != 0) {
|
||||
len++;
|
||||
j /= 10;
|
||||
}
|
||||
if (negative) {
|
||||
len++; // Increase length for the minus sign.
|
||||
}
|
||||
bytes memory bstr = new bytes(len);
|
||||
uint256 k = len;
|
||||
while (absValue != 0) {
|
||||
k = k - 1;
|
||||
uint8 temp = (48 + uint8(absValue - absValue / 10 * 10));
|
||||
bstr[k] = bytes1(temp);
|
||||
absValue /= 10;
|
||||
}
|
||||
if (negative) {
|
||||
bstr[0] = '-';
|
||||
}
|
||||
return string(bstr);
|
||||
}
|
||||
}
|
||||
44
onchain/test/helpers/CSVManager.sol
Normal file
44
onchain/test/helpers/CSVManager.sol
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import "forge-std/Test.sol";
|
||||
import "./CSVHelper.sol";
|
||||
|
||||
/**
|
||||
* @title CSVManager
|
||||
* @dev An abstract contract that manages CSV creation, row appending, and writing to file.
|
||||
* Contracts that inherit this contract can use the CSV functionality without managing the CSV string directly.
|
||||
*/
|
||||
abstract contract CSVManager is Test {
|
||||
using CSVHelper for *;
|
||||
|
||||
string internal csv;
|
||||
|
||||
/**
|
||||
* @notice Creates the header for the CSV file.
|
||||
* This function should be called in the `setUp` function of derived contracts.
|
||||
*/
|
||||
function initializePositionsCSV() internal {
|
||||
csv = CSVHelper.createPositionsHeader();
|
||||
}
|
||||
|
||||
function initializeTimeSeriesCSV() internal {
|
||||
csv = CSVHelper.createTimeSeriesHeader();
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Appends a new row to the CSV string.
|
||||
* @param newRow The new row to append.
|
||||
*/
|
||||
function appendCSVRow(string memory newRow) internal {
|
||||
csv = CSVHelper.appendRow(csv, newRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Writes the CSV string to a file.
|
||||
* @param filePath The path where the CSV file will be saved.
|
||||
*/
|
||||
function writeCSVToFile(string memory filePath) internal {
|
||||
vm.writeFile(filePath, csv);
|
||||
}
|
||||
}
|
||||
81
onchain/test/helpers/UniswapTestBase.sol
Normal file
81
onchain/test/helpers/UniswapTestBase.sol
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import "forge-std/Test.sol";
|
||||
import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
|
||||
import {TickMath} from "@aperture/uni-v3-lib/TickMath.sol";
|
||||
import "../../src/interfaces/IWETH9.sol";
|
||||
import {Harberg} from "../../src/Harberg.sol";
|
||||
|
||||
|
||||
/**
|
||||
* @title UniswapTestBase
|
||||
* @dev Base contract for Uniswap V3 testing, providing reusable swap logic.
|
||||
*/
|
||||
abstract contract UniswapTestBase is Test {
|
||||
address account = makeAddr("alice");
|
||||
IUniswapV3Pool public pool;
|
||||
IWETH9 public weth;
|
||||
Harberg public harberg;
|
||||
bool public token0isWeth;
|
||||
|
||||
/**
|
||||
* @dev Performs a swap in the Uniswap V3 pool.
|
||||
* @param amount The amount to swap.
|
||||
* @param isBuy True if buying WETH, false if selling.
|
||||
*/
|
||||
function performSwap(uint256 amount, bool isBuy) internal {
|
||||
uint160 limit;
|
||||
// Determine the swap direction
|
||||
bool zeroForOne = isBuy ? token0isWeth : !token0isWeth;
|
||||
|
||||
if (isBuy) {
|
||||
vm.prank(account);
|
||||
weth.transfer(address(this), amount);
|
||||
} else {
|
||||
vm.prank(account);
|
||||
harberg.approve(address(this), amount);
|
||||
}
|
||||
|
||||
// Set the sqrtPriceLimitX96 based on the swap direction
|
||||
if (zeroForOne) {
|
||||
// Swapping token0 for token1
|
||||
// sqrtPriceLimitX96 must be less than current price but greater than MIN_SQRT_RATIO
|
||||
limit = TickMath.MIN_SQRT_RATIO + 1;
|
||||
} else {
|
||||
// Swapping token1 for token0
|
||||
// sqrtPriceLimitX96 must be greater than current price but less than MAX_SQRT_RATIO
|
||||
limit = TickMath.MAX_SQRT_RATIO - 1;
|
||||
}
|
||||
|
||||
pool.swap(
|
||||
account,
|
||||
zeroForOne,
|
||||
int256(amount),
|
||||
limit,
|
||||
abi.encode(account, int256(amount), isBuy)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dev The Uniswap V3 swap callback.
|
||||
*/
|
||||
function uniswapV3SwapCallback(
|
||||
int256 amount0Delta,
|
||||
int256 amount1Delta,
|
||||
bytes calldata _data
|
||||
) external {
|
||||
require(amount0Delta > 0 || amount1Delta > 0);
|
||||
|
||||
(address seller, , bool isBuy) = abi.decode(_data, (address, uint256, bool));
|
||||
|
||||
(, uint256 amountToPay) = amount0Delta > 0
|
||||
? (!token0isWeth, uint256(amount0Delta))
|
||||
: (token0isWeth, uint256(amount1Delta));
|
||||
if (isBuy) {
|
||||
weth.transfer(msg.sender, amountToPay);
|
||||
} else {
|
||||
require(harberg.transferFrom(seller, msg.sender, amountToPay), "Transfer failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
59
onchain/test/mocks/MockSentimenter.sol
Normal file
59
onchain/test/mocks/MockSentimenter.sol
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import {Harberg} from "../../src/Harberg.sol";
|
||||
import {Stake} from "../../src/Stake.sol";
|
||||
import {UUPSUpgradeable} from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol";
|
||||
import {Initializable} from "@openzeppelin/proxy/utils/Initializable.sol";
|
||||
|
||||
contract MockSentimenter is Initializable, UUPSUpgradeable {
|
||||
|
||||
Harberg private harberg;
|
||||
Stake private stake;
|
||||
|
||||
/**
|
||||
* @dev The caller account is not authorized to perform an operation.
|
||||
*/
|
||||
error UnauthorizedAccount(address account);
|
||||
|
||||
function initialize(address _harberg, address _stake) initializer public {
|
||||
_changeAdmin(msg.sender);
|
||||
harberg = Harberg(_harberg);
|
||||
stake = Stake(_stake);
|
||||
}
|
||||
/**
|
||||
* @dev Throws if called by any account other than the admin.
|
||||
*/
|
||||
modifier onlyAdmin() {
|
||||
_checkAdmin();
|
||||
_;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @dev Throws if the sender is not the admin.
|
||||
*/
|
||||
function _checkAdmin() internal view virtual {
|
||||
if (_getAdmin() != msg.sender) {
|
||||
revert UnauthorizedAccount(msg.sender);
|
||||
}
|
||||
}
|
||||
function _authorizeUpgrade(address newImplementation) internal override onlyAdmin {}
|
||||
|
||||
function calculateSentiment(uint256 averageTaxRate, uint256 percentageStaked) public pure returns (uint256 sentimentValue) {
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/// @notice Computes the staker sentiment based on the proportion of the authorized stake that is currently staked.
|
||||
/// @return sentiment A number between 0 and 200 indicating the market sentiment.
|
||||
function getSentiment() external returns (uint256 sentiment) {
|
||||
uint256 percentageStaked = stake.getPercentageStaked();
|
||||
uint256 averageTaxRate = stake.getAverageTaxRate();
|
||||
sentiment = calculateSentiment(averageTaxRate, percentageStaked);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue