Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion contracts/LiquidityHub.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
178 changes: 178 additions & 0 deletions contracts/LiquidityMining.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
89 changes: 89 additions & 0 deletions contracts/SprinterLiquidityMining.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
8 changes: 8 additions & 0 deletions contracts/interfaces/ILiquidityHub.sol
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}
);
29 changes: 27 additions & 2 deletions scripts/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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();
Expand Down
Loading