Skip to content

Commit

Permalink
periphery: pre-launch deposit contract draft
Browse files Browse the repository at this point in the history
  • Loading branch information
kyriediculous committed Jul 17, 2024
1 parent 1ddd1de commit 2d8cbbe
Show file tree
Hide file tree
Showing 2 changed files with 398 additions and 0 deletions.
236 changes: 236 additions & 0 deletions src/periphery/PreLaunch.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
pragma solidity ^0.8.25;

import { UD60x18, ud, UNIT as UNIT_60x18, ZERO as ZERO_60x18 } from "@prb/math/UD60x18.sol";
import { ERC20 } from "@solady/tokens/ERC20.sol";
import { WETH } from "@solady/tokens/WETH.sol";
import { SafeTransferLib } from "@solady/utils/SafeTransferLib.sol";

import { OwnableUpgradeable } from "@openzeppelin/upgradeable/access/OwnableUpgradeable.sol";
import { Initializable } from "@openzeppelin/upgradeable/proxy/utils/Initializable.sol";
import { UUPSUpgradeable } from "@openzeppelin/upgradeable/proxy/utils/UUPSUpgradeable.sol";

import { LpETH } from "@/lpETH/LPETH.sol";

address payable constant weth = payable(address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2));

interface VotingEscrow {
function lockFor(address, uint256 amount, uint256 duration) external;
}

struct Lockup {
uint256 amount;
uint256 duration; // In epochs
}

struct Config {
uint256 cap;
uint256 deadline;
uint256 minLockup;
uint256 maxLockup;
uint256 epochLength;
UD60x18 maxMultiplier;
UD60x18 slope;
}

error InvalidDuration();
error Inactive();
error NotClaimable();
error CapExceeded();

contract PreLaunch is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public immutable cap; // Maximum amount of deposits allowed
uint256 public immutable deadline; // Deadline for deposits
UD60x18 internal immutable MIN_LOCKUP_DURATION;
UD60x18 internal immutable MAX_LOCKUP_DURATION;
UD60x18 internal immutable MAX_MULTIPLIER;
UD60x18 internal immutable SLOPE;
uint256 internal immutable EPOCH_LENGTH;

uint256 totalWeightedDeposits; // Total weighted deposits
uint256 totalDeposits; // Total deposits
address public votingEscrow; // Voting escrow contract
address payable lpEth; // LP token for lpETH
uint96 claimableTimestamp; // Timestamp when deposits become claimable
uint256 lpEthReceived = 0;

mapping(address account => Lockup) internal lockups;

constructor(Config memory _config) {
cap = _config.cap;
deadline = _config.deadline;
MIN_LOCKUP_DURATION = UD60x18.wrap(_config.minLockup * 1e18);
MAX_LOCKUP_DURATION = UD60x18.wrap(_config.maxLockup * 1e18);
MAX_MULTIPLIER = _config.maxMultiplier;
SLOPE = _config.slope;
EPOCH_LENGTH = _config.epochLength;
_disableInitializers();
}

function initialize() public initializer {
__Ownable_init(msg.sender);
__UUPSUpgradeable_init();
}

receive() external payable { }

function lockup(address account) external view returns (Lockup memory) {
return lockups[account];
}

function isActive() public view returns (bool) {
return block.timestamp <= deadline;
}

function isClaimable() public view returns (bool) {
return votingEscrow != address(0);
}

function setLpEth(address payable _lpEth) external onlyOwner {
if (lpEth != address(0)) {
revert();
}
lpEth = _lpEth;
}

function setVotingEscrow(address _votingEscrow) external onlyOwner {
if (votingEscrow != address(0)) {
revert();
}
votingEscrow = _votingEscrow;
claimableTimestamp = uint96(block.timestamp);
}

function mintLpEth(uint256 minLpShares) external onlyOwner {
if (lpEth == address(0)) {
revert();
}

uint256 lpShares = LpETH(lpEth).deposit{ value: address(this).balance }(minLpShares);
lpEthReceived += lpShares;
}

function depositETH(uint256 duration) external payable {
_deposit(msg.value, duration);
}

function depositWETH(uint256 amount, uint256 duration) external {
SafeTransferLib.safeTransferFrom(weth, msg.sender, address(this), amount);
SafeTransferLib.safeApprove(weth, weth, amount);
WETH(weth).withdraw(amount);
_deposit(amount, duration);
}

