Skip to content

Commit b4a2abb

Browse files
wip: staking
1 parent 8bff7e4 commit b4a2abb

File tree

4 files changed

+227
-0
lines changed

4 files changed

+227
-0
lines changed

src/EnsoMint.sol

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
pragma solidity ^0.8.20;
3+
4+
import { EnsoValidatorWallet } from "./EnsoValidatorWallet.sol";
5+
import { Ownable } from "openzeppelin-contracts/access/Ownable.sol";
6+
import { MintToken } from "./interfaces/MintToken.sol";
7+
8+
contract EnsoMint is Ownable {
9+
MintToken public token;
10+
mapping(address => bool) public validators;
11+
12+
uint256 public tokensPerSecond;
13+
uint64 public lastTimestamp;
14+
15+
struct ValidatorIssuance {
16+
address validator;
17+
uint256 shares;
18+
}
19+
20+
event FundsIssued(uint256 amount, uint256 period);
21+
event TokensPerSecondUpdated(uint256 amount);
22+
event ValidatorAdded(address validatorWallet);
23+
24+
error NotValidator(address account);
25+
error IncorrectTotalShares(uint256 expectedShares, uint256 actualShares);
26+
27+
constructor(address _owner, uint256 _tokensPerSecond) Ownable(_owner) {
28+
lastTimestamp = uint64(block.timestamp);
29+
tokensPerSecond = _tokensPerSecond;
30+
emit TokensPerSecondUpdated(_tokensPerSecond);
31+
}
32+
33+
function addValidator(address validator) external onlyOwner {
34+
EnsoValidatorWallet validatorWallet = new EnsoValidatorWallet(validator);
35+
validators[address(validatorWallet)] = true;
36+
emit ValidatorAdded(address(validatorWallet));
37+
}
38+
39+
function issueFunds(uint64 timestamp, uint256 totalShares, ValidatorIssuance[] calldata issuance) external onlyOwner {
40+
uint256 period = timestamp - lastTimestamp;
41+
uint256 totalFunds = tokensPerSecond * period;
42+
lastTimestamp = timestamp;
43+
44+
45+
uint256 shareCount;
46+
for (uint256 i; i < issuance.length; i++) {
47+
address validator = issuance[i].validator;
48+
if (!validators[validator]) revert NotValidator(validator);
49+
uint256 shares = issuance[i].shares;
50+
shareCount += shares;
51+
uint256 amount = totalFunds * shares / totalShares;
52+
uint256 validatorShare = amount / 2; // TODO: determine ratio
53+
token.mint(validator, validatorShare);
54+
uint256 stakersShare = amount - validatorShare;
55+
token.mint(address(this), stakersShare);
56+
token.approve(address(staking), stakersShare);
57+
staking.issueRewards(validator, stakersShare);
58+
}
59+
if (totalShares += shareCount) revert IncorrectTotalShares(totalShares, shareCount);
60+
emit FundsIssued(totalFunds, period);
61+
}
62+
63+
function updateTokensPerSecond(uint256 amount) external onlyOwner {
64+
tokensPerSecond = amount;
65+
emit TokensPerSecondUpdated(amount);
66+
}
67+
}

