diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3bad027 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-foundry-upgrades"] + path = lib/openzeppelin-foundry-upgrades + url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index e46926d..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "solidity.compileUsingRemoteVersion": "v0.8.19+commit.7dd6d404" -} diff --git a/contracts/interfaces/IL2Staking.sol b/contracts/interfaces/IL2Staking.sol index 4513590..de58d08 100644 --- a/contracts/interfaces/IL2Staking.sol +++ b/contracts/interfaces/IL2Staking.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.0; interface IL2Staking { - function stake(address generatorAddress, uint256 amount) external returns (uint256); - function intendToReduceStake(uint256 stakeToReduce) external; + function stake(address generatorAddress, uint256 amount) external returns (uint256); + function unstake(address receiver) external; } diff --git a/contracts/interfaces/staking/IInflationRewardManager.sol b/contracts/interfaces/staking/IInflationRewardManager.sol new file mode 100644 index 0000000..25da537 --- /dev/null +++ b/contracts/interfaces/staking/IInflationRewardManager.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +interface IInflationRewardManager { + function updatePendingInflationReward(address _operator) external returns (uint256 timestampIdx, uint256 pendingInflationReward); + + function updateEpochTimestampIdx() external; + + function transferInflationRewardToken(address _to, uint256 _amount) external; +} \ No newline at end of file diff --git a/contracts/interfaces/staking/IJobManager.sol b/contracts/interfaces/staking/IJobManager.sol new file mode 100644 index 0000000..b990d73 --- /dev/null +++ b/contracts/interfaces/staking/IJobManager.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +interface IJobManager { + function createJob(uint256 _jobId, address _requester, address _operator, uint256 _feeAmount) external; + + function submitProof(uint256 jobId, bytes calldata proof) external; + + function refundFee(uint256 jobId) external; + + function operatorRewardShares(address _operator) external view returns (uint256); +} \ No newline at end of file diff --git a/contracts/interfaces/staking/INativeStaking.sol b/contracts/interfaces/staking/INativeStaking.sol new file mode 100644 index 0000000..2c586ff --- /dev/null +++ b/contracts/interfaces/staking/INativeStaking.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {IStakingPool} from "../staking/IStakingPool.sol"; + +interface INativeStaking is IStakingPool { + + function stake(address stakeToken, address operator, uint256 amount) external; + + // TODO: check if timestamp is needed + event Staked(address indexed account, address indexed operator, address indexed token, uint256 amount, uint256 timestamp); + event StakeWithdrawn(address indexed account, address indexed operator, address indexed token, uint256 amount, uint256 timestamp); +} \ No newline at end of file diff --git a/contracts/interfaces/staking/INativeStakingReward.sol b/contracts/interfaces/staking/INativeStakingReward.sol new file mode 100644 index 0000000..3da3314 --- /dev/null +++ b/contracts/interfaces/staking/INativeStakingReward.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {IRewardDistributor} from "./IRewardDistributor.sol"; + +interface INativeStakingReward is IRewardDistributor { + function update(address account, address _stakeToken, address _operator) external; +} \ No newline at end of file diff --git a/contracts/interfaces/staking/IRewardDistributor.sol b/contracts/interfaces/staking/IRewardDistributor.sol new file mode 100644 index 0000000..7d797c6 --- /dev/null +++ b/contracts/interfaces/staking/IRewardDistributor.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +interface IRewardDistributor { + function updateFeeReward(address _stakeToken, address _operator, uint256 _rewardAmount) external; + + function updateInflationReward(address _operator, uint256 _rewardAmount) external; + + function onStakeUpdate(address _account, address _stakeToken, address _operator) external; + + function onClaimReward(address _account, address _operator) external; + + function onSlash() external; + + function setStakeToken(address _stakingPool, bool _isSupported) external; +} \ No newline at end of file diff --git a/contracts/interfaces/staking/IStakingManager.sol b/contracts/interfaces/staking/IStakingManager.sol new file mode 100644 index 0000000..3090b38 --- /dev/null +++ b/contracts/interfaces/staking/IStakingManager.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +import {Struct} from "../../lib/staking/Struct.sol"; + +pragma solidity ^0.8.26; + +interface IStakingManager { + function onJobCreation(uint256 jobId, address operator) external; + + function onJobCompletion(uint256 jobId, address operator, uint256 feePaid) external; + + function onSlashResult(Struct.JobSlashed[] calldata slashedJobs) external; + + function distributeInflationReward(address operator, uint256 rewardAmount, uint256 timestampIdx) external; + + function getPoolConfig(address pool) external view returns (Struct.PoolConfig memory); +} \ No newline at end of file diff --git a/contracts/interfaces/staking/IStakingPool.sol b/contracts/interfaces/staking/IStakingPool.sol new file mode 100644 index 0000000..cf743b3 --- /dev/null +++ b/contracts/interfaces/staking/IStakingPool.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Struct} from "../../lib/staking/Struct.sol"; + +interface IStakingPool { + function isSupportedStakeToken(address stakeToken) external view returns (bool); + + function lockStake(uint256 jobId, address operator) external; // Staking Manager only + + function onJobCompletion(uint256 jobId, address operator, uint256 feeRewardAmount, uint256 inflationRewardAmount, uint256 timestampIdx) external; // Staking Manager only + + function slash(Struct.JobSlashed[] calldata slashedJobs) external; // Staking Manager only + + function getOperatorStakeAmount(address stakeToken, address operator) external view returns (uint256); + + function getOperatorActiveStakeAmount(address stakeToken, address operator) external view returns (uint256); + + function rewardDistributor() external view returns (address); + + function distributeInflationReward(address operator, uint256 rewardAmount, uint256 timestampIdx) external; // Staking Manager only + + function getStakeTokenList() external view returns (address[] memory); + + function getStakeTokenWeights() external view returns (address[] memory, uint256[] memory); + + function tokenSelectionWeightSum() external view returns (uint256); + + function getStakeAmount(address stakeToken, address staker, address operator) external view returns (uint256); +} \ No newline at end of file diff --git a/contracts/interfaces/staking/ISymbioticStaking.sol b/contracts/interfaces/staking/ISymbioticStaking.sol new file mode 100644 index 0000000..2179f1b --- /dev/null +++ b/contracts/interfaces/staking/ISymbioticStaking.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {IStakingPool} from "./IStakingPool.sol"; + +import {Struct} from "../../lib/staking/Struct.sol"; + +interface ISymbioticStaking is IStakingPool { + function submitVaultSnapshot( + uint256 _index, + uint256 _numOfTxs, // number of total transactions + bytes calldata _vaultSnapshotData, + bytes calldata _signature + ) external; + + function submitSlashResult( + uint256 _index, + uint256 _numOfTxs, // number of total transactions + bytes memory _slashResultData, + bytes memory _signature + ) external; + + function getTxCountInfo(uint256 _captureTimestamp, address _transmitter, bytes32 _type) external view returns (Struct.SnapshotTxCountInfo memory); + + function getSubmissionStatus(uint256 _captureTimestamp, address _transmitter) external view returns (bytes32); + + + + function confirmedTimestampInfo(uint256 _idx) external view returns (Struct.ConfirmedTimestamp memory); + + // event OperatorSnapshotSubmitted + + // event VaultSnapshotSubmitted + + // event SlashResultSubmitted + + // event SubmissionCompleted + + /// @notice Returns the captureTimestamp of latest completed snapshot submission + function latestConfirmedTimestamp() external view returns (uint256); + + /// @notice Returns the timestampIdx of latest completed snapshot submission + function latestConfirmedTimestampIdx() external view returns (uint256); +} diff --git a/contracts/interfaces/staking/ISymbioticStakingReward.sol b/contracts/interfaces/staking/ISymbioticStakingReward.sol new file mode 100644 index 0000000..da9c7b8 --- /dev/null +++ b/contracts/interfaces/staking/ISymbioticStakingReward.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +import {Struct} from "../../lib/staking/Struct.sol"; + +pragma solidity ^0.8.26; + +interface ISymbioticStakingReward { + function claimReward(address _operator) external; + + function updateFeeReward(address _stakeToken, address _operator, uint256 _amount) external; + + function updateInflationReward(address _operator, uint256 _rewardAmount) external; + + function onSnapshotSubmission(Struct.VaultSnapshot[] calldata _vaultSnapshots) external; + + function onSnapshotSubmission(address _vault, address _operator) external; +} \ No newline at end of file diff --git a/contracts/lib/staking/Struct.sol b/contracts/lib/staking/Struct.sol new file mode 100644 index 0000000..c9142e8 --- /dev/null +++ b/contracts/lib/staking/Struct.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +library Struct { + + /*=========================== Job Manager =============================*/ + struct JobInfo { + address requester; + address operator; + uint256 feePaid; + uint256 deadline; + } + + /*========================= Staking Manager ===========================*/ + + struct PoolConfig { + uint256 share; + bool enabled; + } + + /*=========================== Staking Pool ============================*/ + + struct PoolLockInfo { + address token; + uint256 amount; + address transmitter; + } + + /*========================== Native Staking ===========================*/ + + struct NativeStakingLock { + address token; + uint256 amount; + } + + struct JobSlashed { + uint256 jobId; + address operator; // TODO: check if cheaper than pulling from JobManager + address rewardAddress; + } + + struct WithdrawalRequest { + address stakeToken; + uint256 amount; + uint256 withdrawalTime; + } + + /*========================= Symbiotic Staking =========================*/ + + struct VaultSnapshot { + address operator; + address vault; + address stakeToken; + uint256 stakeAmount; + } + + struct SnapshotTxCountInfo { + uint256 idxToSubmit; // idx of pratial snapshot tx to submit + uint256 numOfTxs; // total number of txs for the snapshot + } + + struct ConfirmedTimestamp { + uint256 captureTimestamp; + address transmitter; + uint256 transmitterComissionRate; + } + + struct SymbioticStakingLock { + address stakeToken; + uint256 amount; + } +} \ No newline at end of file diff --git a/contracts/staking/l2_contracts/InflationRewardManger.sol b/contracts/staking/l2_contracts/InflationRewardManger.sol new file mode 100644 index 0000000..5054b2f --- /dev/null +++ b/contracts/staking/l2_contracts/InflationRewardManger.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +/* Parent Contracts */ +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; + +/* Interfaces */ +import {IJobManager} from "../../interfaces/staking/IJobManager.sol"; +import {IInflationRewardManager} from "../../interfaces/staking/IInflationRewardManager.sol"; +import {IStakingManager} from "../../interfaces/staking/IStakingManager.sol"; +import {ISymbioticStaking} from "../../interfaces/staking/ISymbioticStaking.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/* Libraries */ +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract InflationRewardManager is + ContextUpgradeable, + ERC165Upgradeable, + AccessControlUpgradeable, + UUPSUpgradeable, + ReentrancyGuardUpgradeable, + IInflationRewardManager +{ + using SafeERC20 for IERC20; + + // gaps in case we new vars in same file + uint256[500] private __gap_0; + + /* config */ + uint256 public startTime; + + /* contract addresses */ + address public jobManager; + address public stakingManager; + address public symbioticStaking; + address public symbioticStakingReward; + + /* reward config */ + address public inflationRewardToken; + uint256 public inflationRewardEpochSize; + uint256 public inflationRewardPerEpoch; + + // gaps in case we new vars in same file + uint256[500] private __gap_1; + + // last epoch when operator completed a job + mapping(address operator => uint256 lastJobCompletionEpoch) lastJobCompletionEpochs; + + // count of jobs done by operator in an epoch + mapping(uint256 epoch => mapping(address operator => uint256 count)) operatorJobCountsPerEpoch; + // total count of jobs done in an epoch + mapping(uint256 epoch => uint256 totalCount) totalJobCountsPerEpoch; + // timestampIdx of the latestConfirmedTimestamp at the time of job completion or snapshot submission + mapping(uint256 epoch => uint256 timestampIdx) epochTimestampIdx; + + modifier onlyJobManager() { + require(msg.sender == jobManager, "InflationRewardManager: Only JobManager"); + _; + } + + modifier onlySymbioticStaking() { + require(msg.sender == symbioticStaking || msg.sender == symbioticStakingReward, "InflationRewardManager: Only SymbioticStaking or SymbioticStakingReward"); + _; + } + + /*==================================================== initialize ===================================================*/ + + function initialize( + address _admin, + uint256 _startTime, + address _jobManager, + address _stakingManager, + address _symbioticStaking, + address _symbioticStakingReward, + address _inflationRewardToken, + uint256 _inflationRewardEpochSize, + uint256 _inflationRewardPerEpoch + ) public initializer { + __Context_init_unchained(); + __ERC165_init_unchained(); + __AccessControl_init_unchained(); + __UUPSUpgradeable_init_unchained(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + + require(_jobManager != address(0), "InflationRewardManager: jobManager address is zero"); + jobManager = _jobManager; + + require(_stakingManager != address(0), "InflationRewardManager: stakingManager address is zero"); + stakingManager = _stakingManager; + + require(_symbioticStaking != address(0), "InflationRewardManager: symbioticStaking address is zero"); + symbioticStaking = _symbioticStaking; + + require(_symbioticStakingReward != address(0), "InflationRewardManager: symbioticStakingReward address is zero"); + symbioticStakingReward = _symbioticStakingReward; + + require(_startTime > 0, "InflationRewardManager: startTime is zero"); + startTime = _startTime; + + require(_inflationRewardToken != address(0), "InflationRewardManager: inflationRewardToken address is zero"); + inflationRewardToken = _inflationRewardToken; + + require(_inflationRewardEpochSize > 0, "InflationRewardManager: inflationRewardEpochSize is zero"); + inflationRewardEpochSize = _inflationRewardEpochSize; + + require(_inflationRewardPerEpoch > 0, "InflationRewardManager: inflationRewardPerEpoch is zero"); + inflationRewardPerEpoch = _inflationRewardPerEpoch; + } + + /*===================================================== external ====================================================*/ + + /// @notice update pending inflation reward for given operator + /// @dev called by JobManager when job is completed or by RewardDistributor when operator is slashed + function updatePendingInflationReward(address _operator) external returns (uint256 timestampIdx, uint256 pendingInflationReward) { + uint256 currentEpoch = (block.timestamp - startTime) / inflationRewardEpochSize; + uint256 operatorLastEpoch = lastJobCompletionEpochs[_operator]; + + // no need to update and distribute pending inflation reward + if (operatorLastEpoch == currentEpoch) { + // return address(0) as the transmitter value will not be used + return (0, 0); + } + + // when job is completed, increase job count + if(msg.sender == stakingManager) { + _increaseJobCount(_operator, currentEpoch); + } + + uint256 operatorLastEpochJobCount = operatorJobCountsPerEpoch[operatorLastEpoch][_operator]; + uint256 operatorCurrentEpochJobCount = operatorJobCountsPerEpoch[currentEpoch][_operator]; + + // if operator has not completed any job + if(operatorLastEpochJobCount == 0 && operatorCurrentEpochJobCount == 0) { + // return address(0) as the transmitter value will not be used + return (0, 0); + } + + timestampIdx = epochTimestampIdx[operatorLastEpoch]; + + // when operator has done job in last epoch, distribute inflation reward + // if 0, it means pendingInflationReward was updated and no job has been done + if(operatorLastEpochJobCount > 0) { + uint256 lastEpochTotalJobCount = totalJobCountsPerEpoch[operatorLastEpoch]; + + pendingInflationReward = Math.mulDiv( + inflationRewardPerEpoch, operatorLastEpochJobCount, lastEpochTotalJobCount + ); + + // TODO: check logic + if(pendingInflationReward > IERC20(inflationRewardToken).balanceOf(address(this))) { + pendingInflationReward = IERC20(inflationRewardToken).balanceOf(address(this)); + } + + // operator deducts comission from inflation reward + uint256 operatorComission = Math.mulDiv( + pendingInflationReward, IJobManager(jobManager).operatorRewardShares(_operator), 1e18 + ); + + IERC20(inflationRewardToken).safeTransfer(_operator, operatorComission); + + pendingInflationReward -= operatorComission; + } + + // when job is completed, inflation reward with distributed by JobManager along with fee reward + if(msg.sender != stakingManager && pendingInflationReward > 0) { + // staking manager will distribute inflation reward based on each pool's share + IStakingManager(stakingManager).distributeInflationReward(_operator, pendingInflationReward, timestampIdx); + } + + lastJobCompletionEpochs[_operator] = currentEpoch; + } + + /// @notice update when snapshot submission is completed, or when a job is completed + function updateEpochTimestampIdx() external onlySymbioticStaking { + // latest confirmed timestampIdx + uint256 currentTimestampIdx = ISymbioticStaking(symbioticStaking).latestConfirmedTimestampIdx(); + + if(epochTimestampIdx[_getCurrentEpoch()] != currentTimestampIdx) { + epochTimestampIdx[_getCurrentEpoch()] = currentTimestampIdx; + } + } + + /*===================================================== internal ====================================================*/ + + function _increaseJobCount(address _operator, uint256 _epoch) internal { + operatorJobCountsPerEpoch[_epoch][_operator]++; + totalJobCountsPerEpoch[_epoch]++; + } + + /*=================================================== external view =================================================*/ + + function getEpochTimestampIdx(uint256 _epoch) external view returns (uint256) { + return epochTimestampIdx[_epoch]; + } + + /*=================================================== internal view =================================================*/ + + function _getCurrentEpoch() internal view returns (uint256) { + return (block.timestamp - startTime) / inflationRewardEpochSize; + } + + /*======================================== Admin ========================================*/ + + function setJobManager(address _jobManager) public onlyRole(DEFAULT_ADMIN_ROLE) { + require(_jobManager != address(0), "InflationRewardManager: jobManager address is zero"); + jobManager = _jobManager; + } + + function setStakingManager(address _stakingManager) public onlyRole(DEFAULT_ADMIN_ROLE) { + require(_stakingManager != address(0), "InflationRewardManager: stakingManager address is zero"); + stakingManager = _stakingManager; + } + + function setInflationRewardPerEpoch(uint256 _inflationRewardPerEpoch) public onlyRole(DEFAULT_ADMIN_ROLE) { + inflationRewardPerEpoch = _inflationRewardPerEpoch; + } + + function setInflationRewardEpochSize(uint256 _inflationRewardEpochSize) public onlyRole(DEFAULT_ADMIN_ROLE) { + inflationRewardEpochSize = _inflationRewardEpochSize; + } + + function setSymbioticStakingReward(address _symbioticStakingReward) public onlyRole(DEFAULT_ADMIN_ROLE) { + require(_symbioticStakingReward != address(0), "InflationRewardManager: symbioticStakingReward address is zero"); + symbioticStakingReward = _symbioticStakingReward; + } + + function transferInflationRewardToken(address _to, uint256 _amount) public onlySymbioticStaking { + IERC20(inflationRewardToken).safeTransfer(_to, _amount); + } + + /*======================================== Overrides ========================================*/ + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC165Upgradeable, AccessControlUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + function _authorizeUpgrade(address /*account*/ ) internal view override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/contracts/staking/l2_contracts/JobManager.sol b/contracts/staking/l2_contracts/JobManager.sol new file mode 100644 index 0000000..b92c09a --- /dev/null +++ b/contracts/staking/l2_contracts/JobManager.sol @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +/* Contracts */ +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; + + +/* Interfaces */ +import {IJobManager} from "../../interfaces/staking/IJobManager.sol"; +import {IInflationRewardManager} from "../../interfaces/staking/IInflationRewardManager.sol"; +import {IStakingManager} from "../../interfaces/staking/IStakingManager.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/* Libraries */ +import {Struct} from "../../lib/staking/Struct.sol"; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract JobManager is + ContextUpgradeable, + ERC165Upgradeable, + AccessControlUpgradeable, + UUPSUpgradeable, + ReentrancyGuardUpgradeable, + IJobManager +{ + using SafeERC20 for IERC20; + + mapping(uint256 jobId => Struct.JobInfo jobInfo) public jobs; + // operator deducts comission from inflation reward + mapping(address operator => uint256 rewardShare) public operatorRewardShares; // 1e18 == 100% + + // gaps in case we new vars in same file + uint256[500] private __gap_0; + + address public stakingManager; + address public symbioticStaking; + address public symbioticStakingReward; + address public feeToken; + address public inflationRewardManager; + + uint256 public jobDuration; + + // gaps in case we new vars in same file + uint256[500] private __gap_1; + + modifier onlySymbioticStaking() { + require(msg.sender == symbioticStaking || msg.sender == symbioticStakingReward, "JobManager: caller is not the SymbioticStaking"); + _; + } + + /*======================================== Init ========================================*/ + + function initialize(address _admin, address _stakingManager, address _symbioticStaking, address _symbioticStakingReward, address _feeToken, address _inflationRewardManager, uint256 _jobDuration) + public + initializer + { + __Context_init_unchained(); + __ERC165_init_unchained(); + __AccessControl_init_unchained(); + __UUPSUpgradeable_init_unchained(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + + require(_stakingManager != address(0), "JobManager: Invalid StakingManager"); + stakingManager = _stakingManager; + + require(_symbioticStaking != address(0), "JobManager: Invalid SymbioticStaking"); + symbioticStaking = _symbioticStaking; + + require(_symbioticStakingReward != address(0), "JobManager: Invalid SymbioticStakingReward"); + symbioticStakingReward = _symbioticStakingReward; + + require(_feeToken != address(0), "JobManager: Invalid Fee Token"); + feeToken = _feeToken; + + + require(_inflationRewardManager != address(0), "JobManager: Invalid InflationRewardManager"); + inflationRewardManager = _inflationRewardManager; + + require(_jobDuration > 0, "JobManager: Invalid Job Duration"); + jobDuration = _jobDuration; + } + + /*======================================== Job ========================================*/ + + // TODO: check paramter for job details + function createJob(uint256 _jobId, address _requester, address _operator, uint256 _feeAmount) + external + nonReentrant + { + // TODO: this should be removed + IERC20(feeToken).safeTransferFrom(_requester, address(this), _feeAmount); + + // stakeToken and lockAmount will be decided in each pool + jobs[_jobId] = Struct.JobInfo({ + requester: _requester, + operator: _operator, + feePaid: _feeAmount, + deadline: block.timestamp + jobDuration + }); + + IStakingManager(stakingManager).onJobCreation(_jobId, _operator); + + // TODO: emit event + } + + /** + * @notice Submit Single Proof + */ + function submitProof(uint256 _jobId, bytes calldata _proof) public nonReentrant { + require(jobs[_jobId].deadline > 0, "Job not created"); + require(block.timestamp <= jobs[_jobId].deadline, "Job Expired"); + + _verifyProof(_jobId, _proof); + + address operator = jobs[_jobId].operator; + + // distribute fee reward + uint256 feeRewardRemaining = _distributeFeeReward(operator, jobs[_jobId].feePaid); + + // inflation reward will be distributed here + IStakingManager(stakingManager).onJobCompletion(_jobId, operator, feeRewardRemaining); + } + + /** + * @notice Submit Multiple proofs in single transaction + */ + function submitProofs(uint256[] calldata _jobIds, bytes[] calldata _proofs) external nonReentrant { + require(_jobIds.length == _proofs.length, "Invalid Length"); + + uint256 len = _jobIds.length; + for (uint256 idx = 0; idx < len; idx++) { + uint256 jobId = _jobIds[idx]; + submitProof(jobId, _proofs[idx]); + } + } + + /*======================================== Fee Reward ========================================*/ + + /// @notice refund fee to the job requester + /// @dev most likely called by the requester when job is not completed + /// @dev or when the job is slashed and the slash result is submitted in SymbioticStaking contract + function refundFee(uint256 _jobId) external nonReentrant { + if (jobs[_jobId].feePaid > 0) { + require(block.timestamp > jobs[_jobId].deadline, "Job not Expired"); + + IERC20(feeToken).safeTransfer(jobs[_jobId].requester, jobs[_jobId].feePaid); + jobs[_jobId].feePaid = 0; + + // TODO: emit event + } + } + + /*======================================== Internal functions ========================================*/ + + function _verifyProof(uint256 _jobId, bytes calldata _proof) internal { + // TODO: verify proof + + // TODO: emit event + } + + function _distributeFeeReward(address _operator, uint256 _feePaid) internal returns(uint256 feeRewardRemaining) { + uint256 operatorFeeReward = Math.mulDiv(_feePaid, operatorRewardShares[_operator], 1e18); + IERC20(feeToken).safeTransfer(_operator, operatorFeeReward); + feeRewardRemaining = _feePaid - operatorFeeReward; + } + + /*======================================== Admin ========================================*/ + + function setStakingManager(address _stakingManager) external onlyRole(DEFAULT_ADMIN_ROLE) { + stakingManager = _stakingManager; + } + + function setFeeToken(address _feeToken) external onlyRole(DEFAULT_ADMIN_ROLE) { + feeToken = _feeToken; + } + + function setJobDuration(uint256 _jobDuration) external onlyRole(DEFAULT_ADMIN_ROLE) { + jobDuration = _jobDuration; + } + + function setOperatorRewardShare(address _operator, uint256 _rewardShare) external onlyRole(DEFAULT_ADMIN_ROLE) { + operatorRewardShares[_operator] = _rewardShare; + } + + // TODO + function transferFeeToken(address _recipient, uint256 _amount) external onlySymbioticStaking { + IERC20(feeToken).safeTransfer(_recipient, _amount); + } + + /*======================================== Overrides ========================================*/ + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC165Upgradeable, AccessControlUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + function _authorizeUpgrade(address /*account*/ ) internal view override onlyRole(DEFAULT_ADMIN_ROLE) {} + + +} diff --git a/contracts/staking/l2_contracts/NativeStaking.sol b/contracts/staking/l2_contracts/NativeStaking.sol new file mode 100644 index 0000000..00ad05f --- /dev/null +++ b/contracts/staking/l2_contracts/NativeStaking.sol @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +/* Contracts */ +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; + +/* Interfaces */ +import {INativeStaking} from "../../interfaces/staking/INativeStaking.sol"; +import {ISymbioticStaking} from "../../interfaces/staking/ISymbioticStaking.sol"; +import {IRewardDistributor} from "../../interfaces/staking/IRewardDistributor.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/* Libraries */ +import {Struct} from "../../lib/staking/Struct.sol"; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + + +contract NativeStaking is + ContextUpgradeable, + ERC165Upgradeable, + AccessControlUpgradeable, + UUPSUpgradeable, + ReentrancyGuardUpgradeable, + INativeStaking +{ + using EnumerableSet for EnumerableSet.AddressSet; + using SafeERC20 for IERC20; + + // gaps in case we new vars in same file + uint256[500] private __gap_0; + + EnumerableSet.AddressSet private stakeTokenSet; + + address public rewardDistributor; + address public stakingManager; + address public feeRewardToken; + address public inflationRewardToken; + + // gaps in case we new vars in same file + + /* Config */ + uint256 public withdrawalDuration; + uint256 public tokenSelectionWeightSum; + + mapping(address stakeToken => uint256 lockAmount) public amountToLock; // amount of token to lock for each job creation + mapping(address stakeToken => uint256 weight) public tokenSelectionWeight; + mapping(address stakeToken => uint256 share) public inflationRewardShare; // 1e18 = 100% + + /* Stake */ + // staked amount for each account + mapping(address stakeToken => mapping(address account => mapping(address operator => uint256 amount))) public + stakeAmounts; + // total staked amounts for each operator + mapping(address stakeToken => mapping(address operator => uint256 amount)) public operatorstakeAmounts; + + mapping(address account => mapping(address operator => Struct.WithdrawalRequest[] withdrawalRequest)) public + withdrawalRequests; + + uint256[500] private __gap_1; + /* Locked Stakes */ + mapping(uint256 jobId => Struct.NativeStakingLock lock) public lockInfo; + mapping(address stakeToken => mapping(address operator => uint256 amount)) public operatorLockedAmounts; + + modifier onlySupportedToken(address _stakeToken) { + require(stakeTokenSet.contains(_stakeToken), "Token not supported"); + _; + } + + modifier onlyStakingManager() { + require(msg.sender == stakingManager, "Only StakingManager"); + _; + } + + /*=================================================== initialize ====================================================*/ + + function initialize( + address _admin, + address _stakingManager, + address _rewardDistributor, + uint256 _withdrawalDuration, + address _feeToken, + address _inflationRewardToken + ) public initializer { + __Context_init_unchained(); + __ERC165_init_unchained(); + __AccessControl_init_unchained(); + __UUPSUpgradeable_init_unchained(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + + stakingManager = _stakingManager; + rewardDistributor = _rewardDistributor; + withdrawalDuration = _withdrawalDuration; + feeRewardToken = _feeToken; + inflationRewardToken = _inflationRewardToken; + } + + /*==================================================== external =====================================================*/ + + /*-------------------------------- Native Staking --------------------------------*/ + + // Staker should be able to choose an Operator they want to stake into + function stake(address _stakeToken, address _operator, uint256 _amount) + external + onlySupportedToken(_stakeToken) + nonReentrant + { + // this check can be removed in the future to allow delegatedStake + require(msg.sender == _operator, "Only operator can stake"); + + IERC20(_stakeToken).safeTransferFrom(msg.sender, address(this), _amount); + + stakeAmounts[_stakeToken][msg.sender][_operator] += _amount; + operatorstakeAmounts[_stakeToken][_operator] += _amount; + + // INativeStakingReward(rewardDistributor).onStakeUpdate(msg.sender, _stakeToken, _operator); + + emit Staked(msg.sender, _operator, _stakeToken, _amount, block.timestamp); + } + + // TODO + function requestStakeWithdrawal(address _operator, address _stakeToken, uint256 _amount) external nonReentrant { + require(getOperatorActiveStakeAmount(_stakeToken, _operator) >= _amount, "Insufficient stake"); + + stakeAmounts[_stakeToken][msg.sender][_operator] -= _amount; + operatorstakeAmounts[_stakeToken][_operator] -= _amount; + + withdrawalRequests[msg.sender][_operator].push( + Struct.WithdrawalRequest(_stakeToken, _amount, block.timestamp + withdrawalDuration) + ); + + // INativeStakingReward(rewardDistributor).onStakeUpdate(msg.sender, _stakeToken, _operator); + + emit StakeWithdrawn(msg.sender, _operator, _stakeToken, _amount, block.timestamp); + } + + function withdrawStake(address _operator, uint256[] calldata _index) external nonReentrant { + require(msg.sender == _operator, "Only operator can withdraw stake"); + + _withdrawStake(_operator, _index); + // TODO + } + + /*-------------------------------- Satking Manager -------------------------------*/ + + function lockStake(uint256 _jobId, address _operator) external onlyStakingManager { + address _stakeToken = _selectStakeToken(_operator); + uint256 _amountToLock = amountToLock[_stakeToken]; + require(getOperatorActiveStakeAmount(_stakeToken, _operator) >= _amountToLock, "Insufficient stake to lock"); + + // lock stake + lockInfo[_jobId] = Struct.NativeStakingLock(_stakeToken, _amountToLock); + operatorLockedAmounts[_stakeToken][_operator] += _amountToLock; + + // TODO: emit event + } + + /// @notice unlock stake and distribute reward + /// @dev called by StakingManager when job is completed + function onJobCompletion( + uint256 _jobId, + address _operator, + uint256 _feeRewardAmount, + uint256 _inflationRewardAmount, + uint256 /* _inflationRewardTimestampIdx */ + ) external onlyStakingManager { + Struct.NativeStakingLock memory lock = lockInfo[_jobId]; + + if (lock.amount == 0) return; + + _unlockStake(_jobId, lock.token, _operator, lock.amount); + + // distribute fee reward + // if (_feeRewardAmount > 0) { + // _distributeFeeReward(lock.token, _operator, _feeRewardAmount); + // } + + // if (_inflationRewardAmount > 0) { + // _distributeInflationReward(_operator, _inflationRewardAmount); + // } + + // TODO: emit event + } + + function slash(Struct.JobSlashed[] calldata _slashedJobs) external onlyStakingManager { + uint256 len = _slashedJobs.length; + for (uint256 i = 0; i < len; i++) { + Struct.NativeStakingLock memory lock = lockInfo[_slashedJobs[i].jobId]; + + uint256 lockedAmount = lock.amount; + if (lockedAmount == 0) continue; // if already slashed + + _unlockStake(_slashedJobs[i].jobId, lock.token, _slashedJobs[i].operator, lockedAmount); + IERC20(lock.token).safeTransfer(_slashedJobs[i].rewardAddress, lockedAmount); + + // INativeStakingReward(rewardDistributor).onStakeUpdate(msg.sender, lock.token, _slashedJobs[i].operator); + } + // TODO: emit event + } + + function distributeInflationReward(address _operator, uint256 _rewardAmount, uint256 /* _timestampIdx */) external onlyStakingManager { + if (_rewardAmount == 0) return; + + // _distributeInflationReward(_operator, _rewardAmount); + } + + /*==================================================== public view ==================================================*/ + + function getOperatorStakeAmount(address _stakeToken, address _operator) public view returns (uint256) { + return operatorstakeAmounts[_stakeToken][_operator]; + } + + function getOperatorActiveStakeAmount(address _stakeToken, address _operator) public view returns (uint256) { + return getOperatorStakeAmount(_stakeToken, _operator) - getOperatorLockedAmount(_stakeToken, _operator); + } + + function getOperatorLockedAmount(address _stakeToken, address _operator) public view returns (uint256) { + return operatorLockedAmounts[_stakeToken][_operator]; + } + + /*================================================== external view ==================================================*/ + + function getStakeTokenList() external view returns (address[] memory) { + return stakeTokenSet.values(); + } + + function getStakeTokenWeights() external view returns (address[] memory, uint256[] memory) { + uint256[] memory weights = new uint256[](stakeTokenSet.length()); + for (uint256 i = 0; i < stakeTokenSet.length(); i++) { + weights[i] = tokenSelectionWeight[stakeTokenSet.at(i)]; + } + return (stakeTokenSet.values(), weights); + } + + function getStakeAmount(address _stakeToken, address _account, address _operator) external view returns (uint256) { + return stakeAmounts[_stakeToken][_account][_operator]; + } + + function isSupportedStakeToken(address _stakeToken) public view returns (bool) { + return stakeTokenSet.contains(_stakeToken); + } + + /*===================================================== internal ====================================================*/ + + function _unlockStake(uint256 _jobId, address _stakeToken, address _operator, uint256 _amount) internal { + operatorLockedAmounts[_stakeToken][_operator] -= _amount; + delete lockInfo[_jobId]; + } + + function _withdrawStake(address _operator, uint256[] calldata _index) internal { + for (uint256 i = 0; i < _index.length; i++) { + Struct.WithdrawalRequest memory request = withdrawalRequests[msg.sender][_operator][_index[i]]; + + require(request.withdrawalTime <= block.timestamp, "Withdrawal time not reached"); + + require(request.amount > 0, "Invalid withdrawal request"); + + withdrawalRequests[msg.sender][_operator][_index[i]].amount = 0; + + IERC20(request.stakeToken).safeTransfer(msg.sender, request.amount); + } + } + + /*============================================== internal view =============================================*/ + + function _calcInflationRewardAmount(address _stakeToken, uint256 _inflationRewardAmount) + internal + view + returns (uint256) + { + return Math.mulDiv(_inflationRewardAmount, inflationRewardShare[_stakeToken], 1e18); + } + + function _selectStakeToken(address _operator) internal view returns(address) { + require(tokenSelectionWeightSum > 0, "Total weight must be greater than zero"); + require(stakeTokenSet.length() > 0, "No tokens available"); + + address[] memory tokens = new address[](stakeTokenSet.length()); + uint256[] memory weights = new uint256[](stakeTokenSet.length()); + + uint256 weightSum = tokenSelectionWeightSum; + uint256 idx = 0; + uint256 len = stakeTokenSet.length(); + for (uint256 i = 0; i < len; i++) { + address token = stakeTokenSet.at(i); + uint256 weight = tokenSelectionWeight[token]; + // ignore if weight is 0 + if (weight > 0) { + tokens[idx] = token; + weights[idx] = weight; + idx++; + } + } + + // repeat until a valid token is selected + while (true) { + require(idx > 0, "No stakeToken available"); + + // random number in range [0, weightSum - 1] + uint256 random = uint256(keccak256(abi.encodePacked(block.timestamp, blockhash(block.number - 1), msg.sender))) % weightSum; + + uint256 cumulativeWeight = 0; + address selectedToken; + + uint256 i; + // select token based on weight + for (i = 0; i < idx; i++) { + cumulativeWeight += weights[i]; + if (random < cumulativeWeight) { + selectedToken = tokens[i]; + break; + } + } + + // check if the selected token has enough active stake amount + if (getOperatorActiveStakeAmount(selectedToken, _operator) >= amountToLock[selectedToken]) { + return selectedToken; + } + + weightSum -= weights[i]; + tokens[i] = tokens[idx - 1]; + weights[i] = weights[idx - 1]; + idx--; // 배열 크기를 줄임 + } + + // this should be returned + return address(0); + } + + /*====================================================== admin ======================================================*/ + + function addStakeToken(address _token, uint256 _weight) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(stakeTokenSet.add(_token), "Token already exists"); + + tokenSelectionWeight[_token] = _weight; + tokenSelectionWeightSum += _weight; + } + + function removeStakeToken(address _token) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(stakeTokenSet.remove(_token), "Token does not exist"); + + tokenSelectionWeightSum -= inflationRewardShare[_token]; + delete tokenSelectionWeight[_token]; + } + + function setStakeTokenWeight(address _token, uint256 _weight) external onlyRole(DEFAULT_ADMIN_ROLE) { + tokenSelectionWeightSum -= tokenSelectionWeight[_token]; + tokenSelectionWeight[_token] = _weight; + tokenSelectionWeightSum += _weight; + } + + function setNativeStakingReward(address _nativeStakingReward) external onlyRole(DEFAULT_ADMIN_ROLE) { + rewardDistributor = _nativeStakingReward; + + // TODO: emit event + } + + function setStakingManager(address _stakingManager) external onlyRole(DEFAULT_ADMIN_ROLE) { + stakingManager = _stakingManager; + + // TODO: emit event + } + + function setAmountToLock(address _token, uint256 _amount) external onlyRole(DEFAULT_ADMIN_ROLE) { + amountToLock[_token] = _amount; + + // TODO: emit event + } + /*==================================================== overrides ====================================================*/ + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC165Upgradeable, AccessControlUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + function _authorizeUpgrade(address /*account*/ ) internal view override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/contracts/staking/l2_contracts/NativeStakingReward.sol b/contracts/staking/l2_contracts/NativeStakingReward.sol new file mode 100644 index 0000000..40e5658 --- /dev/null +++ b/contracts/staking/l2_contracts/NativeStakingReward.sol @@ -0,0 +1,43 @@ +// // SPDX-License-Identifier: MIT + +// pragma solidity ^0.8.26; + +// import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +// import {IJobManager} from "../../interfaces/staking/IJobManager.sol"; +// import {INativeStaking} from "../../interfaces/staking/INativeStaking.sol"; +// import {RewardDistributor} from "./RewardDistributor.sol"; + +// contract NativeStakingReward is +// RewardDistributor +// { + + + +// //-------------------------------- Init start --------------------------------// + + +// //-------------------------------- Init end --------------------------------// + +// //-------------------------------- Staking start --------------------------------// + + +// function _getUserStakeAmount(address account, address token, address operator) internal view returns (uint256) { +// // return INativeStaking(stakingPool).getUserStakeAmount(account, token, operator); +// } + + +// //-------------------------------- Staking end --------------------------------// + +// //-------------------------------- Admin start --------------------------------// + + + +// //-------------------------------- Admin end --------------------------------// + +// //-------------------------------- Overrides start --------------------------------// + +// //-------------------------------- Overrides end --------------------------------// + + +// } diff --git a/contracts/staking/l2_contracts/RewardDistributor.sol b/contracts/staking/l2_contracts/RewardDistributor.sol new file mode 100644 index 0000000..91f75b5 --- /dev/null +++ b/contracts/staking/l2_contracts/RewardDistributor.sol @@ -0,0 +1,221 @@ +// // SPDX-License-Identifier: MIT + +// pragma solidity ^0.8.26; + +// import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +// import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +// import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +// import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +// import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +// import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +// import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +// import {IStakingPool} from "../../interfaces/staking/IStakingPool.sol"; +// import {IJobManager} from "../../interfaces/staking/IJobManager.sol"; +// import {IRewardDistributor} from "../../interfaces/staking/IRewardDistributor.sol"; + +// import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +// import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +// import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + + +// import {Struct} from "../../lib/staking/Struct.sol"; + +// abstract contract RewardDistributor is +// ContextUpgradeable, +// ERC165Upgradeable, +// AccessControlUpgradeable, +// ReentrancyGuardUpgradeable, +// PausableUpgradeable, +// UUPSUpgradeable, +// IRewardDistributor +// { +// using Math for uint256; +// using SafeERC20 for IERC20; +// using EnumerableSet for EnumerableSet.AddressSet; + +// address public jobManager; +// address public stakingPool; + +// address public feeRewardToken; +// address public inflationRewardToken; + +// // mapping(address stakeToken => uint256 share) public inflationRewardShare; // 1e18 = 100% + +// // reward is accrued per operator +// mapping(address stakeToken => mapping(address operator => mapping(address rewardToken => uint256 rewardAmount))) +// rewards; +// // rewardTokens amount per stakeToken +// mapping( +// address stakeToken +// => mapping(address operator => mapping(address rewardToken => uint256 rewardPerToken)) +// ) rewardPerTokenStored; + +// mapping( +// address account +// => mapping( +// address stakeToken +// => mapping(address operator => mapping(address rewardToken => uint256 rewardPerTokenPaid)) +// ) +// ) rewardPerTokenPaids; + +// mapping(address account => mapping(address rewardToken => uint256 amount)) rewardAccrued; + +// modifier onlyStakingPool() { +// require(msg.sender == stakingPool, "Only StakingPool"); +// _; +// } + +// /*============================================= init =============================================*/ + +// function initialize( +// address _admin, +// address _jobManager, +// address _stakingPool, +// address _feeRewardToken, +// address _inflationRewardToken +// ) public initializer { +// __Context_init_unchained(); +// __ERC165_init_unchained(); +// __AccessControl_init_unchained(); +// __UUPSUpgradeable_init_unchained(); +// __ReentrancyGuard_init_unchained(); +// __ReentrancyGuard_init_unchained(); + +// _grantRole(DEFAULT_ADMIN_ROLE, _admin); + +// require(_admin != address(0), "Invalid Admin"); +// require(_jobManager != address(0), "Invalid JobManager"); +// require(_stakingPool != address(0), "Invalid StakingPool"); +// require(_feeRewardToken != address(0), "Invalid FeeRewardToken"); + +// jobManager = _jobManager; +// stakingPool = _stakingPool; +// feeRewardToken = _feeRewardToken; +// inflationRewardToken = _inflationRewardToken; +// } + +// /*======================================== external functions ========================================*/ + +// /// @notice called when fee reward is generated +// function updateFeeReward(address _stakeToken, address _operator, uint256 _rewardAmount) external onlyStakingPool { +// rewards[_stakeToken][_operator][feeRewardToken] += _rewardAmount; +// rewardPerTokenStored[_stakeToken][_operator][feeRewardToken] += _rewardAmount.mulDiv(1e18, _getOperatorStakeAmount(_operator, _stakeToken)); +// } + +// /// @notice called when inflation reward is generated +// function updateInflationReward(address _operator, uint256 _rewardAmount) external onlyStakingPool { +// address[] memory stakeTokenList = _getStakeTokenList(); +// for(uint256 i = 0; i < stakeTokenList.length; i++) { +// rewards[stakeTokenList[i]][_operator][inflationRewardToken] += _rewardAmount; +// rewardPerTokenStored[stakeTokenList[i]][_operator][inflationRewardToken] += _rewardAmount.mulDiv(1e18, _getOperatorStakeAmount(_operator, stakeTokenList[i])); +// } +// } + +// // /// @dev called when stake amount is updated in StakingPool +// // function onStakeUpdate(address _account, address _stakeToken, address _operator) external onlyStakingPool { +// // // update fee reward +// // rewardPerTokenStored[_stakeToken][_operator][feeRewardToken] = _rewardPerTokenStored(_stakeToken, _operator, feeRewardToken); + +// // // update inflation reward +// // // TODO: check if there is any problem by not updating rewardPerTokenStored during Tx +// // _requestInflationRewardUpdate(_operator); + +// // uint256 rewardPerTokenStoredCurrent = rewardPerTokenStored[_stakeToken][_operator][inflationRewardToken]; +// // address[] memory stakeTokenList = IStakingPool(stakingPool).getStakeTokenList(); + +// // for(uint256 i = 0; i < stakeTokenList.length; i++) { +// // uint256 rewardPerTokenPaid = rewardPerTokenPaids[_account][stakeTokenList[i]][_operator][inflationRewardToken]; +// // uint256 accountStakeAmount = _getStakeAmount(_account, stakeTokenList[i], _operator); +// // uint256 pendingReward = accountStakeAmount.mulDiv(rewardPerTokenStoredCurrent - rewardPerTokenPaid, 1e18); + +// // // update account's reward info +// // rewardAccrued[_account][inflationRewardToken] += pendingReward; +// // rewardPerTokenPaids[_account][stakeTokenList[i]][_operator][inflationRewardToken] = rewardPerTokenStoredCurrent; + +// // // update global rewardPerTokenStored +// // uint256 operatorStakeAmount = _getOperatorStakeAmount(_operator, stakeTokenList[i]); +// // rewardPerTokenStored[_stakeToken][_operator][inflationRewardToken] += rewards[stakeTokenList[i]][_operator][inflationRewardToken].mulDiv(1e18, operatorStakeAmount); +// // } +// // } + +// // function onClaimReward(address _account, address _operator) external onlyStakingPool { +// // IERC20(feeRewardToken).safeTransfer(_account, rewardAccrued[_account][feeRewardToken]); +// // IERC20(inflationRewardToken).safeTransfer(_account, rewardAccrued[_account][inflationRewardToken]); + +// // rewardAccrued[_account][feeRewardToken] = 0; +// // rewardAccrued[_account][inflationRewardToken] = 0; + +// // address[] memory stakeTokenList = IStakingPool(stakingPool).getStakeTokenList(); +// // for(uint256 i = 0; i < stakeTokenList.length; i++) { +// // rewardPerTokenPaids[_account][stakeTokenList[i]][_operator][feeRewardToken] = rewardPerTokenStored[stakeTokenList[i]][_operator][feeRewardToken]; +// // rewardPerTokenPaids[_account][stakeTokenList[i]][_operator][inflationRewardToken] = rewardPerTokenStored[stakeTokenList[i]][_operator][inflationRewardToken]; +// // } +// // } + +// function onSlash() external onlyStakingPool { +// // TODO +// } + +// /*======================================== internal functions ========================================*/ +// function _requestInflationRewardUpdate(address _operator) internal { +// // JobManager.updateInflationReward +// // -> StakingManager.distributeInflationReward +// // -> StakingPool.distributeInflationReward +// // -> RewardDistributor.addInflationReward +// IJobManager(jobManager).updateInflationReward(_operator); +// } + +// /* Modification for _update */ +// function _rewardPerTokenStored(address _stakeToken, address _operator, address _rewardToken, uint256 rewardAmount) +// internal +// view +// returns (uint256) +// { +// uint256 operatorStakeAmount = _getOperatorStakeAmount(_operator, _stakeToken); +// if(operatorStakeAmount == 0) return rewardPerTokenStored[_stakeToken][_operator][_rewardToken]; + +// return rewardPerTokenStored[_stakeToken][_operator][_rewardToken] + rewardAmount.mulDiv(1e18, operatorStakeAmount); +// } + +// function _updatePendingInflationReward(address _operator) internal { + +// } + + +// /*======================================== internal view functions ========================================*/ +// function _getOperatorStakeAmount(address _operator, address _stakeToken) internal view returns (uint256) { +// return IStakingPool(stakingPool).getOperatorStakeAmount(_operator, _stakeToken); +// } + +// function _getStakeTokenList() internal view returns (address[] memory) { +// return IStakingPool(stakingPool).getStakeTokenList(); +// } + +// function _getStakeAmount(address account, address _stakeToken, address _operator) internal view returns (uint256) { +// return IStakingPool(stakingPool).getStakeAmount(account, _stakeToken, _operator); +// } + +// /*======================================== admin functions ========================================*/ + +// function setStakingPool(address _stakingPool) public onlyRole(DEFAULT_ADMIN_ROLE) { +// stakingPool = _stakingPool; +// // TODO: emit event +// } + + +// /*======================================== overrides ========================================*/ + +// function supportsInterface(bytes4 interfaceId) +// public +// view +// virtual +// override(ERC165Upgradeable, AccessControlUpgradeable) +// returns (bool) +// { +// return super.supportsInterface(interfaceId); +// } + +// function _authorizeUpgrade(address /*account*/ ) internal view override onlyRole(DEFAULT_ADMIN_ROLE) {} + +// } diff --git a/contracts/staking/l2_contracts/StakingManager.sol b/contracts/staking/l2_contracts/StakingManager.sol new file mode 100644 index 0000000..5e8000e --- /dev/null +++ b/contracts/staking/l2_contracts/StakingManager.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +/* Contracts */ +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; + +/* Interfaces */ +import {IJobManager} from "../../interfaces/staking/IJobManager.sol"; +import {IInflationRewardManager} from "../../interfaces/staking/IInflationRewardManager.sol"; +import {IStakingManager} from "../../interfaces/staking/IStakingManager.sol"; +import {IStakingPool} from "../../interfaces/staking/IStakingPool.sol"; +import {IRewardDistributor} from "../../interfaces/staking/IRewardDistributor.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/* Libraries */ +import {Struct} from "../../lib/staking/Struct.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract StakingManager is + ContextUpgradeable, + ERC165Upgradeable, + AccessControlUpgradeable, + UUPSUpgradeable, + ReentrancyGuardUpgradeable, + IStakingManager +{ + using EnumerableSet for EnumerableSet.AddressSet; + using SafeERC20 for IERC20; + + // gaps in case we new vars in same file + uint256[500] private __gap_0; + + mapping(address pool => Struct.PoolConfig config) private poolConfig; + + EnumerableSet.AddressSet private stakingPoolSet; + + address public jobManager; + address public symbioticStaking; + address public inflationRewardManager; + + address public feeToken; + address public inflationRewardToken; + + + // gaps in case we new vars in same file + uint256[500] private __gap_1; + + modifier onlyJobManager() { + require(msg.sender == jobManager, "StakingManager: Only JobManager"); + _; + } + + modifier onlySymbioticStaking() { + require(msg.sender == symbioticStaking, "StakingManager: Only SymbioticStaking"); + _; + } + + modifier onlyInflationRewardManager() { + require(msg.sender == jobManager, "StakingManager: Only JobManager"); + _; + } + + function initialize(address _admin, address _jobManager, address _symbioticStaking, address _inflationRewardManager, address _feeToken, address _inflationRewardToken) public initializer { + __Context_init_unchained(); + __ERC165_init_unchained(); + __AccessControl_init_unchained(); + __UUPSUpgradeable_init_unchained(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + + require(_jobManager != address(0), "StakingManager: Invalid JobManager"); + jobManager = _jobManager; + + require(_inflationRewardManager != address(0), "StakingManager: Invalid InflationRewardManager"); + inflationRewardManager = _inflationRewardManager; + + require(_feeToken != address(0), "StakingManager: Invalid FeeToken"); + feeToken = _feeToken; + + require(_symbioticStaking != address(0), "StakingManager: Invalid SymbioticStaking"); + symbioticStaking = _symbioticStaking; + + require(_inflationRewardToken != address(0), "StakingManager: Invalid InflationRewardToken"); + inflationRewardToken = _inflationRewardToken; + } + + // create job and lock stakes (operator self stake, some portion of native stake and symbiotic stake) + // locked stake will be unlocked after an epoch if no slas result is submitted + + // note: data related to the job should be stored in JobManager (e.g. operator, lockToken, lockAmount, proofDeadline) + function onJobCreation(uint256 _jobId, address _operator) external onlyJobManager { + uint256 len = stakingPoolSet.length(); + + for (uint256 i = 0; i < len; i++) { + address pool = stakingPoolSet.at(i); + if (!isEnabledPool(pool)) continue; // skip if the pool is not enabled + + IStakingPool(pool).lockStake(_jobId, _operator); + } + } + + // called when job is completed to unlock the locked stakes + function onJobCompletion(uint256 _jobId, address _operator, uint256 _feeRewardAmount) external onlyJobManager { + // update pending inflation reward + (uint256 timestampIdx, uint256 pendingInflationReward) = IInflationRewardManager(inflationRewardManager).updatePendingInflationReward(_operator); + + uint256 len = stakingPoolSet.length(); + for (uint256 i = 0; i < len; i++) { + address pool = stakingPoolSet.at(i); + + if(!isEnabledPool(pool)) continue; + + (uint256 poolFeeRewardAmount, uint256 poolInflationRewardAmount) = _calcRewardAmount(pool, _feeRewardAmount, pendingInflationReward); + + IStakingPool(pool).onJobCompletion(_jobId, _operator, poolFeeRewardAmount, poolInflationRewardAmount, timestampIdx); + } + + // TODO: emit event + } + + /// @notice called by SymbioticStaking contract when slash result is submitted + function onSlashResult(Struct.JobSlashed[] calldata _jobsSlashed) external onlySymbioticStaking { + // msg.sender will most likely be SymbioticStaking contract + require(stakingPoolSet.contains(msg.sender), "StakingManager: Invalid Pool"); + + // refund fee to the requester + for(uint256 i = 0; i < _jobsSlashed.length; i++) { + // this can be done manually in the JobManager contract + // refunds nothing if already refunded + IJobManager(jobManager).refundFee(_jobsSlashed[i].jobId); + } + + uint256 len = stakingPoolSet.length(); + for (uint256 i = 0; i < len; i++) { + address pool = stakingPoolSet.at(i); + IStakingPool(pool).slash(_jobsSlashed); + } + } + + function distributeInflationReward(address _operator, uint256 _rewardAmount, uint256 _timestampIdx) external onlyInflationRewardManager { + if(_rewardAmount == 0) return; + + uint256 len = stakingPoolSet.length(); + for (uint256 i = 0; i < len; i++) { + address pool = stakingPoolSet.at(i); + + (, uint256 poolRewardAmount) = _calcRewardAmount(pool, 0, _rewardAmount); + + IStakingPool(pool).distributeInflationReward(_operator, poolRewardAmount, _timestampIdx); + } + } + + function _calcRewardAmount(address _pool, uint256 _feeRewardAmount, uint256 _inflationRewardAmount) internal view returns (uint256, uint256) { + uint256 poolShare = poolConfig[_pool].share; + + uint256 poolFeeRewardAmount = _feeRewardAmount > 0 ? Math.mulDiv(_feeRewardAmount, poolShare, 1e18) : 0; + uint256 poolInflationRewardAmount = _inflationRewardAmount > 0 ? Math.mulDiv(_inflationRewardAmount, poolShare, 1e18) : 0; + + return (poolFeeRewardAmount, poolInflationRewardAmount); + } + + /*======================================== Getters ========================================*/ + function isEnabledPool(address _pool) public view returns (bool) { + return poolConfig[_pool].enabled; + } + + function getPoolConfig(address _pool) external view returns (Struct.PoolConfig memory) { + return poolConfig[_pool]; + } + + /*======================================== Admin ========================================*/ + + /// @notice add new staking pool + /// @dev share and enabled must be set + function addStakingPool(address _stakingPool) external onlyRole(DEFAULT_ADMIN_ROLE) { + stakingPoolSet.add(_stakingPool); + + // TODO: emit event + } + + function removeStakingPool(address _stakingPool) external onlyRole(DEFAULT_ADMIN_ROLE) { + stakingPoolSet.remove(_stakingPool); + + // TODO: emit event + } + + function setEnabledPool(address _pool, bool _enabled) external onlyRole(DEFAULT_ADMIN_ROLE) { + poolConfig[_pool].enabled = _enabled; + + // TODO: emit event + } + + function setJobManager(address _jobManager) external onlyRole(DEFAULT_ADMIN_ROLE) { + jobManager = _jobManager; + + // TODO: emit event + } + + // when job is closed, the reward will be distributed based on the share + function setPoolRewardShare(address[] calldata _pools, uint256[] calldata _shares) + external + onlyRole(DEFAULT_ADMIN_ROLE) + { + require(_pools.length == _shares.length || _pools.length == stakingPoolSet.length(), "Invalid Length"); + + uint256 sum = 0; + for (uint256 i = 0; i < _shares.length; i++) { + poolConfig[_pools[i]].share = _shares[i]; + + sum += _shares[i]; + } + + // as the weight is in percentage, the sum of the shares should be 1e18 (100%) + require(sum == 1e18, "Invalid Shares"); + } + + /*======================================== Override ========================================*/ + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC165Upgradeable, AccessControlUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + function _authorizeUpgrade(address /*account*/ ) internal view override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/contracts/staking/l2_contracts/SymbioticStaking.sol b/contracts/staking/l2_contracts/SymbioticStaking.sol new file mode 100644 index 0000000..9100349 --- /dev/null +++ b/contracts/staking/l2_contracts/SymbioticStaking.sol @@ -0,0 +1,627 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +/* Contracts */ +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {JobManager} from "./JobManager.sol"; + +/* Interfaces */ +import {IInflationRewardManager} from "../../interfaces/staking/IInflationRewardManager.sol"; +import {IStakingManager} from "../../interfaces/staking/IStakingManager.sol"; +import {ISymbioticStaking} from "../../interfaces/staking/ISymbioticStaking.sol"; +import {IRewardDistributor} from "../../interfaces/staking/IRewardDistributor.sol"; +import {ISymbioticStakingReward} from "../../interfaces/staking/ISymbioticStakingReward.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/* Libraries */ +import {Struct} from "../../lib/staking/Struct.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +contract SymbioticStaking is + ContextUpgradeable, + ERC165Upgradeable, + AccessControlUpgradeable, + UUPSUpgradeable, + ReentrancyGuardUpgradeable, + ISymbioticStaking +{ + using EnumerableSet for EnumerableSet.AddressSet; + using SafeERC20 for IERC20; + + // gaps in case we new vars in same file + uint256[500] private __gap_0; + + /* Job Status */ + bytes32 public constant STAKE_SNAPSHOT_MASK = 0x0000000000000000000000000000000000000000000000000000000000000001; + bytes32 public constant SLASH_RESULT_MASK = 0x0000000000000000000000000000000000000000000000000000000000000010; + bytes32 public constant COMPLETE_MASK = 0x0000000000000000000000000000000000000000000000000000000000000011; + + bytes32 public constant STAKE_SNAPSHOT_TYPE = keccak256("STAKE_SNAPSHOT_TYPE"); + bytes32 public constant SLASH_RESULT_TYPE = keccak256("SLASH_RESULT_TYPE"); + + uint256 public submissionCooldown; // 18 decimal (in seconds) + uint256 public baseTransmitterComissionRate; // 18 decimal (in percentage) + + EnumerableSet.AddressSet stakeTokenSet; + + address public stakingManager; + address public jobManager; + address public rewardDistributor; + address public inflationRewardManager; + + address public feeRewardToken; + address public inflationRewardToken; + + uint256 public tokenSelectionWeightSum; + + // gaps in case we new vars in same file + uint256[500] private __gap_1; + + /* Config */ + mapping(address stakeToken => uint256 amount) public amountToLock; + mapping(address stakeToken => uint256 weight) public tokenSelectionWeight; + mapping(address stakeToken => uint256 share) public inflationRewardShare; // 1e18 = 100% + + /* Symbiotic Snapshot */ + // to track if all partial txs are received + mapping( + uint256 captureTimestamp + => mapping(address account => mapping(bytes32 submissionType => Struct.SnapshotTxCountInfo snapshot)) + ) txCountInfo; + // to track if all partial txs are received + mapping(uint256 captureTimestamp => mapping(address account => bytes32 status)) submissionStatus; + + // staked amount for each operator + mapping(uint256 captureTimestamp => mapping(address stakeToken => mapping(address operator => uint256 stakeAmount))) + operatorStakeAmounts; + // staked amount for each vault + mapping( + uint256 captureTimestamp + => mapping(address stakeToken => mapping(address vault => mapping(address operator => uint256 stakeAmount))) + ) vaultStakeAmounts; + + Struct.ConfirmedTimestamp[] public confirmedTimestamps; // timestamp is added once all types of partial txs are received + + /* Staking */ + mapping(uint256 jobId => Struct.SymbioticStakingLock lockInfo) public lockInfo; // note: this does not actually affect L1 Symbiotic stake + mapping(address stakeToken => mapping(address operator => uint256 locked)) public operatorLockedAmounts; + + mapping(uint256 captureTimestamp => address transmitter) registeredTransmitters; // only one transmitter can submit the snapshot for the same capturetimestamp + + modifier onlyStakingManager() { + require(msg.sender == stakingManager, "Only StakingManager"); + _; + } + + function initialize( + address _admin, + address _jobManager, + address _stakingManager, + address _rewardDistributor, + address _inflationRewardManager, + address _feeRewardToken, + address _inflationRewardToken + ) public initializer { + __Context_init_unchained(); + __ERC165_init_unchained(); + __AccessControl_init_unchained(); + __UUPSUpgradeable_init_unchained(); + __ReentrancyGuard_init_unchained(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + + require(_stakingManager != address(0), "SymbioticStaking: stakingManager is zero"); + stakingManager = _stakingManager; + + require(_jobManager != address(0), "SymbioticStaking: jobManager is zero"); + jobManager = _jobManager; + + require(_rewardDistributor != address(0), "SymbioticStaking: rewardDistributor is zero"); + rewardDistributor = _rewardDistributor; + + require(_inflationRewardManager != address(0), "SymbioticStaking: inflationRewardManager is zero"); + inflationRewardManager = _inflationRewardManager; + + require(_feeRewardToken != address(0), "SymbioticStaking: feeRewardToken is zero"); + feeRewardToken = _feeRewardToken; + + require(_inflationRewardToken != address(0), "SymbioticStaking: inflationRewardToken is zero"); + inflationRewardToken = _inflationRewardToken; + } + + /*===================================================== external ====================================================*/ + + /*------------------------------ L1 to L2 submission -----------------------------*/ + + function submitVaultSnapshot( + uint256 _index, + uint256 _numOfTxs, // number of total transactions + bytes calldata _vaultSnapshotData, + bytes calldata _signature + ) external { + (uint256 captureTimestamp, Struct.VaultSnapshot[] memory _vaultSnapshots) = + abi.decode(_vaultSnapshotData, (uint256, Struct.VaultSnapshot[])); + + _checkTransmitterRegistration(captureTimestamp); + + _checkValidity(_index, _numOfTxs, captureTimestamp, STAKE_SNAPSHOT_TYPE); + + _verifySignature(_index, _numOfTxs, captureTimestamp, _vaultSnapshotData, _signature); + + // update Vault and Operator stake amount + // update rewardPerToken for each vault and operator in SymbioticStakingReward + _submitVaultSnapshot(captureTimestamp, _vaultSnapshots); + + _updateTxCountInfo(_numOfTxs, captureTimestamp, STAKE_SNAPSHOT_TYPE); + + Struct.SnapshotTxCountInfo memory _snapshot = txCountInfo[captureTimestamp][msg.sender][STAKE_SNAPSHOT_TYPE]; + + // when all chunks of VaultSnapshots are submitted + if (_snapshot.idxToSubmit == _snapshot.numOfTxs) { + submissionStatus[captureTimestamp][msg.sender] |= STAKE_SNAPSHOT_MASK; + } + } + + // TODO + function submitSlashResult( + uint256 _index, + uint256 _numOfTxs, // number of total transactions + bytes memory _SlashResultData, + bytes memory _signature + ) external { + (uint256 captureTimestamp, Struct.JobSlashed[] memory _jobSlashed) = + abi.decode(_SlashResultData, (uint256, Struct.JobSlashed[])); + + // Vault Snapshot should be submitted before Slash Result + require( + submissionStatus[captureTimestamp][msg.sender] & STAKE_SNAPSHOT_MASK == STAKE_SNAPSHOT_MASK, + "Vault Snapshot not submitted" + ); + + _checkTransmitterRegistration(captureTimestamp); + + _checkValidity(_index, _numOfTxs, captureTimestamp, SLASH_RESULT_TYPE); + + _verifySignature(_index, _numOfTxs, captureTimestamp, _SlashResultData, _signature); + + _updateTxCountInfo(_numOfTxs, captureTimestamp, SLASH_RESULT_TYPE); + + Struct.SnapshotTxCountInfo memory _snapshot = txCountInfo[captureTimestamp][msg.sender][STAKE_SNAPSHOT_TYPE]; + + // there could be no operator slashed + if (_jobSlashed.length > 0) IStakingManager(stakingManager).onSlashResult(_jobSlashed); + + // when all chunks of Snapshots are submitted + uint256 numOfTxsStored = _snapshot.numOfTxs; + if (numOfTxsStored > 0 && _snapshot.idxToSubmit == _snapshot.numOfTxs) { + submissionStatus[captureTimestamp][msg.sender] |= STAKE_SNAPSHOT_MASK; + _completeSubmission(captureTimestamp); + } + + // TODO: unlock the selfStake and reward it to the transmitter + } + + /*--------------------------- stake lock/unlock for job --------------------------*/ + + function lockStake(uint256 _jobId, address _operator) external onlyStakingManager { + address _stakeToken = _selectStakeToken(_operator); + uint256 _amountToLock = amountToLock[_stakeToken]; + require(getOperatorActiveStakeAmount(_stakeToken, _operator) >= _amountToLock, "Insufficient stake amount"); + + lockInfo[_jobId] = Struct.SymbioticStakingLock(_stakeToken, _amountToLock); + operatorLockedAmounts[_stakeToken][_operator] += _amountToLock; + + // TODO: emit event + } + + function onJobCompletion( + uint256 _jobId, + address _operator, + uint256 _feeRewardAmount, + uint256 _inflationRewardAmount, + uint256 _inflationRewardTimestampIdx + ) external onlyStakingManager { + Struct.SymbioticStakingLock memory lock = lockInfo[_jobId]; + + // currentEpoch => currentTimestampIdx + IInflationRewardManager(inflationRewardManager).updateEpochTimestampIdx(); + + // distribute fee reward + if (_feeRewardAmount > 0) { + uint256 currentTimestampIdx = latestConfirmedTimestampIdx(); + uint256 transmitterComission = + Math.mulDiv(_feeRewardAmount, confirmedTimestamps[currentTimestampIdx].transmitterComissionRate, 1e18); + uint256 feeRewardRemaining = _feeRewardAmount - transmitterComission; + + // reward the transmitter who created the latestConfirmedTimestamp at the time of job creation + JobManager(jobManager).transferFeeToken(confirmedTimestamps[currentTimestampIdx].transmitter, transmitterComission); + + // distribute the remaining fee reward + _distributeFeeReward(lock.stakeToken, _operator, feeRewardRemaining); + + ISymbioticStakingReward(rewardDistributor).updateFeeReward(lock.stakeToken, _operator, feeRewardRemaining); + } + + // distribute inflation reward + if (_inflationRewardAmount > 0) { + uint256 transmitterComission = Math.mulDiv( + _inflationRewardAmount, confirmedTimestamps[_inflationRewardTimestampIdx].transmitterComissionRate, 1e18 + ); + uint256 inflationRewardRemaining = _inflationRewardAmount - transmitterComission; + + // reward the transmitter who created the latestConfirmedTimestamp at the time of job creation + IInflationRewardManager(inflationRewardManager).transferInflationRewardToken( + confirmedTimestamps[_inflationRewardTimestampIdx].transmitter, transmitterComission + ); + + // distribute the remaining inflation reward + _distributeInflationReward(_operator, inflationRewardRemaining); + + ISymbioticStakingReward(rewardDistributor).updateInflationReward(_operator, inflationRewardRemaining); + } + + // unlock the stake locked during job creation + delete lockInfo[_jobId]; + operatorLockedAmounts[lock.stakeToken][_operator] -= amountToLock[lock.stakeToken]; + + // TODO: emit event + } + + /*------------------------------------- slash ------------------------------------*/ + + function slash(Struct.JobSlashed[] calldata _slashedJobs) external onlyStakingManager { + uint256 len = _slashedJobs.length; + for (uint256 i = 0; i < len; i++) { + Struct.SymbioticStakingLock memory lock = lockInfo[_slashedJobs[i].jobId]; + + uint256 lockedAmount = lock.amount; + + // unlock the stake locked during job creation + operatorLockedAmounts[lock.stakeToken][_slashedJobs[i].operator] -= lockedAmount; + delete lockInfo[_slashedJobs[i].jobId]; + + // TODO: emit events? + } + } + + /// @notice called when pending inflation reward is updated + function distributeInflationReward(address _operator, uint256 _rewardAmount, uint256 _timestampIdx) + external + onlyStakingManager + { + if (_rewardAmount == 0) return; + + uint256 transmitterComission = + Math.mulDiv(_rewardAmount, confirmedTimestamps[_timestampIdx].transmitterComissionRate, 1e18); + uint256 inflationRewardRemaining = _rewardAmount - transmitterComission; + + // reward the transmitter who created the latestConfirmedTimestamp at the time of job creation + IERC20(inflationRewardToken).safeTransfer(confirmedTimestamps[_timestampIdx].transmitter, transmitterComission); + + uint256 len = stakeTokenSet.length(); + for (uint256 i = 0; i < len; i++) { + _distributeInflationReward( + _operator, _calcInflationRewardAmount(stakeTokenSet.at(i), inflationRewardRemaining) + ); // TODO: gas optimization + } + } + + /*===================================================== internal ====================================================*/ + + /*------------------------------- Snapshot Submission ----------------------------*/ + + function _checkTransmitterRegistration(uint256 _captureTimestamp) internal { + if (registeredTransmitters[_captureTimestamp] == address(0)) { + // once transmitter is registered, other transmitters cannot submit the snapshot for the same capturetimestamp + registeredTransmitters[_captureTimestamp] = msg.sender; + } else { + require(registeredTransmitters[_captureTimestamp] == msg.sender, "Not registered transmitter"); + } + } + + function _updateTxCountInfo(uint256 _numOfTxs, uint256 _captureTimestamp, bytes32 _type) internal { + Struct.SnapshotTxCountInfo memory _snapshot = txCountInfo[_captureTimestamp][msg.sender][_type]; + + // update length if 0 + if (_snapshot.numOfTxs == 0) { + txCountInfo[_captureTimestamp][msg.sender][_type].numOfTxs = _numOfTxs; + } + + // increase count by 1 + txCountInfo[_captureTimestamp][msg.sender][_type].idxToSubmit += 1; + } + + function _submitVaultSnapshot(uint256 _captureTimestamp, Struct.VaultSnapshot[] memory _vaultSnapshots) internal { + for (uint256 i = 0; i < _vaultSnapshots.length; i++) { + Struct.VaultSnapshot memory _vaultSnapshot = _vaultSnapshots[i]; + + // update vault staked amount + vaultStakeAmounts[_captureTimestamp][_vaultSnapshot.stakeToken][_vaultSnapshot.vault][_vaultSnapshot + .operator] = _vaultSnapshot.stakeAmount; + + // update operator staked amount + operatorStakeAmounts[_captureTimestamp][_vaultSnapshot.stakeToken][_vaultSnapshot.operator] += + _vaultSnapshot.stakeAmount; + + ISymbioticStakingReward(rewardDistributor).onSnapshotSubmission( + _vaultSnapshot.vault, _vaultSnapshot.operator + ); + + // TODO: emit event for each update? + } + } + + function _completeSubmission(uint256 _captureTimestamp) internal { + uint256 transmitterComission = _calcTransmitterComissionRate(_captureTimestamp); + + Struct.ConfirmedTimestamp memory confirmedTimestamp = + Struct.ConfirmedTimestamp(_captureTimestamp, msg.sender, transmitterComission); + confirmedTimestamps.push(confirmedTimestamp); + + IInflationRewardManager(inflationRewardManager).updateEpochTimestampIdx(); + // TODO: emit event + } + + /*------------------------------ Reward Distribution -----------------------------*/ + + function _distributeFeeReward(address _stakeToken, address _operator, uint256 _amount) internal {} + + function _distributeInflationReward(address _operator, uint256 _amount) internal { + ISymbioticStakingReward(rewardDistributor).updateInflationReward(_operator, _amount); + } + + /*================================================== external view ==================================================*/ + + function latestConfirmedTimestamp() public view returns (uint256) { + uint256 len = confirmedTimestamps.length; + return len > 0 ? confirmedTimestamps[len - 1].captureTimestamp : 0; + } + + function confirmedTimestampInfo(uint256 _idx) public view returns (Struct.ConfirmedTimestamp memory) { + return confirmedTimestamps[_idx]; + } + + function latestConfirmedTimestampIdx() public view returns (uint256) { + uint256 len = confirmedTimestamps.length; + return len > 0 ? len - 1 : 0; + } + + function getOperatorStakeAmount(address _stakeToken, address _operator) public view returns (uint256) { + return operatorStakeAmounts[latestConfirmedTimestamp()][_stakeToken][_operator]; + } + + function getOperatorActiveStakeAmount(address _stakeToken, address _operator) public view returns (uint256) { + uint256 operatorStakeAmount = getOperatorStakeAmount(_stakeToken, _operator); + uint256 operatorLockedAmount = operatorLockedAmounts[_stakeToken][_operator]; + return operatorStakeAmount > operatorLockedAmount ? operatorStakeAmount - operatorLockedAmount : 0; + } + + function getStakeAmount(address _stakeToken, address _vault, address _operator) external view returns (uint256) { + return vaultStakeAmounts[latestConfirmedTimestamp()][_stakeToken][_vault][_operator]; + } + + function getStakeTokenList() external view returns (address[] memory) { + return stakeTokenSet.values(); + } + + function getStakeTokenWeights() external view returns (address[] memory, uint256[] memory) { + uint256[] memory weights = new uint256[](stakeTokenSet.length()); + for (uint256 i = 0; i < stakeTokenSet.length(); i++) { + weights[i] = tokenSelectionWeight[stakeTokenSet.at(i)]; + } + return (stakeTokenSet.values(), weights); + } + + function isSupportedStakeToken(address _stakeToken) public view returns (bool) { + return stakeTokenSet.contains(_stakeToken); + } + + function getTxCountInfo(uint256 _captureTimestamp, address _transmitter, bytes32 _type) + external + view + returns (Struct.SnapshotTxCountInfo memory) + { + return txCountInfo[_captureTimestamp][_transmitter][_type]; + } + + function getSubmissionStatus(uint256 _captureTimestamp, address _transmitter) external view returns (bytes32) { + return submissionStatus[_captureTimestamp][_transmitter]; + } + + /*================================================== internal view ==================================================*/ + + /*------------------------------ Snapshot Submission -----------------------------*/ + + function _checkValidity(uint256 _index, uint256 _numOfTxs, uint256 _captureTimestamp, bytes32 _type) + internal + view + { + require(_numOfTxs > 0, "Invalid length"); + + // snapshot cannot be submitted before the cooldown period from the last confirmed timestamp (completed snapshot submission) + require(_captureTimestamp >= (latestConfirmedTimestamp() + submissionCooldown), "Cooldown period not passed"); + require(_captureTimestamp <= block.timestamp, "Invalid timestamp"); + + Struct.SnapshotTxCountInfo memory snapshot = txCountInfo[_captureTimestamp][msg.sender][_type]; + require(_index == snapshot.idxToSubmit, "Invalid index"); + require(_index < _numOfTxs, "Invalid index"); // here we assume enclave submis the correct data + + bytes32 mask; + if (_type == STAKE_SNAPSHOT_TYPE) mask = STAKE_SNAPSHOT_MASK; + else if (_type == SLASH_RESULT_TYPE) mask = SLASH_RESULT_MASK; + + require(submissionStatus[_captureTimestamp][msg.sender] & mask == 0, "Already submitted"); + } + + function _verifySignature( + uint256 _index, + uint256 _numOfTxs, + uint256 _captureTimestamp, + bytes memory _data, + bytes memory _signature + ) internal { + // TODO: Verify the signature + // TODO: "signature" should be from the enclave key that is verified against the PCR values of the bridge enclave image + } + + function _isCompleteStatus(uint256 _captureTimestamp) internal view returns (bool) { + return submissionStatus[_captureTimestamp][msg.sender] == COMPLETE_MASK; + } + + function _calcTransmitterComissionRate(uint256 _confirmedTimestamp) internal view returns (uint256) { + // TODO: (block.timestamp - _lastConfirmedTimestamp) * X + return baseTransmitterComissionRate; + } + + function _currentTransmitter() internal view returns (address) { + return confirmedTimestamps[latestConfirmedTimestampIdx()].transmitter; + } + + /*-------------------------------------- Job -------------------------------------*/ + + function _selectStakeToken(address _operator) internal view returns (address) { + require(tokenSelectionWeightSum > 0, "Total weight must be greater than zero"); + require(stakeTokenSet.length() > 0, "No tokens available"); + + address[] memory tokens = new address[](stakeTokenSet.length()); + uint256[] memory weights = new uint256[](stakeTokenSet.length()); + + uint256 weightSum = tokenSelectionWeightSum; + + uint256 idx = 0; + uint256 len = stakeTokenSet.length(); + for (uint256 i = 0; i < len; i++) { + address token = stakeTokenSet.at(i); + uint256 weight = tokenSelectionWeight[token]; + // ignore if weight is 0 + if (weight > 0) { + tokens[idx] = token; + weights[idx] = weight; + idx++; + } + } + + // repeat until a valid token is selected + while (true) { + require(idx > 0, "No stakeToken available"); + + // random number in range [0, weightSum - 1] + uint256 random = uint256( + keccak256(abi.encodePacked(block.timestamp, blockhash(block.number - 1), msg.sender)) + ) % weightSum; + + uint256 cumulativeWeight = 0; + address selectedToken; + + uint256 i; + // select token based on weight + for (i = 0; i < idx; i++) { + cumulativeWeight += weights[i]; + if (random < cumulativeWeight) { + selectedToken = tokens[i]; + break; + } + } + + // check if the selected token has enough active stake amount + if (getOperatorActiveStakeAmount(selectedToken, _operator) >= amountToLock[selectedToken]) { + return selectedToken; + } + + weightSum -= weights[i]; + tokens[i] = tokens[idx - 1]; + weights[i] = weights[idx - 1]; + idx--; // 배열 크기를 줄임 + } + + // this should be returned + return address(0); + } + + function _getActiveStakeAmount(address _stakeToken) internal view returns (uint256) { + // TODO + } + + function _transmitterComissionRate(uint256 _lastConfirmedTimestamp) internal view returns (uint256) { + // TODO: implement logic + return baseTransmitterComissionRate; + } + + /*------------------------------------ Reward ------------------------------------*/ + + function _calcInflationRewardAmount(address _stakeToken, uint256 _inflationRewardAmount) + internal + view + returns (uint256) + { + return Math.mulDiv(_inflationRewardAmount, inflationRewardShare[_stakeToken], 1e18); + } + + /*====================================================== admin ======================================================*/ + + function setStakingManager(address _stakingManager) external onlyRole(DEFAULT_ADMIN_ROLE) { + stakingManager = _stakingManager; + + // TODO: emit event + } + + function addStakeToken(address _stakeToken, uint256 _weight) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(stakeTokenSet.add(_stakeToken), "Token already exists"); + + tokenSelectionWeightSum += _weight; + tokenSelectionWeight[_stakeToken] = _weight; + } + + function setAmountToLock(address _stakeToken, uint256 _amount) external onlyRole(DEFAULT_ADMIN_ROLE) { + amountToLock[_stakeToken] = _amount; + } + + function removeStakeToken(address _stakeToken) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(stakeTokenSet.remove(_stakeToken), "Token does not exist"); + + tokenSelectionWeightSum -= tokenSelectionWeight[_stakeToken]; + delete tokenSelectionWeight[_stakeToken]; + } + + function setStakeTokenWeight(address _stakeToken, uint256 _weight) external onlyRole(DEFAULT_ADMIN_ROLE) { + tokenSelectionWeightSum -= tokenSelectionWeight[_stakeToken]; + tokenSelectionWeight[_stakeToken] = _weight; + tokenSelectionWeightSum += _weight; + } + + function setSubmissionCooldown(uint256 _submissionCooldown) external onlyRole(DEFAULT_ADMIN_ROLE) { + submissionCooldown = _submissionCooldown; + + // TODO: emit event + } + + /// @dev base transmitter comission rate is in range [0, 1e18) + function setBaseTransmitterComissionRate(uint256 _baseTransmitterComission) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_baseTransmitterComission < 1e18, "Invalid comission rate"); + + baseTransmitterComissionRate = _baseTransmitterComission; + + // TODO: emit event + } + + /*==================================================== overrides ====================================================*/ + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC165Upgradeable, AccessControlUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + function _authorizeUpgrade(address /*account*/ ) internal view override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/contracts/staking/l2_contracts/SymbioticStakingReward.sol b/contracts/staking/l2_contracts/SymbioticStakingReward.sol new file mode 100644 index 0000000..7536c90 --- /dev/null +++ b/contracts/staking/l2_contracts/SymbioticStakingReward.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +/* Contracts */ +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {JobManager} from "./JobManager.sol"; + +/* Interfaces */ +import {IJobManager} from "../../interfaces/staking/IJobManager.sol"; +import {IInflationRewardManager} from "../../interfaces/staking/IInflationRewardManager.sol"; +import {ISymbioticStaking} from "../../interfaces/staking/ISymbioticStaking.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/* Libraries */ +import {Struct} from "../../lib/staking/Struct.sol"; + +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +contract SymbioticStakingReward is + ContextUpgradeable, + ERC165Upgradeable, + AccessControlUpgradeable, + ReentrancyGuardUpgradeable, + PausableUpgradeable, + UUPSUpgradeable +{ + using SafeERC20 for IERC20; + using EnumerableSet for EnumerableSet.AddressSet; + using Math for uint256; + + // gaps in case we new vars in same file + uint256[500] private __gap_0; + + + address public jobManager; + address public symbioticStaking; + address public inflationRewardManager; + + address public feeRewardToken; + address public inflationRewardToken; + + // gaps in case we new vars in same file + uint256[500] private __gap_1; + + // rewardTokens amount per stakeToken + mapping(address stakeToken => mapping(address rewardToken => mapping(address operator => uint256 rewardPerToken))) public + rewardPerTokenStored; + + mapping( + address stakeToken + => mapping( + address rewardToken + => mapping(address vault => mapping(address operator => uint256 rewardPerTokenPaid)) + ) + ) public rewardPerTokenPaids; + + // reward accrued that the vault can claim + mapping(address rewardToken => mapping(address vault => uint256 amount)) public rewardAccrued; + + + modifier onlySymbioticStaking() { + require(_msgSender() == symbioticStaking, "Caller is not the staking manager"); + _; + } + + /*=============================================== initialize ===============================================*/ + + function initialize( + address _admin, + address _inflationRewardManager, + address _jobManager, + address _symbioticStaking, + address _feeRewardToken, + address _inflationRewardToken + ) public initializer { + __Context_init_unchained(); + __ERC165_init_unchained(); + __AccessControl_init_unchained(); + __UUPSUpgradeable_init_unchained(); + __ReentrancyGuard_init_unchained(); + __ReentrancyGuard_init_unchained(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + + require(_inflationRewardManager != address(0), "SymbioticStakingReward: inflationRewardManager address is zero"); + inflationRewardManager = _inflationRewardManager; + + require(_jobManager != address(0), "SymbioticStakingReward: jobManager address is zero"); + jobManager = _jobManager; + + require(_symbioticStaking != address(0), "SymbioticStakingReward: symbioticStaking address is zero"); + symbioticStaking = _symbioticStaking; + + require(_feeRewardToken != address(0), "SymbioticStakingReward: feeRewardToken address is zero"); + feeRewardToken = _feeRewardToken; + + require(_inflationRewardToken != address(0), "SymbioticStakingReward: inflationRewardToken address is zero"); + inflationRewardToken = _inflationRewardToken; + } + + /*================================================ external ================================================*/ + + /* ------------------------- reward update ------------------------- */ + + /// @notice called when fee reward is generated + /// @dev triggered from JobManager when job is completed + function updateFeeReward(address _stakeToken, address _operator, uint256 _rewardAmount) + external + onlySymbioticStaking + { + uint256 operatorStakeAmount = _getOperatorStakeAmount(_stakeToken, _operator); + if (operatorStakeAmount > 0) { + rewardPerTokenStored[_stakeToken][feeRewardToken][_operator] += + _rewardAmount.mulDiv(1e18, operatorStakeAmount); + } + + } + + /// @notice called when inflation reward is generated + /// @dev this function is not called if there is no pending inflation reward in JobManager + function updateInflationReward(address _operator, uint256 _rewardAmount) external onlySymbioticStaking { + address[] memory stakeTokenList = _getStakeTokenList(); + for (uint256 i = 0; i < stakeTokenList.length; i++) { + uint256 operatorStakeAmount = _getOperatorStakeAmount(stakeTokenList[i], _operator); + // TODO: fix this logic + if (operatorStakeAmount > 0) { + rewardPerTokenStored[stakeTokenList[i]][inflationRewardToken][_operator] += + _rewardAmount.mulDiv(1e18, operatorStakeAmount); + } + } + } + + function onSnapshotSubmission(address _vault, address _operator) external onlySymbioticStaking { + _updateVaultReward(_getStakeTokenList(), _vault, _operator); + } + + /* ------------------------- reward claim ------------------------- */ + + /// @notice vault can claim reward calling this function + function claimReward(address _operator) external nonReentrant { + // update pending inflation reward for the operator + _updatePendingInflaionReward(_operator); + + // update rewardPerTokenPaid and rewardAccrued for each vault + _updateVaultReward(_getStakeTokenList(), _msgSender(), _operator); + + address[] memory stakeTokenList = _getStakeTokenList(); + for (uint256 i = 0; i < stakeTokenList.length; i++) { + } + + + // TODO: check transfer logic + // transfer fee reward to the vault + uint256 feeRewardAmount = rewardAccrued[_msgSender()][feeRewardToken]; + if (feeRewardAmount > 0) { + JobManager(jobManager).transferFeeToken(_msgSender(), feeRewardAmount); + rewardAccrued[_msgSender()][feeRewardToken] = 0; + } + + // transfer inflation reward to the vault + uint256 inflationRewardAmount = rewardAccrued[_msgSender()][inflationRewardToken]; + if (inflationRewardAmount > 0) { + IInflationRewardManager(inflationRewardManager).transferInflationRewardToken(_msgSender(), inflationRewardAmount); + rewardAccrued[_msgSender()][inflationRewardToken] = 0; + } + } + + /*================================================== external view ==================================================*/ + + function getVaultRewardAccrued(address _vault) external view returns (uint256 feeReward, uint256 inflationReward) { + // TODO: this does not include pending inflation reward as it requires states update in JobManager + return (rewardAccrued[feeRewardToken][_vault], rewardAccrued[inflationRewardToken][_vault]); + } + + /*===================================================== internal ====================================================*/ + + /// @dev this will update pending inflation reward and rewardPerToken for the operator + function _updatePendingInflaionReward(address _operator) internal { + IInflationRewardManager(inflationRewardManager).updatePendingInflationReward(_operator); + } + + /// @dev update rewardPerToken and rewardAccrued for each vault + function _updateVaultReward(address[] memory _stakeTokenList, address _vault, address _operator) + internal + { + for (uint256 i = 0; i < _stakeTokenList.length; i++) { + address stakeToken = _stakeTokenList[i]; + + /* fee reward */ + uint256 operatorRewardPerTokenStored = rewardPerTokenStored[stakeToken][feeRewardToken][_operator]; + uint256 vaultRewardPerTokenPaid = rewardPerTokenPaids[stakeToken][feeRewardToken][_vault][_operator]; + + // update reward accrued for the vault + rewardAccrued[_vault][feeRewardToken] += _getVaultStakeAmount(stakeToken, _vault, _operator).mulDiv( + operatorRewardPerTokenStored - vaultRewardPerTokenPaid, 1e18 + ); + + + // update rewardPerTokenPaid of the vault + rewardPerTokenPaids[stakeToken][feeRewardToken][_vault][_operator] = operatorRewardPerTokenStored; + + + /* inflation reward */ + operatorRewardPerTokenStored = rewardPerTokenStored[stakeToken][inflationRewardToken][_operator]; + vaultRewardPerTokenPaid = rewardPerTokenPaids[stakeToken][inflationRewardToken][_vault][_operator]; + + // update reward accrued for the vault + rewardAccrued[_vault][inflationRewardToken] += _getVaultStakeAmount(stakeToken, _vault, _operator).mulDiv( + operatorRewardPerTokenStored - vaultRewardPerTokenPaid, 1e18 + ); + + // update rewardPerTokenPaid of the vault + rewardPerTokenPaids[stakeToken][inflationRewardToken][_vault][_operator] = operatorRewardPerTokenStored; + } + } + + /*================================================== internal view ==================================================*/ + + function _getStakeTokenList() internal view returns (address[] memory) { + return ISymbioticStaking(symbioticStaking).getStakeTokenList(); + } + + function _getOperatorStakeAmount(address _stakeToken, address _operator) internal view returns (uint256) { + return ISymbioticStaking(symbioticStaking).getOperatorStakeAmount(_stakeToken, _operator); + } + + function _getVaultStakeAmount(address _stakeToken, address _vault, address _operator) internal view returns (uint256) { + return ISymbioticStaking(symbioticStaking).getStakeAmount(_stakeToken, _vault,_operator); + } + + /*======================================================= admin =====================================================*/ + + function setStakingPool(address _symbioticStaking) public onlyRole(DEFAULT_ADMIN_ROLE) { + symbioticStaking = _symbioticStaking; + } + + function setJobManager(address _jobManager) public onlyRole(DEFAULT_ADMIN_ROLE) { + jobManager = _jobManager; + } + + function setFeeRewardToken(address _feeRewardToken) public onlyRole(DEFAULT_ADMIN_ROLE) { + feeRewardToken = _feeRewardToken; + } + + function setInflationRewardToken(address _inflationRewardToken) public onlyRole(DEFAULT_ADMIN_ROLE) { + inflationRewardToken = _inflationRewardToken; + } + + /*===================================================== overrides ===================================================*/ + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC165Upgradeable, AccessControlUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + function _authorizeUpgrade(address /*account*/ ) internal view override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..083817d --- /dev/null +++ b/foundry.toml @@ -0,0 +1,13 @@ +[profile.default] +src = "contracts" +out = "artifacts" +libs = ["lib", "node_modules"] +remappings = [ + "@openzeppelin/=node_modules/@openzeppelin/", + "eth-gas-reporter/=node_modules/eth-gas-reporter/", + "hardhat/=node_modules/hardhat/", + "forge-std/=lib/forge-std/src/", +] +auto_detect_solc = true + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/hardhat.config.ts b/hardhat.config.ts index 700945e..b031394 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -1,14 +1,15 @@ -import { HardhatUserConfig, task } from "hardhat/config"; -import "@nomicfoundation/hardhat-toolbox"; -import "@openzeppelin/hardhat-upgrades"; -import "@nomicfoundation/hardhat-chai-matchers"; +import '@nomicfoundation/hardhat-toolbox'; +import '@openzeppelin/hardhat-upgrades'; +import '@nomicfoundation/hardhat-chai-matchers'; +import 'hardhat-gas-reporter'; +import 'solidity-coverage'; -import "hardhat-gas-reporter"; -import "solidity-coverage"; - -import { config as dotenvConfig } from "dotenv"; - -import BigNumber from "bignumber.js"; +import BigNumber from 'bignumber.js'; +import { config as dotenvConfig } from 'dotenv'; +import { + HardhatUserConfig, + task, +} from 'hardhat/config'; dotenvConfig(); @@ -24,6 +25,16 @@ task("accounts", "Prints the list of accounts", async (taskArgs, hre) => { const config: HardhatUserConfig = { solidity: { compilers: [ + { + version: "0.8.26", + settings: { + viaIR: true, + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, { version: "0.8.24", settings: { @@ -78,67 +89,67 @@ const config: HardhatUserConfig = { hardhat: { blockGasLimit: 500000000000, }, - sepolia: { - url: `${process.env.SEPOLIA_RPC_URL}`, - // NOTE: don't change the order of elements in the array, add new elements at the last. - accounts: [ - `${process.env.SEPOLIA_ADMIN}`, - `${process.env.SEPOLIA_TOKEN_HOLDER}`, - `${process.env.SEPOLIA_TREASURY}`, - `${process.env.SEPOLIA_MARKET_CREATOR}`, - `${process.env.SEPOLIA_GENERATOR}`, - `${process.env.SEPOLIA_MATCHING_ENGINE}`, - `${process.env.SEPOLIA_PROOF_REQUESTOR}`, - ], - }, - arbSepolia: { - url: `${process.env.ARB_SEPOLIA_RPC_URL}`, - accounts: [ - `${process.env.SEPOLIA_ADMIN}`, - `${process.env.SEPOLIA_TOKEN_HOLDER}`, - `${process.env.SEPOLIA_TREASURY}`, - `${process.env.SEPOLIA_MARKET_CREATOR}`, - `${process.env.SEPOLIA_GENERATOR}`, - `${process.env.SEPOLIA_MATCHING_ENGINE}`, - `${process.env.SEPOLIA_PROOF_REQUESTOR}`, - ], - }, - nova: { - url: `${process.env.NOVA_RPC_URL}`, - accounts: [ - `${process.env.NOVA_ADMIN}`, - `${process.env.NOVA_TOKEN_HOLDER}`, - `${process.env.NOVA_TREASURY}`, - `${process.env.NOVA_MARKET_CREATOR}`, - `${process.env.NOVA_GENERATOR}`, - `${process.env.NOVA_MATCHING_ENGINE}`, - `${process.env.NOVA_PROOF_REQUESTOR}`, - ], - }, - zksync: { - url: `${process.env.ZKSYNC_URL}`, - accounts: [ - `${process.env.ZKSYNC_ADMIN}`, - `${process.env.ZKSYNC_TOKEN_HOLDER}`, - `${process.env.ZKSYNC_TREASURY}`, - `${process.env.ZKSYNC_MARKET_CREATOR}`, - `${process.env.ZKSYNC_GENERATOR}`, - `${process.env.ZKSYNC_MATCHING_ENGINE}`, - `${process.env.ZKSYNC_PROOF_REQUESTOR}`, - ], - }, - amoy: { - url: `${process.env.AMOY_RPC}`, - accounts: [ - `${process.env.AMOY_ADMIN}`, - `${process.env.AMOY_TOKEN_HOLDER}`, - `${process.env.AMOY_TREASURY}`, - `${process.env.AMOY_MARKET_CREATOR}`, - `${process.env.AMOY_GENERATOR}`, - `${process.env.AMOY_MATCHING_ENGINE}`, - `${process.env.AMOY_PROOF_REQUESTOR}`, - ], - }, + // sepolia: { + // url: `${process.env.SEPOLIA_RPC_URL}`, + // // NOTE: don't change the order of elements in the array, add new elements at the last. + // accounts: [ + // `${process.env.SEPOLIA_ADMIN}`, + // `${process.env.SEPOLIA_TOKEN_HOLDER}`, + // `${process.env.SEPOLIA_TREASURY}`, + // `${process.env.SEPOLIA_MARKET_CREATOR}`, + // `${process.env.SEPOLIA_GENERATOR}`, + // `${process.env.SEPOLIA_MATCHING_ENGINE}`, + // `${process.env.SEPOLIA_PROOF_REQUESTOR}`, + // ], + // }, + // arbSepolia: { + // url: `${process.env.ARB_SEPOLIA_RPC_URL}`, + // accounts: [ + // `${process.env.SEPOLIA_ADMIN}`, + // `${process.env.SEPOLIA_TOKEN_HOLDER}`, + // `${process.env.SEPOLIA_TREASURY}`, + // `${process.env.SEPOLIA_MARKET_CREATOR}`, + // `${process.env.SEPOLIA_GENERATOR}`, + // `${process.env.SEPOLIA_MATCHING_ENGINE}`, + // `${process.env.SEPOLIA_PROOF_REQUESTOR}`, + // ], + // }, + // nova: { + // url: `${process.env.NOVA_RPC_URL}`, + // accounts: [ + // `${process.env.NOVA_ADMIN}`, + // `${process.env.NOVA_TOKEN_HOLDER}`, + // `${process.env.NOVA_TREASURY}`, + // `${process.env.NOVA_MARKET_CREATOR}`, + // `${process.env.NOVA_GENERATOR}`, + // `${process.env.NOVA_MATCHING_ENGINE}`, + // `${process.env.NOVA_PROOF_REQUESTOR}`, + // ], + // }, + // zksync: { + // url: `${process.env.ZKSYNC_URL}`, + // accounts: [ + // `${process.env.ZKSYNC_ADMIN}`, + // `${process.env.ZKSYNC_TOKEN_HOLDER}`, + // `${process.env.ZKSYNC_TREASURY}`, + // `${process.env.ZKSYNC_MARKET_CREATOR}`, + // `${process.env.ZKSYNC_GENERATOR}`, + // `${process.env.ZKSYNC_MATCHING_ENGINE}`, + // `${process.env.ZKSYNC_PROOF_REQUESTOR}`, + // ], + // }, + // amoy: { + // url: `${process.env.AMOY_RPC}`, + // accounts: [ + // `${process.env.AMOY_ADMIN}`, + // `${process.env.AMOY_TOKEN_HOLDER}`, + // `${process.env.AMOY_TREASURY}`, + // `${process.env.AMOY_MARKET_CREATOR}`, + // `${process.env.AMOY_GENERATOR}`, + // `${process.env.AMOY_MATCHING_ENGINE}`, + // `${process.env.AMOY_PROOF_REQUESTOR}`, + // ], + // }, }, }; diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..8f24d6b --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 8f24d6b04c92975e0795b5868aa0d783251cdeaa diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..864ddd1 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,4 @@ +@openzeppelin/=node_modules/@openzeppelin/ +eth-gas-reporter/=node_modules/eth-gas-reporter/ +hardhat/=node_modules/hardhat/ +forge-std/=lib/forge-std/src/ diff --git a/test/foundry/TestSetup.t.sol b/test/foundry/TestSetup.t.sol new file mode 100644 index 0000000..f8acf8d --- /dev/null +++ b/test/foundry/TestSetup.t.sol @@ -0,0 +1,377 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {Test, console} from "forge-std/Test.sol"; + +/* mocks */ +import {USDC} from "./mocks/USDC.sol"; +import {POND} from "./mocks/POND.sol"; +import {WETH} from "./mocks/WETH.sol"; + +/* contracts */ +import {JobManager} from "../../contracts/staking/l2_contracts/JobManager.sol"; +import {StakingManager} from "../../contracts/staking/l2_contracts/StakingManager.sol"; +import {NativeStaking} from "../../contracts/staking/l2_contracts/NativeStaking.sol"; +import {SymbioticStaking} from "../../contracts/staking/l2_contracts/SymbioticStaking.sol"; +import {SymbioticStakingReward} from "../../contracts/staking/l2_contracts/SymbioticStakingReward.sol"; +import {InflationRewardManager} from "../../contracts/staking/l2_contracts/InflationRewardManger.sol"; + +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +/* interfaces */ +import {IJobManager} from "../../contracts/interfaces/staking/IJobManager.sol"; +import {IStakingManager} from "../../contracts/interfaces/staking/IStakingManager.sol"; +import {IInflationRewardManager} from "../../contracts/interfaces/staking/IInflationRewardManager.sol"; +import {INativeStaking} from "../../contracts/interfaces/staking/INativeStaking.sol"; +import {ISymbioticStaking} from "../../contracts/interfaces/staking/ISymbioticStaking.sol"; +import {ISymbioticStakingReward} from "../../contracts/interfaces/staking/ISymbioticStakingReward.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/* libraries */ +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +contract TestSetup is Test { + uint256 constant public TWENTY_PERCENT = 20; + uint256 constant public THIRTY_PERCENT = 30; + uint256 constant public FORTY_PERCENT = 40; + uint256 constant public FIFTY_PERCENT = 50; + uint256 constant public SIXTY_PERCENT = 60; + uint256 constant public HUNDRED_PERCENT = 100; + + uint256 constant public FUND_FOR_GAS = 10 ether; // 10 ether + uint256 constant public FUND_FOR_FEE = 10_000 ether; // 10,000 USDC + uint256 constant public FUND_FOR_SELF_STAKE = 1000_000 ether; // 10,000 POND + uint256 constant public FUND_FOR_INFLATION_REWARD = 100_000 ether; // 100,000 POND + + uint256 constant public INFLATION_REWARD_EPOCH_SIZE = 30 minutes; // 30 minutes + uint256 constant public INFLATION_REWARD_PER_EPOCH = 100 ether; // 1,000 POND + + uint256 constant public SUBMISSION_COOLDOWN = 12 hours; + + + /* contracts */ + address public jobManager; + address public inflationRewardManager; + + address public stakingManager; + address public nativeStaking; + address public symbioticStaking; + address public symbioticStakingReward; + + /* reward tokens */ + address public feeToken; + address public inflationRewardToken; + + /* stake tokens */ + address public usdc; + address public pond; + address public weth; + + /* admin */ + address public deployer; + address public admin; + address public inflationRewardVault; // holds inflation reward tokens + + /* operators */ + address public operatorA; + address public operatorB; + address public operatorC; + + /* symbiotic vaults */ + address public symbioticVaultA; + address public symbioticVaultB; + address public symbioticVaultC; + + /* transmitters */ + address public transmitterA; + address public transmitterB; + address public transmitterC; + + /* stakers */ + address public stakerA; + address public stakerB; + address public stakerC; + + /* slasher */ + address public slasher; + + /* job requesters */ + address public jobRequesterA; + address public jobRequesterB; + address public jobRequesterC; + + + function _setupAddr() internal { + /* set address */ + deployer = makeAddr("deployer"); + admin = makeAddr("admin"); + inflationRewardVault = makeAddr("inflationRewardVault"); + + slasher = makeAddr("slasher"); + + stakerA = makeAddr("stakerA"); + stakerB = makeAddr("stakerB"); + stakerC = makeAddr("stakerC"); + + operatorA = makeAddr("operatorA"); + operatorB = makeAddr("operatorB"); + operatorC = makeAddr("operatorC"); + + symbioticVaultA = makeAddr("symbioticVaultA"); + symbioticVaultB = makeAddr("symbioticVaultB"); + symbioticVaultC = makeAddr("symbioticVaultC"); + + transmitterA = makeAddr("transmitterA"); + transmitterB = makeAddr("transmitterB"); + transmitterC = makeAddr("transmitterC"); + + jobRequesterA = makeAddr("jobRequesterA"); + jobRequesterB = makeAddr("jobRequesterB"); + jobRequesterC = makeAddr("jobRequesterC"); + + /* fund gas */ + vm.deal(deployer, FUND_FOR_GAS); + vm.deal(admin, FUND_FOR_GAS); + vm.deal(inflationRewardVault, FUND_FOR_GAS); + + vm.deal(operatorA, FUND_FOR_GAS); + vm.deal(operatorB, FUND_FOR_GAS); + vm.deal(operatorC, FUND_FOR_GAS); + vm.deal(slasher, FUND_FOR_GAS); + + vm.deal(stakerA, FUND_FOR_GAS); + vm.deal(stakerB, FUND_FOR_GAS); + vm.deal(stakerC, FUND_FOR_GAS); + + vm.deal(transmitterA, FUND_FOR_GAS); + vm.deal(transmitterB, FUND_FOR_GAS); + vm.deal(transmitterC, FUND_FOR_GAS); + + vm.deal(jobRequesterA, FUND_FOR_GAS); + vm.deal(jobRequesterB, FUND_FOR_GAS); + vm.deal(jobRequesterC, FUND_FOR_GAS); + + /* label */ + vm.label(deployer, "deployer"); + vm.label(admin, "admin"); + vm.label(slasher, "slasher"); + + vm.label(operatorA, "operatorA"); + vm.label(operatorB, "operatorB"); + vm.label(operatorC, "operatorC"); + + vm.label(stakerA, "stakerA"); + vm.label(stakerB, "stakerB"); + vm.label(stakerC, "stakerC"); + + vm.label(symbioticVaultA, "symbioticVaultA"); + vm.label(symbioticVaultB, "symbioticVaultB"); + vm.label(symbioticVaultC, "symbioticVaultC"); + + vm.label(jobRequesterA, "jobRequesterA"); + vm.label(jobRequesterB, "jobRequesterB"); + vm.label(jobRequesterC, "jobRequesterC"); + + vm.label(transmitterA, "transmitterA"); + vm.label(transmitterB, "transmitterB"); + vm.label(transmitterC, "transmitterC"); + } + + /*======================================== internal ========================================*/ + + function _setupContracts() internal { + _deployContracts(); + _initializeContracts(); + } + + function _deployContracts() internal { + vm.startPrank(deployer); + + // FeeToken + usdc = address(new USDC(admin)); + feeToken = usdc; + + // InflationRewardToken + pond = address(new POND(admin)); + inflationRewardToken = pond; + + // stakeToken + weth = address(new WETH(admin)); + pond = inflationRewardToken; + + // contract implementations + address jobManagerImpl = address(new JobManager()); + address stakingManagerImpl = address(new StakingManager()); + address nativeStakingImpl = address(new NativeStaking()); + address symbioticStakingImpl = address(new SymbioticStaking()); + address symbioticStakingRewardImpl = address(new SymbioticStakingReward()); + address inflationRewardManagerImpl = address(new InflationRewardManager()); + + // deploy proxies + jobManager = address(new ERC1967Proxy(jobManagerImpl, "")); + stakingManager = address(new ERC1967Proxy(stakingManagerImpl, "")); + nativeStaking = address(new ERC1967Proxy(nativeStakingImpl, "")); + symbioticStaking = address(new ERC1967Proxy(symbioticStakingImpl, "")); + symbioticStakingReward = address(new ERC1967Proxy(symbioticStakingRewardImpl, "")); + inflationRewardManager = address(new ERC1967Proxy(inflationRewardManagerImpl, "")); + vm.stopPrank(); + + /* label */ + vm.label(address(jobManager), "JobManager"); + vm.label(address(stakingManager), "StakingManager"); + vm.label(address(nativeStaking), "NativeStaking"); + vm.label(address(symbioticStaking), "SymbioticStaking"); + vm.label(address(symbioticStakingReward), "SymbioticStakingReward"); + vm.label(address(inflationRewardManager), "InflationRewardManager"); + } + + function _initializeContracts() internal { + vm.startPrank(admin); + + // JobManager + JobManager(address(jobManager)).initialize( + admin, address(stakingManager), address(symbioticStaking), address(symbioticStakingReward), address(feeToken), address(inflationRewardManager), 1 hours + ); + assertEq(JobManager(jobManager).hasRole(JobManager(jobManager).DEFAULT_ADMIN_ROLE(), admin), true); + + // StakingManager + StakingManager(address(stakingManager)).initialize( + admin, + address(jobManager), + address(symbioticStaking), + address(inflationRewardManager), + address(feeToken), + address(inflationRewardToken) + ); + assertEq(StakingManager(stakingManager).hasRole(StakingManager(stakingManager).DEFAULT_ADMIN_ROLE(), admin), true); + + // NativeStaking + NativeStaking(address(nativeStaking)).initialize( + admin, + address(stakingManager), + address(0), // rewardDistributor (not set) + 2 days, // withdrawalDuration + address(feeToken), + address(inflationRewardToken) + ); + assertEq(NativeStaking(nativeStaking).hasRole(NativeStaking(nativeStaking).DEFAULT_ADMIN_ROLE(), admin), true); + + // SymbioticStaking + SymbioticStaking(address(symbioticStaking)).initialize( + admin, + jobManager, + stakingManager, + symbioticStakingReward, + inflationRewardManager, + feeToken, + inflationRewardToken + ); + assertEq(SymbioticStaking(symbioticStaking).hasRole(SymbioticStaking(symbioticStaking).DEFAULT_ADMIN_ROLE(), admin), true); + // SymbioticStakingReward + SymbioticStakingReward(address(symbioticStakingReward)).initialize( + admin, + inflationRewardManager, + jobManager, + symbioticStaking, + feeToken, + inflationRewardToken + ); + assertEq(SymbioticStakingReward(symbioticStakingReward).hasRole(SymbioticStakingReward(symbioticStakingReward).DEFAULT_ADMIN_ROLE(), admin), true); + + // InflationRewardManager + InflationRewardManager(address(inflationRewardManager)).initialize( + admin, + block.timestamp, + jobManager, + stakingManager, + symbioticStaking, + symbioticStakingReward, + inflationRewardToken, + INFLATION_REWARD_EPOCH_SIZE, // inflationRewardEpochSize + INFLATION_REWARD_PER_EPOCH // inflationRewardPerEpoch + ); + assertEq(InflationRewardManager(inflationRewardManager).hasRole(InflationRewardManager(inflationRewardManager).DEFAULT_ADMIN_ROLE(), admin), true); + vm.stopPrank(); + } + + function _setJobManagerConfig() internal { + vm.startPrank(admin); + // operatorA: 30% of the reward as comission + JobManager(jobManager).setOperatorRewardShare(operatorA, _calcShareAmount(THIRTY_PERCENT)); + // operatorB: 50% of the reward as comission + JobManager(jobManager).setOperatorRewardShare(operatorB, _calcShareAmount(FIFTY_PERCENT)); + vm.stopPrank(); + } + + function _setStakingManagerConfig() internal { + address[] memory pools = new address[](2); + pools[0] = nativeStaking; + pools[1] = symbioticStaking; + + uint256[] memory shares = new uint256[](2); + shares[0] = 0; + shares[1] = _calcShareAmount(HUNDRED_PERCENT); + + vm.startPrank(admin); + StakingManager(stakingManager).addStakingPool(nativeStaking); + StakingManager(stakingManager).addStakingPool(symbioticStaking); + + StakingManager(stakingManager).setPoolRewardShare(pools, shares); + + StakingManager(stakingManager).setEnabledPool(nativeStaking, true); + StakingManager(stakingManager).setEnabledPool(symbioticStaking, true); + vm.stopPrank(); + + assertEq(IStakingManager(stakingManager).getPoolConfig(nativeStaking).share, 0); + assertEq(IStakingManager(stakingManager).getPoolConfig(symbioticStaking).share, _calcShareAmount(HUNDRED_PERCENT)); + } + + function _setNativeStakingConfig() internal { + vm.startPrank(admin); + NativeStaking(nativeStaking).addStakeToken(pond, _calcShareAmount(HUNDRED_PERCENT)); + NativeStaking(nativeStaking).setAmountToLock(pond, 1 ether); + vm.stopPrank(); + } + + function _setSymbioticStakingConfig() internal { + vm.startPrank(admin); + + /* stake tokens and weights */ + SymbioticStaking(symbioticStaking).addStakeToken(pond, _calcShareAmount(SIXTY_PERCENT)); + SymbioticStaking(symbioticStaking).addStakeToken(weth, _calcShareAmount(FORTY_PERCENT)); + + /* base transmitter comission rate and submission cooldown */ + SymbioticStaking(symbioticStaking).setBaseTransmitterComissionRate(_calcShareAmount(TWENTY_PERCENT)); + SymbioticStaking(symbioticStaking).setSubmissionCooldown(12 hours); + + /* amount to lock */ + SymbioticStaking(symbioticStaking).setAmountToLock(pond, 0.2 ether); + SymbioticStaking(symbioticStaking).setAmountToLock(weth, 0.2 ether); + + vm.stopPrank(); + + assertEq(SymbioticStaking(symbioticStaking).baseTransmitterComissionRate(), _calcShareAmount(TWENTY_PERCENT)); + assertEq(SymbioticStaking(symbioticStaking).submissionCooldown(), SUBMISSION_COOLDOWN); + } + + function _fund_tokens() internal { + deal(pond, operatorA, FUND_FOR_SELF_STAKE); + deal(pond, operatorB, FUND_FOR_SELF_STAKE); + deal(pond, operatorC, FUND_FOR_SELF_STAKE); + + deal(usdc, jobRequesterA, FUND_FOR_FEE); + deal(usdc, jobRequesterB, FUND_FOR_FEE); + deal(usdc, jobRequesterC, FUND_FOR_FEE); + + deal(inflationRewardToken, inflationRewardManager, FUND_FOR_INFLATION_REWARD); + } + + + /*===================================== internal pure ======================================*/ + + /// @notice convert 100% -> 1e18 (i.e. 50 -> 50e17) + function _calcShareAmount(uint256 _shareIntPercentage) internal pure returns (uint256) { + return Math.mulDiv(_shareIntPercentage, 1e18, 100); + } +} diff --git a/test/foundry/e2e/KalypsoStaking.t.sol b/test/foundry/e2e/KalypsoStaking.t.sol new file mode 100644 index 0000000..9d67fb9 --- /dev/null +++ b/test/foundry/e2e/KalypsoStaking.t.sol @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import {TestSetup} from "../TestSetup.t.sol"; + +/* contracts */ +import {JobManager} from "../../../contracts/staking/l2_contracts/JobManager.sol"; +import {StakingManager} from "../../../contracts/staking/l2_contracts/StakingManager.sol"; +import {SymbioticStaking} from "../../../contracts/staking/l2_contracts/SymbioticStaking.sol"; +import {SymbioticStakingReward} from "../../../contracts/staking/l2_contracts/SymbioticStakingReward.sol"; + +/* interfaces */ +import {IJobManager} from "../../../contracts/interfaces/staking/IJobManager.sol"; +import {IStakingManager} from "../../../contracts/interfaces/staking/IStakingManager.sol"; +import {INativeStaking} from "../../../contracts/interfaces/staking/INativeStaking.sol"; +import {ISymbioticStaking} from "../../../contracts/interfaces/staking/ISymbioticStaking.sol"; +import {ISymbioticStakingReward} from "../../../contracts/interfaces/staking/ISymbioticStakingReward.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/* libraries */ +import {Struct} from "../../../contracts/lib/staking/Struct.sol"; + +contract KalypsoStakingTest is Test, TestSetup { + + uint256 constant OPERATORA_SELF_STAKE_AMOUNT = 1000 ether; + uint256 constant OPERATORB_SELF_STAKE_AMOUNT = 2000 ether; + + uint256 constant VAULT_A_INTO_OPERATOR_A = 1000 ether; + uint256 constant VAULT_B_INTO_OPERATOR_A = 2000 ether; + uint256 constant VAULT_B_INTO_OPERATOR_B = 3000 ether; + + function setUp() public { + _setupAddr(); + _setupContracts(); + _fund_tokens(); + + /*-------------------- Config --------------------*/ + /* JobManager */ + _setJobManagerConfig(); + + /* StakingManager */ + _setStakingManagerConfig(); + + /* NativeStaking */ + _setNativeStakingConfig(); + + /* SymbioticStaking */ + _setSymbioticStakingConfig(); + } + + /// @notice test full lifecycle of kalypso staking + function test_kalypso_staking() public { + /* current block nubmer: 50_001 */ + vm.warp(block.timestamp + 50_000); + assertEq(block.timestamp, 50_001); + + // operators self stake + _operator_self_stake(); + + // symbiotic staking snapshot submitted + _symbiotic_staking_snapshot_submission(); + + // jobId1 created + _create_job_1(); + + vm.warp(block.timestamp + 10 minutes); + + // proof submitted + _submit_proof_job_1(); + + // symbioticVaultA claims fee reward (no inflation reward from job1 generated yet) + _vault_claim_reward_from_job_1(); + + // jobId2 created + vm.warp(block.timestamp + INFLATION_REWARD_EPOCH_SIZE); // inflation reward for job1 should be generated + _create_job_2(); + + // jobId2 completed + _submit_proof_job_2(); // TODO: inflation reward generated + + // fee reward and inflation reward distributed + + // job created + _create_job_3(); + + // job slashed in Symbiotic Staking and result submitted + vm.warp(block.timestamp + SUBMISSION_COOLDOWN); + _slash_result_submission_job_3(); + } + + /*===================================================== internal ====================================================*/ + + function _operator_self_stake() internal { + // Operator A self stakes into 1_000 POND + vm.startPrank(operatorA); + { + IERC20(weth).approve(nativeStaking, type(uint256).max); + IERC20(pond).approve(nativeStaking, type(uint256).max); + + // weth is not supported in NativeStaking + vm.expectRevert("Token not supported"); + INativeStaking(nativeStaking).stake(weth, operatorA, OPERATORA_SELF_STAKE_AMOUNT); + + // only operator can stake + vm.expectRevert("Only operator can stake"); + INativeStaking(nativeStaking).stake(pond, operatorB, OPERATORA_SELF_STAKE_AMOUNT); + + // stake 1000 POND + INativeStaking(nativeStaking).stake(pond, operatorA, OPERATORA_SELF_STAKE_AMOUNT); + } + vm.stopPrank(); + assertEq(INativeStaking(nativeStaking).getOperatorStakeAmount(pond, operatorA), OPERATORA_SELF_STAKE_AMOUNT); + assertEq(INativeStaking(nativeStaking).getOperatorActiveStakeAmount(pond, operatorA), OPERATORA_SELF_STAKE_AMOUNT); + + vm.startPrank(operatorB); + { + IERC20(pond).approve(nativeStaking, type(uint256).max); + + INativeStaking(nativeStaking).stake(pond, operatorB, OPERATORB_SELF_STAKE_AMOUNT); + + } + vm.stopPrank(); + assertEq(INativeStaking(nativeStaking).getOperatorStakeAmount(pond, operatorB), OPERATORB_SELF_STAKE_AMOUNT, "Stake amount mismatch"); + assertEq(INativeStaking(nativeStaking).getOperatorActiveStakeAmount(pond, operatorB), OPERATORB_SELF_STAKE_AMOUNT, "Active stake amount mismatch"); + } + + function _symbiotic_staking_snapshot_submission() internal { + /* + < TransmitterA Transmits > + OperatorA: opted-into symbioticVaultA (weth) - 1000 weth, + OperatorB: opted-into symbioticVaultA (weth) - 2000 weth, symbioticVaultB (pond) - 3000 pond + */ + + // Partial Tx 1 + Struct.VaultSnapshot[] memory _vaultSnapshots1 = new Struct.VaultSnapshot[](1); + /* Vault A */ + // VaultA(1000 WETH) -> OperatorA + _vaultSnapshots1[0].operator = operatorA; + _vaultSnapshots1[0].vault = symbioticVaultA; + _vaultSnapshots1[0].stakeToken = weth; + _vaultSnapshots1[0].stakeAmount = VAULT_A_INTO_OPERATOR_A; + + // Partial Tx 2 + Struct.VaultSnapshot[] memory _vaultSnapshots2 = new Struct.VaultSnapshot[](2); + + /* Vault B */ + + // VaultA(2000 weth) -> OperatorB + _vaultSnapshots2[0].operator = operatorB; + _vaultSnapshots2[0].vault = symbioticVaultA; + _vaultSnapshots2[0].stakeToken = weth; + _vaultSnapshots2[0].stakeAmount = VAULT_B_INTO_OPERATOR_A; + + // VaultB(3000 POND) -> OperatorB + _vaultSnapshots2[1].operator = operatorB; + _vaultSnapshots2[1].vault = symbioticVaultB; + _vaultSnapshots2[1].stakeToken = pond; + _vaultSnapshots2[1].stakeAmount = VAULT_B_INTO_OPERATOR_B; + + + /* Snapshot Submission */ + vm.startPrank(transmitterA); + { + vm.expectRevert("Invalid index"); + ISymbioticStaking(symbioticStaking).submitVaultSnapshot(3, 2, abi.encode(block.timestamp - 5, _vaultSnapshots1), ""); + + vm.expectRevert("Invalid index"); + ISymbioticStaking(symbioticStaking).submitVaultSnapshot(2, 2, abi.encode(block.timestamp - 5, _vaultSnapshots1), ""); + + vm.expectRevert("Invalid timestamp"); + ISymbioticStaking(symbioticStaking).submitVaultSnapshot(1, 2, abi.encode(block.timestamp + 1, _vaultSnapshots1), ""); + + ISymbioticStaking(symbioticStaking).submitVaultSnapshot(0, 2, abi.encode(block.timestamp - 5, _vaultSnapshots1), ""); + } + vm.stopPrank(); + Struct.SnapshotTxCountInfo memory _txCountInfo = ISymbioticStaking(symbioticStaking).getTxCountInfo(block.timestamp - 5, transmitterA, keccak256("STAKE_SNAPSHOT_TYPE")); + + assertEq(_txCountInfo.idxToSubmit, 1); + assertEq(_txCountInfo.numOfTxs, 2); + assertEq(ISymbioticStaking(symbioticStaking).getSubmissionStatus(block.timestamp - 5, transmitterA), 0x0, "Submission status mismatch"); + + vm.startPrank(transmitterA); + { + ISymbioticStaking(symbioticStaking).submitVaultSnapshot(1, 2, abi.encode(block.timestamp - 5, _vaultSnapshots2), ""); + } + vm.stopPrank(); + + _txCountInfo = ISymbioticStaking(symbioticStaking).getTxCountInfo(block.timestamp - 5, transmitterA, keccak256("STAKE_SNAPSHOT_TYPE")); + assertEq(_txCountInfo.idxToSubmit, 2); + assertEq(_txCountInfo.numOfTxs, 2); + assertEq(ISymbioticStaking(symbioticStaking).getSubmissionStatus(block.timestamp - 5, transmitterA), 0x0000000000000000000000000000000000000000000000000000000000000001, "Submission status mismatch"); + + /* Slash Result Submission */ + vm.prank(transmitterA); + ISymbioticStaking(symbioticStaking).submitSlashResult(0, 1, abi.encode(block.timestamp - 5, ""), ""); + } + + function _create_job_1() internal { + // requesterA creates a job + vm.startPrank(jobRequesterA); + { + IERC20(feeToken).approve(jobManager, type(uint256).max); + uint256 jobmanagerBalanceBefore = IERC20(feeToken).balanceOf(jobManager); + + vm.expectRevert("No stakeToken available"); + IJobManager(jobManager).createJob(1, jobRequesterA, operatorC, 1 ether); // should revert as operatorC didn't stake any token to NativeStaking + + // pay 1 usdc as fee + IJobManager(jobManager).createJob(1, jobRequesterA, operatorA, 1 ether); + assertEq(IERC20(feeToken).balanceOf(jobManager) - jobmanagerBalanceBefore, 1 ether); + } + vm.stopPrank(); + } + + function _submit_proof_job_1() internal { + Struct.ConfirmedTimestamp memory _confirmedTimestampInfo = ISymbioticStaking(symbioticStaking).confirmedTimestampInfo(0); + + vm.startPrank(operatorA); + { + // reverts if submitted after deadline + vm.warp(block.timestamp + 12 hours); + vm.expectRevert("Job Expired"); + IJobManager(jobManager).submitProof(1, ""); + + vm.warp(block.timestamp - 12 hours); + IJobManager(jobManager).submitProof(1, ""); + } + vm.stopPrank(); + + /* + + fee paid: 1 usdc + + operator reward share: 30% + => 1 * 0.3 = 0.3 usdc + + transmitter comission rate: 20% + => 1 * 0.7 * 0.2 = 0.14 usdc + */ + assertEq(IERC20(feeToken).balanceOf(operatorA), 0.3 ether, "OperatorA fee reward mismatch"); + assertEq(IERC20(feeToken).balanceOf(transmitterA), 0.14 ether, "TransmitterA fee reward mismatch"); + } + + + function _vault_claim_reward_from_job_1() internal { + /* + Vault A claim fee reward + Inflation reward still in pending + */ + vm.startPrank(symbioticVaultA); + ISymbioticStakingReward(symbioticStakingReward).claimReward(operatorA); + vm.stopPrank(); + + /* + current status of staking: + operatorA: opted-into symbioticVaultA (weth) - 1000 weth, + operatorB: opted-into symbioticVaultA (weth) - 2000 weth, symbioticVaultB (pond) - 3000 pond + + operatorA has 100% reward share + + 1 USDC * 0.7(after operatorA commision 30%) * 0.8(after transmitter commision 20%) = 0.56 USDC + */ + + assertEq(IERC20(feeToken).balanceOf(symbioticVaultA), 0.56 ether, "SymbioticVaultA fee reward mismatch"); + } + + function _create_job_2() internal { + // requesterB creates a job + vm.startPrank(jobRequesterB); + { + // approve jobRequesterB -> feeToken + IERC20(feeToken).approve(jobManager, type(uint256).max); + uint256 jobmanagerBalanceBefore = IERC20(feeToken).balanceOf(jobManager); + + // pay 0.5 usdc as fee + IJobManager(jobManager).createJob(2, jobRequesterA, operatorA, 0.5 ether); + assertEq(IERC20(feeToken).balanceOf(jobManager) - jobmanagerBalanceBefore, 0.5 ether); + } + vm.stopPrank(); + } + + // inflation reward generated + function _submit_proof_job_2() internal { + vm.startPrank(operatorA); + { + IJobManager(jobManager).submitProof(2, ""); + } + vm.stopPrank(); + + /* + Inflation reward generated: 100 POND (1 job don by operator) + + OperatorA comission: 20% + => 100 * 0.3 = 30 POND + + TransmitterA comission: 20% + => 100 * 0.7(after operatorA comission) * 0.2(transmitter comission) = 14 POND + + SymbioticVaultA + */ + + // TODO: check inflation reward logic + } + + // job gets slashed + function _create_job_3() internal { + // requesterC creates a job + vm.startPrank(jobRequesterC); + { + IERC20(feeToken).approve(jobManager, type(uint256).max); + uint256 jobmanagerBalanceBefore = IERC20(feeToken).balanceOf(jobManager); + + // pay 2 usdc as fee + IJobManager(jobManager).createJob(3, jobRequesterC, operatorB, 2 ether); + assertEq(IERC20(feeToken).balanceOf(jobManager) - jobmanagerBalanceBefore, 2 ether); + } + vm.stopPrank(); + + // TODO: check job completion logic + } + + + function _slash_result_submission_job_3() internal { + uint256 jobRequesterCBalanceBefore = IERC20(feeToken).balanceOf(jobRequesterC); + + + // Partial Tx 1 + Struct.VaultSnapshot[] memory _vaultSnapshot = new Struct.VaultSnapshot[](3); + /* Vault A */ + // VaultA(1000 WETH) -> OperatorA + _vaultSnapshot[0].operator = operatorA; + _vaultSnapshot[0].vault = symbioticVaultA; + _vaultSnapshot[0].stakeToken = weth; + _vaultSnapshot[0].stakeAmount = VAULT_A_INTO_OPERATOR_A; + + /* Vault B */ + // VaultA(2000 weth) -> OperatorB + _vaultSnapshot[1].operator = operatorB; + _vaultSnapshot[1].vault = symbioticVaultA; + _vaultSnapshot[1].stakeToken = weth; + _vaultSnapshot[1].stakeAmount = VAULT_B_INTO_OPERATOR_A; + // VaultB(3000 POND) -> OperatorB + _vaultSnapshot[2].operator = operatorB; + _vaultSnapshot[2].vault = symbioticVaultB; + _vaultSnapshot[2].stakeToken = pond; + _vaultSnapshot[2].stakeAmount = VAULT_B_INTO_OPERATOR_B; + + Struct.JobSlashed[] memory _jobSlashed = new Struct.JobSlashed[](1); + + _jobSlashed[0].jobId = 3; + _jobSlashed[0].operator = operatorB; + _jobSlashed[0].rewardAddress = slasher; + + vm.startPrank(transmitterB); + { + // submit vault snapshot + ISymbioticStaking(symbioticStaking).submitVaultSnapshot(0, 1, abi.encode(block.timestamp, _vaultSnapshot), ""); + + // submit slash result + ISymbioticStaking(symbioticStaking).submitSlashResult(0, 1, abi.encode(block.timestamp, _jobSlashed), ""); + } + vm.stopPrank(); + + // check if fee is refunded + assertEq(IERC20(feeToken).balanceOf(jobRequesterC) - jobRequesterCBalanceBefore, 2 ether, "JobRequesterC fee refund mismatch"); + } +} diff --git a/test/foundry/mocks/POND.sol b/test/foundry/mocks/POND.sol new file mode 100644 index 0000000..da3b1db --- /dev/null +++ b/test/foundry/mocks/POND.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract POND is ERC20 { + + uint256 constant INITIAL_SUPPLY = 100_000_000 ether; + + constructor(address admin) ERC20("POND", "POND") { + _mint(admin, INITIAL_SUPPLY); + } +} diff --git a/test/foundry/mocks/USDC.sol b/test/foundry/mocks/USDC.sol new file mode 100644 index 0000000..fd32ee1 --- /dev/null +++ b/test/foundry/mocks/USDC.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract USDC is ERC20 { + + uint256 constant INITIAL_SUPPLY = 100_000_000 ether; + + constructor(address admin) ERC20("USDC", "USDC") { + _mint(admin, INITIAL_SUPPLY); + } +} diff --git a/test/foundry/mocks/WETH.sol b/test/foundry/mocks/WETH.sol new file mode 100644 index 0000000..c3d70c5 --- /dev/null +++ b/test/foundry/mocks/WETH.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract WETH is ERC20 { + + uint256 constant INITIAL_SUPPLY = 100_000_000 ether; + + constructor(address admin) ERC20("WETH", "WETH") { + _mint(admin, INITIAL_SUPPLY); + } +}