function _deposit(uint256 amount, uint256 duration) internal {
if (!isActive()) {
revert Inactive();
}
if (!isValidDuration(duration)) {
revert InvalidDuration();
}

if (totalDeposits + amount > cap) {
revert CapExceeded();
}

// Since we allow changing the lockup before the deadline
// When a user has an existing deposit, and his new deposit has a different lockup,
// We adopt the latest lockup set.
// 1. Calculate existing weighted deposit
// 2. Subtract the existing weighted deposit from the totalWeightedDeposits
// 3. Add the new weighted deposit to the totalWeightedDeposits
// 4. Update the lockup
// 5. Update the totalDeposits

Lockup storage lockup = lockups[msg.sender];
if (lockup.amount > 0) {
uint256 existingWeightedDeposit = calculateWeightedDeposit(lockup.amount, lockup.duration);
unchecked {
totalWeightedDeposits -= existingWeightedDeposit;
}
}

uint256 weightedDeposit = calculateWeightedDeposit(amount + lockup.amount, duration);
totalWeightedDeposits += weightedDeposit;
totalDeposits += amount;

lockups[msg.sender] = Lockup({ amount: amount + lockup.amount, duration: duration });
}

function withdraw(uint256 amount) external {
if (!isActive()) {
revert Inactive();
}

// 1. Calculate the weighted deposit
// 2. Subtract the weighted deposit from the totalWeightedDeposits
// 3. Calculate the weighted deposit based on the remaining balance
// 4. Add the new weighted deposit to the totalWeightedDeposits
// 5. Update the lockup
// 6. Update the totalDeposits
Lockup storage lockup = lockups[msg.sender];

uint256 weightedDeposit = calculateWeightedDeposit(lockup.amount, lockup.duration);
totalWeightedDeposits -= weightedDeposit;
uint256 remainingAmount = lockup.amount - amount;
uint256 remainingWeightedDeposit = calculateWeightedDeposit(remainingAmount, lockup.duration);
totalWeightedDeposits += remainingWeightedDeposit;
unchecked {
totalDeposits -= amount;
}
lockup.amount = remainingAmount;

payable(msg.sender).transfer(amount);
}

function changeLockup(uint256 duration) external {
if (!isActive()) {
revert Inactive();
}
if (!isValidDuration(duration)) {
revert InvalidDuration();
}

Lockup storage lockup = lockups[msg.sender];

uint256 weightedDeposit = calculateWeightedDeposit(lockup.amount, lockup.duration);
totalWeightedDeposits -= weightedDeposit;
uint256 newWeightedDeposit = calculateWeightedDeposit(lockup.amount, duration);
totalWeightedDeposits += newWeightedDeposit;
lockup.duration = duration;
}

function claimVeTokens() external {
if (!isClaimable()) {
revert NotClaimable();
}
Lockup storage lockup = lockups[msg.sender];
// Account for elapsed time since the deposits became claimable in epochs
uint256 epochsElapsedSinceClaimable = (block.timestamp - claimableTimestamp) / EPOCH_LENGTH;
uint256 lpEthAmount = lockup.amount * lpEthReceived / totalDeposits;
SafeTransferLib.safeApprove(lpEth, votingEscrow, lpEthAmount);
VotingEscrow(votingEscrow).lockFor(msg.sender, lpEthAmount, lockup.duration - epochsElapsedSinceClaimable);
delete lockups[msg.sender];
}

function calculateWeightedDeposit(uint256 amount, uint256 epochs) public view returns (uint256) {
UD60x18 durationUD = UD60x18.wrap(epochs * 1e18);
if (durationUD.lt(MIN_LOCKUP_DURATION)) {
return 0;
}
return UD60x18.wrap(amount).mul(
MAX_MULTIPLIER.mul(
durationUD.sub(MIN_LOCKUP_DURATION).div(MAX_LOCKUP_DURATION - MIN_LOCKUP_DURATION).pow(SLOPE)
)
).unwrap();
}

function isValidDuration(uint256 duration) internal view returns (bool) {
// We compare the unscaled version of epochs so we increase stepwise per epoch
// If we compare against the fixed point version, we can end up in between epochs
return duration >= MIN_LOCKUP_DURATION.unwrap() / 1e18 && duration <= MAX_LOCKUP_DURATION.unwrap() / 1e18;
}

///@dev required by the OZ UUPS module
// solhint-disable-next-line no-empty-blocks
function _authorizeUpgrade(address) internal override onlyOwner { }
}
162 changes: 162 additions & 0 deletions test/periphery/PreLaunch.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
pragma solidity ^0.8.25;

import "forge-std/Test.sol";
import {
PreLaunch,
Config,
Lockup,
VotingEscrow,
weth,
CapExceeded,
Inactive,
InvalidDuration
} from "@/periphery/PreLaunch.sol";
import { WETH } from "@solady/tokens/WETH.sol";
import { UD60x18, ud } from "@prb/math/UD60x18.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