src/EnsoStaking.sol

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
pragma solidity ^0.8.20;
3+
4+
import { ERC721 } from "openzeppelin-contracts/token/ERC721/ERC721.sol";
5+
import { SafeERC20, IERC20 } from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol";
6+
import { Ownable } from "openzeppelin-contracts/access/Ownable.sol";
7+
8+
contract EnsoStaking is ERC721, Ownable {
9+
using SafeERC20 for IERC20;
10+
11+
IERC20 public immutable token;
12+
uint256 public nextPositionId;
13+
14+
uint256 maxMultiplier;
15+
uint256 maxRange;
16+
uint64 maxPeriod;
17+
uint64 minPeriod;
18+
19+
mapping(uint256 => Position) public positions;
20+
mapping(address => uint256) public stakes; // depositor => stake // TODO: purpose of this is informational, but maybe it could be used for voting in future? if so, should have a timestamp checkpoint
21+
mapping(address => uint256) public delegateStakes; // delegate => stake
22+
mapping(address => uint256) public rewardsPerStake; // delegate => value per stake;
23+
mapping(address => uint256) public totalRewards; // delegate => rewards earned;
24+
25+
uint256 private PRECISION = 10**18;
26+
27+
struct Position {
28+
uint64 expiry;
29+
address delegate;
30+
uint256 deposit;
31+
uint256 stake;
32+
uint256 rewardsCheckpoint;
33+
}
34+
35+
event NewPosition(uint256 positionId, uint64 expiry, address delegate);
36+
event Deposit(uint256 positionId, uint256 depositAdded, uint256 stakeAdded);
37+
event Redeem(uint256 positionId, uint256 depositRemoved, uint256 stakeRemoved);
38+
39+
40+
constructor(address _owner, uint64 _minPeriod, uint64 _maxPeriod, uint256 _maxMulitplier) Ownable(_owner) {
41+
minPeriod = _minPeriod;
42+
maxPeriod = _maxPeriod;
43+
maxRange = _maxPeriod - _minPeriod;
44+
maxMultiplier = _maxMulitplier;
45+
}
46+
47+
function createPosition(uint256 deposit, uint64 period, address receiver, address delegate) external {
48+
if (period > maxPeriod || period < minPeriod) revert InvalidStakingPeriod(period);
49+
if (!validators[delegate]) revert NotValidator(delegate);
50+
token.safeTransferFrom(msg.sender, address(this), deposit);
51+
uint256 range = period - minPeriod;
52+
uint256 stake = deposit + (deposit * maxMultiplier * range / maxRange);
53+
uint64 expiry = block.timestamp + period;
54+
uint256 positionId = nextPositionId;
55+
nextPositionId++;
56+
_mint(receiver, positionId);
57+
positions[positionId] = Position(expiry, delegate, deposit, stake, rewardsPerStake[delegate]);
58+
stakes[receiver] += stake;
59+
delegatedStakes[delegate] += stake;
60+
emit NewPosition(positionId, expiry);
61+
emit Deposit(positionId, deposit, stake);
62+
}
63+
64+
function deposit(uint256 positionId, uint256 amount) public {
65+
collectRewards(positionId);
66+
67+
address account = _ownerOf(positionId);
68+
Position storage position = positions[positionId];
69+
70+
uint64 timestamp = block.timestamp;
71+
uint256 stake;
72+
// multiplier is only applied if the period left til expiry is more than the min period
73+
if (position.expiry <= (timestamp + minPeriod)) {
74+
stake = amount;
75+
} else {
76+
uint256 period = position.expiry - timestamp;
77+
uint256 range = period - minPeriod;
78+
stake = amount + (amount * maxMultiplier * range / maxRange);
79+
}
80+
position.stake += stake;
81+
position.deposit += amount;
82+
stakes[account] += stake;
83+
delegatedStakes[position.delegate] += stake;
84+
emit Deposit(positionId, amount, stake);
85+
}
86+
87+
function redeem(uint256 positionId, uint256 amount, address receiver) external {
88+
collectRewards(positionId);
89+
90+
address account = _ownerOf(positionId);
91+
if (msg.sender != account) revert InvalidSender(account, msg.sender);
92+
93+
Position storage position = positions[positionId];
94+
if (block.timestamp < position.expiry) revert PositionNotExpired(position.expiry, block.timestamp);
95+
if (amount > position.stake) revert InsufficientStake(position.stake, amount);
96+
97+
uint256 withdraw = position.deposit * amount / position.stake;
98+
position.stake -= amount;
99+
position.deposit -= withdraw;
100+
stakes[account] -= amount;
101+
delegatedStakes[position.delegate] -= amount;
102+
token.safeTransfer(receiver, withdraw);
103+
emit Redeem(positionId, withdraw, amount);
104+
}
105+
106+
function issueRewards(address delegate, uint256 amount) external {
107+
token.safeTransferFrom(msg.sender, address(this), amount);
108+
rewardsPerStake[delegate] += amount * PRECISION / delegateStakes[delegate];
109+
totalRewards[delegate] += amount;
110+
}
111+
112+
// TODO: here we are sending rewards to the user, but instead do we want to keep funds on contract and update a mapping?
113+
// this way we could claim from many positions and only transfer once, or claim and reinvest. this goes for deposit and
114+
// redeem too. do we just want to claim rewards without collecting them?
115+
function collectRewards(uint256 positionId) public {
116+
if (positionId >= nextPositionId) revert InvalidPositionId(positionId);
117+
Position storage position = positions[positionId];
118+
uint256 rewards = _availableRewards(position);
119+
position.rewardsCheckpoint = rewardsPerStake[position.delegate];
120+
address account = _ownerOf(positionId);
121+
token.safeTransfer(account, rewards);
122+
emit RewardsCollected(positionId, account, rewards);
123+
}
124+
125+
function availableRewards(uint256 positionId) external view returns (uint256 rewards) {
126+
Position memory position = positions[positionId];
127+
rewards = _availableRewards(position);
128+
}
129+
130+
function _availableRewards(Position memory position) internal view returns (uint256 rewards) {
131+
uint256 rewardDiff = rewardsPerStake[position.delegate] - position.rewardsCheckpoint;
132+
rewards = rewardDiff * position.stake / PRECISION;
133+
}
134+
}

src/EnsoValidatorWallet.sol

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
pragma solidity ^0.8.20;
3+
4+
import { SafeERC20, IERC20 } from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol";
5+
import { Ownable } from "openzeppelin-contracts/access/Ownable.sol";
6+
7+
contract EnsoValidatorWallet is Ownable {
8+
using SafeERC20 for IERC20;
9+
10+
IERC20 public immutable token;
11+
12+
13+
constructor(address owner) Ownable(owner) {
14+
// all state set in Ownable constructor
15+
}
16+
17+
function withdraw(address receiver, uint256 amount) external onlyOwner {
18+
token.safeTransfer(receiver, amount);
19+
}
20+
}

src/interfaces/MintToken.sol

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
pragma solidity ^0.8.0;
3+
4+
interface MintToken {
5+
function mint(address to, uint256 amount) external;
6+
}

0 commit comments

Comments
 (0)