diff --git a/.env.example b/.env.example index fab2ac3..a531937 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,13 @@ ADMIN= USDC= # Deposits to Liquidity Hub are only allowed till this limit is reached. ASSETS_LIMIT= +# Liquidity mining tiers. Multiplier will be divided by 100. So 175 will result in 1.75x. +# There is no limit to the number of tiers, but has to be atleast one. +TIER_1_DAYS=90 +TIER_1_MULTIPLIER=100 +TIER_2_DAYS=180 +TIER_2_MULTIPLIER=150 +TIER_3_DAYS=360 +TIER_3_MULTIPLIER=200 BASETEST_PRIVATE_KEY= VERIFY=false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d0780a7..d0accd6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Use default .env + run: mv .env.example .env - name: Install Node.js 22 uses: actions/setup-node@v4 with: diff --git a/contracts/LiquidityHub.sol b/contracts/LiquidityHub.sol index 504eab9..6ca99ea 100644 --- a/contracts/LiquidityHub.sol +++ b/contracts/LiquidityHub.sol @@ -14,8 +14,9 @@ import {AccessControlUpgradeable} from '@openzeppelin/contracts-upgradeable/acce import {ERC7201Helper} from './utils/ERC7201Helper.sol'; import {IManagedToken} from './interfaces/IManagedToken.sol'; import {ILiquidityPool} from './interfaces/ILiquidityPool.sol'; +import {ILiquidityHub} from './interfaces/ILiquidityHub.sol'; -contract LiquidityHub is ERC4626Upgradeable, AccessControlUpgradeable { +contract LiquidityHub is ILiquidityHub, ERC4626Upgradeable, AccessControlUpgradeable { using Math for uint256; IManagedToken immutable public SHARES; diff --git a/contracts/LiquidityMining.sol b/contracts/LiquidityMining.sol new file mode 100644 index 0000000..8fb9350 --- /dev/null +++ b/contracts/LiquidityMining.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +contract LiquidityMining is ERC20, Ownable { + using SafeERC20 for IERC20; + + uint32 public constant MULTIPLIER_PRECISION = 100; + IERC20 public immutable STAKING_TOKEN; + + struct Tier { + uint32 period; + uint32 multiplier; + } + + struct Stake { + uint256 amount; + uint32 period; + uint32 until; + uint32 multiplier; + } + + bool public miningAllowed; + Tier[] public tiers; + mapping(address user => Stake) public stakes; + + event DisableMining(); + event StakeLocked( + address from, + address to, + uint256 amount, + uint256 totalAmount, + uint32 until, + uint256 addedScore + ); + event StakeUnlocked( + address from, + address to, + uint256 amount + ); + + error ZeroAddress(); + error EmptyInput(); + error ZeroPeriod(); + error ZeroMultiplier(); + error DecreasingPeriod(); + error InvalidAddedScore(); + error AlreadyDisabled(); + error MiningDisabled(); + error ZeroAmount(); + error Locked(); + + constructor( + string memory name_, + string memory symbol_, + address owner_, + address stakingToken, + Tier[] memory tiers_ + ) + ERC20(name_, symbol_) + Ownable(owner_) + { + require(stakingToken != address(0), ZeroAddress()); + STAKING_TOKEN = IERC20(stakingToken); + miningAllowed = true; + require(tiers_.length > 0, EmptyInput()); + for (uint256 i = 0; i < tiers_.length; ++i) { + require(tiers_[i].period > 0, ZeroPeriod()); + require(tiers_[i].multiplier > 0, ZeroMultiplier()); + if (i > 0) { + require(tiers_[i].period > tiers_[i - 1].period, DecreasingPeriod()); + } + tiers.push(tiers_[i]); + } + } + + function stake(address scoreTo, uint256 amount, uint256 tierId) public { + if (amount > 0) { + STAKING_TOKEN.safeTransferFrom(_msgSender(), address(this), amount); + } + _stake(_msgSender(), scoreTo, amount, tierId); + } + + function stakeWithPermit( + address scoreTo, + uint256 amount, + uint256 tierId, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + IERC20Permit(address(STAKING_TOKEN)).permit( + _msgSender(), + address(this), + amount, + deadline, + v, + r, + s + ); + stake(scoreTo, amount, tierId); + } + + function unstake(address to) external { + uint256 amount = _unstake(_msgSender(), to); + STAKING_TOKEN.safeTransfer(to, amount); + } + + function disableMining() external onlyOwner() { + require(miningAllowed, AlreadyDisabled()); + miningAllowed = false; + emit DisableMining(); + } + + function _stake(address from, address scoreTo, uint256 amount, uint256 tierId) internal { + require(miningAllowed, MiningDisabled()); + Stake memory currentStake = stakes[from]; + Tier memory tier = tiers[tierId]; + uint256 pendingScore = 0; + if (notReached(currentStake.until)) { + uint256 remainingTime = till(currentStake.until); + require(tier.period >= remainingTime, DecreasingPeriod()); + pendingScore = Math.ceilDiv( + currentStake.amount * remainingTime * uint256(currentStake.multiplier), + MULTIPLIER_PRECISION * currentStake.period + ); + } + currentStake.amount += amount; + currentStake.period = tier.period; + currentStake.until = timeNow() + tier.period; + currentStake.multiplier = tier.multiplier; + stakes[from] = currentStake; + uint256 newPendingScore = + currentStake.amount * uint256(tier.multiplier) / + uint256(MULTIPLIER_PRECISION); + require(newPendingScore > pendingScore, InvalidAddedScore()); + uint256 addedScore = newPendingScore - pendingScore; + _mint(scoreTo, addedScore); + + emit StakeLocked(from, scoreTo, amount, currentStake.amount, currentStake.until, addedScore); + } + + function _unstake(address from, address to) internal returns (uint256) { + Stake memory currentStake = stakes[from]; + require(currentStake.amount > 0, ZeroAmount()); + require(reached(currentStake.until), Locked()); + delete stakes[from]; + + emit StakeUnlocked(_msgSender(), to, currentStake.amount); + + return currentStake.amount; + } + + function timeNow() internal view returns (uint32) { + return uint32(block.timestamp); + } + + function reached(uint32 timestamp) internal view returns (bool) { + return timeNow() >= timestamp; + } + + function notReached(uint32 timestamp) internal view returns (bool) { + return !reached(timestamp); + } + + function till(uint32 timestamp) internal view returns (uint32) { + if (reached(timestamp)) { + return 0; + } + return timestamp - timeNow(); + } +} diff --git a/contracts/SprinterLiquidityMining.sol b/contracts/SprinterLiquidityMining.sol new file mode 100644 index 0000000..0c96593 --- /dev/null +++ b/contracts/SprinterLiquidityMining.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +import { + LiquidityMining, + SafeERC20, + IERC20, + IERC20Permit +} from "./LiquidityMining.sol"; +import {ILiquidityHub} from "./interfaces/ILiquidityHub.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +contract SprinterLiquidityMining is LiquidityMining { + using SafeERC20 for IERC20; + + ILiquidityHub public immutable LIQUIDITY_HUB; + + error NotImplemented(); + + constructor(address owner_, address liquidityHub, Tier[] memory tiers_) + LiquidityMining( + "Sprinter USDC LP Score", + "sprUSDC-LP-Score", + owner_, + address(ILiquidityHub(liquidityHub).SHARES()), + tiers_ + ) + { + LIQUIDITY_HUB = ILiquidityHub(liquidityHub); + } + + function depositAndStake(address scoreTo, uint256 amount, uint256 tierId) public { + address from = _msgSender(); + IERC4626 liquidityHub = IERC4626(address(LIQUIDITY_HUB)); + IERC20 asset = IERC20(liquidityHub.asset()); + asset.safeTransferFrom(from, address(this), amount); + asset.approve(address(liquidityHub), amount); + uint256 shares = liquidityHub.deposit(amount, address(this)); + _stake(from, scoreTo, shares, tierId); + } + + function depositAndStakeWithPermit( + address scoreTo, + uint256 amount, + uint256 tierId, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + IERC20Permit(IERC4626(address(LIQUIDITY_HUB)).asset()).permit( + _msgSender(), + address(this), + amount, + deadline, + v, + r, + s + ); + depositAndStake(scoreTo, amount, tierId); + } + + function unstakeAndWithdraw(address to) external { + uint256 shares = _unstake(_msgSender(), address(this)); + IERC4626(address(LIQUIDITY_HUB)).redeem(shares, to, address(this)); + } + + function transfer(address, uint256) public pure override returns (bool) { + revert NotImplemented(); + } + + function allowance(address, address) public pure override returns (uint256) { + // Silences the unreachable code warning from ERC20._spendAllowance(). + return 0; + } + + function approve(address, uint256) public pure override returns (bool) { + revert NotImplemented(); + } + + function transferFrom(address, address, uint256) public pure override returns (bool) { + revert NotImplemented(); + } + + function _update(address from, address to, uint256 value) internal virtual override { + require(from == address(0), NotImplemented()); + super._update(from, to, value); + } +} diff --git a/contracts/interfaces/ILiquidityHub.sol b/contracts/interfaces/ILiquidityHub.sol new file mode 100644 index 0000000..fcbe6ac --- /dev/null +++ b/contracts/interfaces/ILiquidityHub.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +import {IManagedToken} from "./IManagedToken.sol"; + +interface ILiquidityHub { + function SHARES() external view returns (IManagedToken); +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 2f42358..5498da8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,6 +28,7 @@ export default tseslint.config( "@typescript-eslint/no-explicit-any": "off", quotes: ["error", "double"], semi: "off", + "@typescript-eslint/no-unused-expressions": "off", } } ); diff --git a/scripts/deploy.ts b/scripts/deploy.ts index ab7efac..a7f3b0c 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -4,18 +4,36 @@ dotenv.config(); import hre from "hardhat"; import {isAddress, MaxUint256, getBigInt} from "ethers"; import {getContractAt, getCreateAddress, deploy, ZERO_BYTES32} from "../test/helpers"; -import {assert, getVerifier} from "./helpers"; +import {assert, getVerifier, isSet} from "./helpers"; import { TestUSDC, SprinterUSDCLPShare, LiquidityHub, TransparentUpgradeableProxy, ProxyAdmin, - TestLiquidityPool, + TestLiquidityPool, SprinterLiquidityMining, } from "../typechain-types"; +const DAY = 60n * 60n * 24n; + async function main() { const [deployer] = await hre.ethers.getSigners(); const admin: string = isAddress(process.env.ADMIN) ? process.env.ADMIN : deployer.address; const adjuster: string = isAddress(process.env.ADJUSTER) ? process.env.ADJUSTER : deployer.address; const maxLimit: bigint = MaxUint256 / 10n ** 12n; const assetsLimit: bigint = getBigInt(process.env.ASSETS_LIMIT || maxLimit); + + const tiers = []; + + for (let i = 1;; i++) { + if (!isSet(process.env[`TIER_${i}_DAYS`])) { + break; + } + const period = BigInt(process.env[`TIER_${i}_DAYS`] || "0") * DAY; + const multiplier = BigInt(process.env[`TIER_${i}_MULTIPLIER`] || "0"); + tiers.push({period, multiplier}); + } + + if (tiers.length == 0) { + throw new Error("Empty liquidity mining tiers configuration."); + } + let usdc: string; if (isAddress(process.env.USDC)) { usdc = process.env.USDC; @@ -52,6 +70,10 @@ async function main() { assert(liquidityHubAddress == liquidityHubProxy.target, "LiquidityHub address mismatch"); + const liquidityMining = ( + await deploy("SprinterLiquidityMining", deployer, {}, admin, liquidityHub.target, tiers) + ) as SprinterLiquidityMining; + const DEFAULT_ADMIN_ROLE = ZERO_BYTES32; console.log("TEST: Using default admin role for Hub on Pool"); @@ -63,6 +85,9 @@ async function main() { console.log(`LiquidityHub: ${liquidityHub.target}`); console.log(`LiquidityHubProxyAdmin: ${liquidityHubAdmin.target}`); console.log(`USDC: ${usdc}`); + console.log(`SprinterLiquidityMining: ${liquidityMining.target}`); + console.log("Tiers:"); + console.table(tiers); if (process.env.VERIFY === "true") { await verifier.verify(); diff --git a/test/SprinterLiquidityMining.ts b/test/SprinterLiquidityMining.ts new file mode 100644 index 0000000..a0f15d0 --- /dev/null +++ b/test/SprinterLiquidityMining.ts @@ -0,0 +1,1065 @@ +import { + loadFixture, time, +} from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import {expect} from "chai"; +import hre from "hardhat"; +import {Signature, resolveAddress, MaxUint256, getBigInt} from "ethers"; +import { + getCreateAddress, getContractAt, deploy, + ZERO_ADDRESS, ZERO_BYTES32, divCeil, +} from "./helpers"; +import { + TestUSDC, SprinterUSDCLPShare, LiquidityHub, TransparentUpgradeableProxy, ProxyAdmin, + TestLiquidityPool, SprinterLiquidityMining, +} from "../typechain-types"; + +const DAY = 60n * 60n * 24n; +const MONTH = 30n * DAY; + +async function now() { + return BigInt(await time.latest()); +} + +describe("SprinterLiquidityMining", function () { + const deployAll = async () => { + const [deployer, admin, user, user2, user3] = await hre.ethers.getSigners(); + + const DEFAULT_ADMIN_ROLE = ZERO_BYTES32; + + const usdc = (await deploy("TestUSDC", deployer, {})) as TestUSDC; + const liquidityPool = (await deploy("TestLiquidityPool", deployer, {}, usdc.target)) as TestLiquidityPool; + + const USDC = 10n ** (await usdc.decimals()); + + const startingNonce = await deployer.getNonce(); + + const liquidityHubAddress = await getCreateAddress(deployer, startingNonce + 2); + const lpToken = ( + await deploy("SprinterUSDCLPShare", deployer, {nonce: startingNonce + 0}, liquidityHubAddress) + ) as SprinterUSDCLPShare; + const LP = 10n ** (await lpToken.decimals()); + + const liquidityHubImpl = ( + await deploy("LiquidityHub", deployer, {nonce: startingNonce + 1}, lpToken.target, liquidityPool.target) + ) as LiquidityHub; + const liquidityHubInit = (await liquidityHubImpl.initialize.populateTransaction( + usdc.target, admin.address, admin.address, getBigInt(MaxUint256) * USDC / LP) + ).data; + const liquidityHubProxy = (await deploy( + "TransparentUpgradeableProxy", deployer, {nonce: startingNonce + 2}, + liquidityHubImpl.target, admin, liquidityHubInit + )) as TransparentUpgradeableProxy; + const liquidityHub = (await getContractAt("LiquidityHub", liquidityHubAddress, deployer)) as LiquidityHub; + const liquidityHubProxyAdminAddress = await getCreateAddress(liquidityHubProxy, 1); + const liquidityHubAdmin = (await getContractAt("ProxyAdmin", liquidityHubProxyAdminAddress, admin)) as ProxyAdmin; + + const tiers = [ + {period: 3n * MONTH, multiplier: 100n}, + {period: 6n * MONTH, multiplier: 150n}, + {period: 12n * MONTH, multiplier: 200n}, + ]; + + const liquidityMining = ( + await deploy("SprinterLiquidityMining", deployer, {}, admin.address, liquidityHub.target, tiers) + ) as SprinterLiquidityMining; + + await liquidityPool.grantRole(DEFAULT_ADMIN_ROLE, liquidityHub.target); + + return {deployer, admin, user, user2, user3, usdc, lpToken, + liquidityHub, liquidityHubProxy, liquidityHubAdmin, USDC, LP, liquidityPool, liquidityMining}; + }; + + it("Should have default values", async function () { + const { + lpToken, liquidityHub, user, user2, + liquidityMining, admin, + } = await loadFixture(deployAll); + + expect(await liquidityMining.LIQUIDITY_HUB()).to.equal(liquidityHub.target); + expect(await liquidityMining.name()).to.equal("Sprinter USDC LP Score"); + expect(await liquidityMining.symbol()).to.equal("sprUSDC-LP-Score"); + expect(await liquidityMining.decimals()).to.equal(18n); + expect(await liquidityMining.owner()).to.equal(admin.address); + expect(await liquidityMining.MULTIPLIER_PRECISION()).to.equal(100n); + expect(await liquidityMining.STAKING_TOKEN()).to.equal(lpToken.target); + expect(await liquidityMining.miningAllowed()).to.be.true; + expect(await liquidityMining.tiers(0)).to.eql([3n * MONTH, 100n]); + expect(await liquidityMining.tiers(1)).to.eql([6n * MONTH, 150n]); + expect(await liquidityMining.tiers(2)).to.eql([12n * MONTH, 200n]); + expect(await liquidityMining.stakes(user.address)).to.eql([0n, 0n, 0n, 0n]); + + await expect(liquidityHub.transfer(user.address, 1n)) + .to.be.revertedWithCustomError(liquidityHub, "NotImplemented()"); + await expect(liquidityHub.approve(user.address, 1n)) + .to.be.revertedWithCustomError(liquidityHub, "NotImplemented()"); + await expect(liquidityHub.transferFrom(user.address, user2.address, 1n)) + .to.be.revertedWithCustomError(liquidityHub, "NotImplemented()"); + await expect(liquidityMining.tiers(3)).to.be.reverted; + }); + + it("Should allow to stake", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + const tx = liquidityMining.connect(user).stake(user.address, 10n * LP, 0n); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(user.address, liquidityMining.target, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, 10n * LP); + const until = await now() + 3n * MONTH; + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user.address, + 10n * LP, + 10n * LP, + until, + 10n * LP, + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(10n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(0n); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([10n * LP, 3n * MONTH, until, 100n]); + expect(await liquidityMining.stakes(user2.address)).to.eql([0n, 0n, 0n, 0n]); + }); + + it("Should allow to stake by multiple users", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(deployer).transfer(user2.address, 20n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await usdc.connect(user2).approve(liquidityHub.target, 20n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await liquidityHub.connect(user2).deposit(20n * USDC, user2.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + await lpToken.connect(user2).approve(liquidityMining.target, 20n * LP); + const tx = liquidityMining.connect(user).stake(user.address, 10n * LP, 0n); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(user.address, liquidityMining.target, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, 10n * LP); + const until = await now() + 3n * MONTH; + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user.address, + 10n * LP, + 10n * LP, + until, + 10n * LP, + ); + const tx2 = liquidityMining.connect(user2).stake(user2.address, 20n * LP, 1n); + await expect(tx2) + .to.emit(lpToken, "Transfer") + .withArgs(user2.address, liquidityMining.target, 20n * LP); + await expect(tx2) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user2.address, 20n * LP * 150n / 100n); + const until2 = await now() + 6n * MONTH; + await expect(tx2) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user2.address, + user2.address, + 20n * LP, + 20n * LP, + until2, + 20n * LP * 150n / 100n, + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(user2.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(30n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP + 20n * LP * 150n / 100n); + expect(await liquidityMining.balanceOf(user.address)).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(20n * LP * 150n / 100n); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(30n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([10n * LP, 3n * MONTH, until, 100n]); + expect(await liquidityMining.stakes(user2.address)).to.eql([20n * LP, 6n * MONTH, until2, 150n]); + }); + + it("Should allow to stake with a score to another address", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + const tx = liquidityMining.connect(user).stake(user2.address, 10n * LP, 0n); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(user.address, liquidityMining.target, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user2.address, 10n * LP); + const until = await now() + 3n * MONTH; + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user2.address, + 10n * LP, + 10n * LP, + until, + 10n * LP, + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(10n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(0n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([10n * LP, 3n * MONTH, until, 100n]); + expect(await liquidityMining.stakes(user2.address)).to.eql([0n, 0n, 0n, 0n]); + }); + + it("Should allow to stake with a different tier", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + const tx = liquidityMining.connect(user).stake(user.address, 10n * LP, 1n); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(user.address, liquidityMining.target, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, 10n * LP * 150n / 100n); + const until = await now() + 6n * MONTH; + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user.address, + 10n * LP, + 10n * LP, + until, + 10n * LP * 150n / 100n, + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(10n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP * 150n / 100n); + expect(await liquidityMining.balanceOf(user.address)).to.equal(10n * LP * 150n / 100n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(0n); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([10n * LP, 6n * MONTH, until, 150n]); + expect(await liquidityMining.stakes(user2.address)).to.eql([0n, 0n, 0n, 0n]); + }); + + it("Should allow to stake with permit", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + + const domain = { + name: "Sprinter USDC LP Share", + version: "1", + chainId: hre.network.config.chainId, + verifyingContract: await resolveAddress(lpToken), + }; + + const types = { + Permit: [ + {name: "owner", type: "address"}, + {name: "spender", type: "address"}, + {name: "value", type: "uint256"}, + {name: "nonce", type: "uint256"}, + {name: "deadline", type: "uint256"}, + ], + }; + + const permitSig = Signature.from(await user.signTypedData(domain, types, { + owner: user.address, + spender: liquidityMining.target, + value: 10n * LP, + nonce: 0n, + deadline: 2000000000n, + })); + + const tx = liquidityMining.connect(user).stakeWithPermit( + user2.address, + 10n * LP, + 0n, + 2000000000n, + permitSig.v, + permitSig.r, + permitSig.s, + ); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(user.address, liquidityMining.target, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user2.address, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user2.address, + 10n * LP, + 10n * LP, + await now() + 3n * MONTH, + 10n * LP, + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(10n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(0n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + }); + + it("Should allow to unstake", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + await liquidityMining.connect(user).stake(user2.address, 10n * LP, 0n); + + await time.increase(3n * MONTH); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(10n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(0n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + + const tx = liquidityMining.connect(user).unstake(user.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(liquidityMining.target, user.address, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "StakeUnlocked") + .withArgs(user.address, user.address, 10n * LP); + expect(await lpToken.balanceOf(user.address)).to.equal(10n * LP); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(0n); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(0n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + }); + + it("Should allow to unstake to another address", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + await liquidityMining.connect(user).stake(user2.address, 10n * LP, 0n); + + await time.increase(3n * MONTH); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(10n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(0n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + + const tx = liquidityMining.connect(user).unstake(user2.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(liquidityMining.target, user2.address, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "StakeUnlocked") + .withArgs(user.address, user2.address, 10n * LP); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(user2.address)).to.equal(10n * LP); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(0n); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(0n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + }); + + it("Should allow admin to disable mining", async function () { + const { + liquidityMining, admin, + } = await loadFixture(deployAll); + + const tx = liquidityMining.connect(admin).disableMining(); + await expect(tx) + .to.emit(liquidityMining, "DisableMining"); + expect(await liquidityMining.miningAllowed()).to.be.false; + + await expect(liquidityMining.connect(admin).disableMining()) + .to.be.revertedWithCustomError(liquidityMining, "AlreadyDisabled()"); + }); + + it("Should not allow others to disable mining", async function () { + const { + user, liquidityMining, + } = await loadFixture(deployAll); + + await expect(liquidityMining.connect(user).disableMining()) + .to.be.revertedWithCustomError(liquidityMining, "OwnableUnauthorizedAccount(address)"); + }); + + it("Should not allow to stake if mining is disabled", async function () { + const { + lpToken, liquidityHub, usdc, user, USDC, LP, + liquidityMining, admin, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + + await liquidityMining.connect(admin).disableMining(); + await expect(liquidityMining.connect(user).stake(user.address, 10n * LP, 0n)) + .to.be.revertedWithCustomError(liquidityMining, "MiningDisabled()"); + }); + + it("Should not allow to stake with permit if mining is disabled", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, USDC, LP, + liquidityMining, admin, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + + const domain = { + name: "Sprinter USDC LP Share", + version: "1", + chainId: hre.network.config.chainId, + verifyingContract: await resolveAddress(lpToken), + }; + + const types = { + Permit: [ + {name: "owner", type: "address"}, + {name: "spender", type: "address"}, + {name: "value", type: "uint256"}, + {name: "nonce", type: "uint256"}, + {name: "deadline", type: "uint256"}, + ], + }; + + const permitSig = Signature.from(await user.signTypedData(domain, types, { + owner: user.address, + spender: liquidityMining.target, + value: 10n * LP, + nonce: 0n, + deadline: 2000000000n, + })); + + await liquidityMining.connect(admin).disableMining(); + await expect(liquidityMining.connect(user).stakeWithPermit( + user2.address, + 10n * LP, + 0n, + 2000000000n, + permitSig.v, + permitSig.r, + permitSig.s, + )).to.be.revertedWithCustomError(liquidityMining, "MiningDisabled()"); + }); + + it("Should allow to unstake if mining is disabled", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, admin, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + await liquidityMining.connect(user).stake(user2.address, 10n * LP, 0n); + + await time.increase(3n * MONTH); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(10n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(0n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + + await liquidityMining.connect(admin).disableMining(); + const tx = liquidityMining.connect(user).unstake(user.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(liquidityMining.target, user.address, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "StakeUnlocked") + .withArgs(user.address, user.address, 10n * LP); + expect(await lpToken.balanceOf(user.address)).to.equal(10n * LP); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(0n); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(0n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + }); + + it("Should not allow to stake 0 amount", async function () { + const { + lpToken, liquidityHub, usdc, user, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + + await expect(liquidityMining.connect(user).stake(user.address, 0n, 0n)) + .to.be.revertedWithCustomError(liquidityMining, "InvalidAddedScore()"); + }); + + it("Should allow to restake by staking 0 amount", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + await liquidityMining.connect(user).stake(user.address, 10n * LP, 0n); + const extraSeconds = 100n; + await time.setNextBlockTimestamp(await now() + extraSeconds); + const tx = liquidityMining.connect(user).stake(user.address, 0n, 0n); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, 10n * LP * extraSeconds / (3n * MONTH)); + const until = await now() + 3n * MONTH; + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user.address, + 0n, + 10n * LP, + until, + 10n * LP * extraSeconds / (3n * MONTH), + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(10n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP * (3n * MONTH + extraSeconds) / (3n * MONTH)); + expect(await liquidityMining.balanceOf(user.address)).to.equal( + 10n * LP * (3n * MONTH + extraSeconds) / (3n * MONTH) + ); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(0n); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([10n * LP, 3n * MONTH, until, 100n]); + expect(await liquidityMining.stakes(user2.address)).to.eql([0n, 0n, 0n, 0n]); + }); + + it("Should allow to restake by staking positive amount", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 11n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 11n * USDC); + await liquidityHub.connect(user).deposit(11n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 11n * LP); + await liquidityMining.connect(user).stake(user.address, 10n * LP, 0n); + const extraSeconds = 100n; + await time.setNextBlockTimestamp(await now() + extraSeconds); + const addedScore = 10n * LP * extraSeconds / (3n * MONTH) + 1n * LP; + const tx = liquidityMining.connect(user).stake(user.address, 1n * LP, 0n); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(user.address, liquidityMining.target, 1n * LP); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, addedScore); + const until = await now() + 3n * MONTH; + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user.address, + 1n * LP, + 11n * LP, + until, + addedScore, + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(11n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP + addedScore); + expect(await liquidityMining.balanceOf(user.address)).to.equal(10n * LP + addedScore); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(0n); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(11n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([11n * LP, 3n * MONTH, until, 100n]); + expect(await liquidityMining.stakes(user2.address)).to.eql([0n, 0n, 0n, 0n]); + }); + + it("Should allow to restake into longer tier with 0 amount", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + await liquidityMining.connect(user).stake(user.address, 10n * LP, 0n); + const extraSeconds = 100n; + await time.setNextBlockTimestamp(await now() + extraSeconds); + const tx = liquidityMining.connect(user).stake(user.address, 0n, 1n); + const addedScore = 10n * LP * 150n / 100n - divCeil(10n * LP * (3n * MONTH - extraSeconds), 3n * MONTH); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, addedScore); + const until = await now() + 6n * MONTH; + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user.address, + 0n, + 10n * LP, + until, + addedScore, + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(10n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP + addedScore); + expect(await liquidityMining.balanceOf(user.address)).to.equal(10n * LP + addedScore); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(0n); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([10n * LP, 6n * MONTH, until, 150n]); + expect(await liquidityMining.stakes(user2.address)).to.eql([0n, 0n, 0n, 0n]); + }); + + it("Should allow to restake into longer tier with positive amount", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 11n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 11n * USDC); + await liquidityHub.connect(user).deposit(11n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 11n * LP); + await liquidityMining.connect(user).stake(user.address, 10n * LP, 0n); + const extraSeconds = 100n; + await time.setNextBlockTimestamp(await now() + extraSeconds); + const tx = liquidityMining.connect(user).stake(user.address, 1n * LP, 1n); + const addedScore = 11n * LP * 150n / 100n - divCeil(10n * LP * (3n * MONTH - extraSeconds), 3n * MONTH); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, addedScore); + const until = await now() + 6n * MONTH; + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user.address, + 1n * LP, + 11n * LP, + until, + addedScore, + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(11n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP + addedScore); + expect(await liquidityMining.balanceOf(user.address)).to.equal(10n * LP + addedScore); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(0n); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(11n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([11n * LP, 6n * MONTH, until, 150n]); + expect(await liquidityMining.stakes(user2.address)).to.eql([0n, 0n, 0n, 0n]); + }); + + it("Should allow to restake into shorter tier if remaining time is even shorter", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 11n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 11n * USDC); + await liquidityHub.connect(user).deposit(11n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 11n * LP); + await liquidityMining.connect(user).stake(user.address, 10n * LP, 1n); + const extraSeconds = 5n * MONTH; + await time.setNextBlockTimestamp(await now() + extraSeconds); + const tx = liquidityMining.connect(user).stake(user.address, 1n * LP, 0n); + const addedScore = 11n * LP - 10n * LP * (6n * MONTH - extraSeconds) * 150n / (100n * 6n * MONTH); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, addedScore); + const until = await now() + 3n * MONTH; + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user.address, + 1n * LP, + 11n * LP, + until, + addedScore, + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(11n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP * 150n / 100n + addedScore); + expect(await liquidityMining.balanceOf(user.address)).to.equal(10n * LP * 150n / 100n + addedScore); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(0n); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(11n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([11n * LP, 3n * MONTH, until, 100n]); + expect(await liquidityMining.stakes(user2.address)).to.eql([0n, 0n, 0n, 0n]); + }); + + it("Should allow to unstake after restaking", async function () { + const { + lpToken, liquidityHub, usdc, user, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + await liquidityMining.connect(user).stake(user.address, 10n * LP, 0n); + const extraSeconds = 100n; + await time.setNextBlockTimestamp(await now() + extraSeconds); + await liquidityMining.connect(user).stake(user.address, 0n, 0n); + await time.increase(3n * MONTH); + + const tx = liquidityMining.connect(user).unstake(user.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(liquidityMining.target, user.address, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "StakeUnlocked") + .withArgs(user.address, user.address, 10n * LP); + expect(await lpToken.balanceOf(user.address)).to.equal(10n * LP); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(0n); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP * (3n * MONTH + extraSeconds) / (3n * MONTH)); + expect(await liquidityMining.balanceOf(user.address)).to.equal( + 10n * LP * (3n * MONTH + extraSeconds) / (3n * MONTH) + ); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([0n, 0n, 0n, 0n]); + }); + + it("Should not allow to decrease remaining staking period", async function () { + const { + lpToken, liquidityHub, usdc, user, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 11n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 11n * USDC); + await liquidityHub.connect(user).deposit(11n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 11n * LP); + await liquidityMining.connect(user).stake(user.address, 10n * LP, 1n); + + await expect(liquidityMining.connect(user).stake(user.address, 1n * LP, 0n)) + .to.be.revertedWithCustomError(liquidityMining, "DecreasingPeriod()"); + await expect(liquidityMining.connect(user).stake(user.address, 0n, 0n)) + .to.be.revertedWithCustomError(liquidityMining, "DecreasingPeriod()"); + }); + + it("Should not allow to restake with 0 or negative added score", async function () { + const { + lpToken, liquidityHub, usdc, user, USDC, LP, + deployer, admin, + } = await loadFixture(deployAll); + + const tiers = [ + {period: 3n * MONTH, multiplier: 100n}, + {period: 6n * MONTH, multiplier: 10n}, + ]; + + const liquidityMining = ( + await deploy("SprinterLiquidityMining", deployer, {}, admin.address, liquidityHub.target, tiers) + ) as SprinterLiquidityMining; + + await usdc.connect(deployer).transfer(user.address, 11n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 11n * USDC); + await liquidityHub.connect(user).deposit(11n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 11n * LP); + await liquidityMining.connect(user).stake(user.address, 10n * LP, 0n); + + await expect(liquidityMining.connect(user).stake(user.address, 1n * LP, 1n)) + .to.be.revertedWithCustomError(liquidityMining, "InvalidAddedScore()"); + await expect(liquidityMining.connect(user).stake(user.address, 0n, 1n)) + .to.be.revertedWithCustomError(liquidityMining, "InvalidAddedScore()"); + }); + + it("Should not allow to unstake too early", async function () { + const { + lpToken, liquidityHub, usdc, user, USDC, LP, + liquidityMining, deployer, user2, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + await liquidityMining.connect(user).stake(user.address, 10n * LP, 0n); + + await time.increase(3n * MONTH - 10n); + + await expect(liquidityMining.connect(user).unstake(user.address)) + .to.be.revertedWithCustomError(liquidityMining, "Locked()"); + await expect(liquidityMining.connect(user).unstake(user2.address)) + .to.be.revertedWithCustomError(liquidityMining, "Locked()"); + }); + + it("Should not allow to unstake 0 amount", async function () { + const { + lpToken, liquidityHub, usdc, user, USDC, LP, + liquidityMining, deployer, user2, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + await liquidityMining.connect(user).stake(user.address, 10n * LP, 0n); + + await time.increase(3n * MONTH + 1n); + + await expect(liquidityMining.connect(user2).unstake(user2.address)) + .to.be.revertedWithCustomError(liquidityMining, "ZeroAmount()"); + await liquidityMining.connect(user).unstake(user.address); + await expect(liquidityMining.connect(user).unstake(user.address)) + .to.be.revertedWithCustomError(liquidityMining, "ZeroAmount()"); + }); + + it("Should not allow to deploy with invalid parameters", async function () { + const { + liquidityHub, deployer, admin, liquidityMining, + } = await loadFixture(deployAll); + + const tiers = [ + {period: 3n * MONTH, multiplier: 100n}, + {period: 6n * MONTH, multiplier: 10n}, + ]; + + await expect(deploy("SprinterLiquidityMining", deployer, {}, admin.address, ZERO_ADDRESS, tiers)) + .to.be.reverted; + await expect(deploy("SprinterLiquidityMining", deployer, {}, admin.address, liquidityHub.target, [])) + .to.be.revertedWithCustomError(liquidityMining, "EmptyInput()"); + const tiersZeroPeriod = [ + {period: 0n, multiplier: 100n}, + {period: 6n * MONTH, multiplier: 10n}, + ]; + await expect(deploy("SprinterLiquidityMining", deployer, {}, admin.address, liquidityHub.target, tiersZeroPeriod)) + .to.be.revertedWithCustomError(liquidityMining, "ZeroPeriod()"); + const tiersZeroMultiplier = [ + {period: 3n * MONTH, multiplier: 100n}, + {period: 6n * MONTH, multiplier: 0n}, + ]; + await expect( + deploy("SprinterLiquidityMining", deployer, {}, admin.address, liquidityHub.target, tiersZeroMultiplier) + ).to.be.revertedWithCustomError(liquidityMining, "ZeroMultiplier()"); + const tiersSamePeriod = [ + {period: 3n * MONTH, multiplier: 100n}, + {period: 3n * MONTH, multiplier: 10n}, + ]; + await expect( + deploy("SprinterLiquidityMining", deployer, {}, admin.address, liquidityHub.target, tiersSamePeriod) + ).to.be.revertedWithCustomError(liquidityMining, "DecreasingPeriod()"); + const tiersDecreasingPeriod = [ + {period: 3n * MONTH, multiplier: 100n}, + {period: 3n * MONTH - 1n, multiplier: 10n}, + ]; + await expect( + deploy("SprinterLiquidityMining", deployer, {}, admin.address, liquidityHub.target, tiersDecreasingPeriod) + ).to.be.revertedWithCustomError(liquidityMining, "DecreasingPeriod()"); + }); + + it("Should allow to deposit and stake", async function () { + const { + lpToken, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityMining.target, 10n * USDC); + const tx = liquidityMining.connect(user).depositAndStake(user2.address, 10n * USDC, 0n); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(user.address, liquidityMining.target, 10n * USDC); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(liquidityMining.target, liquidityPool.target, 10n * USDC); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(ZERO_ADDRESS, liquidityMining.target, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user2.address, 10n * LP); + const until = await now() + 3n * MONTH; + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user2.address, + 10n * LP, + 10n * LP, + until, + 10n * LP, + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(user2.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(10n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(0n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([10n * LP, 3n * MONTH, until, 100n]); + expect(await liquidityMining.stakes(user2.address)).to.eql([0n, 0n, 0n, 0n]); + }); + + it("Should allow to deposit and stake with permit", async function () { + const { + lpToken, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + + const domain = { + name: "Circle USD", + version: "1", + chainId: hre.network.config.chainId, + verifyingContract: await resolveAddress(usdc), + }; + + const types = { + Permit: [ + {name: "owner", type: "address"}, + {name: "spender", type: "address"}, + {name: "value", type: "uint256"}, + {name: "nonce", type: "uint256"}, + {name: "deadline", type: "uint256"}, + ], + }; + + const permitSig = Signature.from(await user.signTypedData(domain, types, { + owner: user.address, + spender: liquidityMining.target, + value: 10n * USDC, + nonce: 0n, + deadline: 2000000000n, + })); + + const tx = liquidityMining.connect(user).depositAndStakeWithPermit( + user2.address, + 10n * USDC, + 0n, + 2000000000n, + permitSig.v, + permitSig.r, + permitSig.s, + ); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(user.address, liquidityMining.target, 10n * USDC); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(liquidityMining.target, liquidityPool.target, 10n * USDC); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(ZERO_ADDRESS, liquidityMining.target, 10n * LP); + await expect(tx) + .to.emit(liquidityMining, "Transfer") + .withArgs(ZERO_ADDRESS, user2.address, 10n * LP); + const until = await now() + 3n * MONTH; + await expect(tx) + .to.emit(liquidityMining, "StakeLocked") + .withArgs( + user.address, + user2.address, + 10n * LP, + 10n * LP, + until, + 10n * LP, + ); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(user2.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(10n * LP); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(0n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(10n * USDC); + expect(await liquidityMining.stakes(user.address)).to.eql([10n * LP, 3n * MONTH, until, 100n]); + expect(await liquidityMining.stakes(user2.address)).to.eql([0n, 0n, 0n, 0n]); + }); + + it("Should allow to unstake and withdraw", async function () { + const { + lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, + liquidityMining, deployer, + } = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(liquidityMining.target, 10n * LP); + await liquidityMining.connect(user).stake(user2.address, 10n * LP, 0n); + + await time.increase(3n * MONTH); + + const tx = liquidityMining.connect(user).unstakeAndWithdraw(user2.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(liquidityMining.target, ZERO_ADDRESS, 10n * LP); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(liquidityPool.target, user2.address, 10n * USDC); + await expect(tx) + .to.emit(liquidityMining, "StakeUnlocked") + .withArgs(user.address, liquidityMining.target, 10n * LP); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.balanceOf(user2.address)).to.equal(0n); + expect(await lpToken.balanceOf(liquidityMining.target)).to.equal(0n); + expect(await liquidityMining.totalSupply()).to.equal(10n * LP); + expect(await liquidityMining.balanceOf(user.address)).to.equal(0n); + expect(await liquidityMining.balanceOf(user2.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(0n); + expect(await usdc.balanceOf(user.address)).to.equal(0n); + expect(await usdc.balanceOf(user2.address)).to.equal(10n * USDC); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts index b7abb37..f32c275 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -27,3 +27,10 @@ export function toBytes32(str: string) { if (str.length > 32) throw new Error("String too long"); return zeroPadBytes(toUtf8Bytes(str), 32); } + +export function divCeil(a: bigint, b: bigint): bigint { + if (a % b == 0n) { + return a / b; + } + return a / b + 1n; +}