contract PreLaunchHarness is PreLaunch {
constructor(Config memory _config) PreLaunch(_config) { }

function lpEthAddress() public view returns (address) {
return lpEth;
}
}

contract PreLaunchTest is Test {
PreLaunchHarness preLaunch;
address owner = address(0x123);
address depositor = address(0x456);
address newLpEth = address(0x789);
address newVotingEscrow = address(0xabc);

Config config;

function setUp() public {
vm.createSelectFork(vm.envString("MAINNET_RPC"), 19_847_895);

config = Config({
cap: 100 ether,
deadline: block.timestamp + 7 days,
minLockup: 1,
maxLockup: 52,
epochLength: 1 weeks,
maxMultiplier: ud(3e18),
slope: ud(2e18)
});

address preLaunchImplementation = address(new PreLaunchHarness(config));

preLaunch = PreLaunchHarness(payable(address(new ERC1967Proxy(address(preLaunchImplementation), ""))));

preLaunch.initialize();

preLaunch.transferOwnership(owner);

vm.deal(depositor, 100 ether);
}

function testIsActive() public {
assertTrue(preLaunch.isActive());
}

function testIsClaimable() public {
assertFalse(preLaunch.isClaimable());
vm.startPrank(owner);
preLaunch.setVotingEscrow(newVotingEscrow);
vm.stopPrank();
assertTrue(preLaunch.isClaimable());
}

function testSetLpEth() public {
vm.startPrank(owner);
preLaunch.setLpEth(payable(newLpEth));
assertEq(preLaunch.lpEthAddress(), newLpEth);
vm.expectRevert();
preLaunch.setLpEth(payable(newLpEth));
vm.stopPrank();
}

function testSetVotingEscrow() public {
vm.startPrank(owner);
preLaunch.setVotingEscrow(newVotingEscrow);
assertEq(preLaunch.votingEscrow(), newVotingEscrow);
vm.expectRevert();
preLaunch.setVotingEscrow(newVotingEscrow);
vm.stopPrank();
}

function testMintLpEth() public {
vm.startPrank(owner);
preLaunch.setLpEth(payable(newLpEth));
vm.deal(address(preLaunch), 1 ether);
preLaunch.mintLpEth(1 ether);
vm.stopPrank();
}

function testDepositETH() public {
vm.startPrank(depositor);
preLaunch.depositETH{ value: 1 ether }(4);
Lockup memory lockup = preLaunch.lockup(depositor);
assertEq(lockup.amount, 1 ether);
assertEq(lockup.duration, 4);
vm.stopPrank();
}

function testDepositWETH() public {
address wethHolder = 0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E;
vm.startPrank(wethHolder);
WETH(weth).transfer(depositor, 1 ether);
vm.stopPrank();

vm.startPrank(depositor);
WETH(weth).approve(address(preLaunch), 1 ether);
preLaunch.depositWETH(1 ether, 4);
Lockup memory lockup = preLaunch.lockup(depositor);
assertEq(lockup.amount, 1 ether);
assertEq(lockup.duration, 4);
vm.stopPrank();
}

function testWithdraw() public {
vm.startPrank(depositor);
preLaunch.depositETH{ value: 1 ether }(4);
preLaunch.withdraw(0.5 ether);
Lockup memory lockup = preLaunch.lockup(depositor);
assertEq(lockup.amount, 0.5 ether);
vm.stopPrank();
}

function testChangeLockup() public {
vm.startPrank(depositor);
preLaunch.depositETH{ value: 1 ether }(4);
preLaunch.changeLockup(8);
Lockup memory lockup = preLaunch.lockup(depositor);
assertEq(lockup.duration, 8);
vm.stopPrank();
}

function testInvalidDuration() public {
vm.startPrank(depositor);
vm.expectRevert(abi.encodeWithSelector(InvalidDuration.selector));
preLaunch.depositETH{ value: 1 ether }(0);
vm.stopPrank();
}

function testInactiveDeposit() public {
vm.warp(config.deadline + 1);
vm.startPrank(depositor);
vm.expectRevert(abi.encodeWithSelector(Inactive.selector));
preLaunch.depositETH{ value: 1 ether }(4);
vm.stopPrank();
}

function testCapExceeded() public {
vm.deal(depositor, 101 ether);
vm.startPrank(depositor);
preLaunch.depositETH{ value: 50 ether }(4);
vm.expectRevert(abi.encodeWithSelector(CapExceeded.selector));
preLaunch.depositETH{ value: 51 ether }(4);
vm.stopPrank();
}
}

0 comments on commit 2d8cbbe

Please sign in to comment.