From ff59baeb5201526bf58709db870418bf66cfea9b Mon Sep 17 00:00:00 2001 From: Nick95550 Date: Wed, 26 Jun 2024 13:43:24 +0800 Subject: [PATCH] implement Directory interface --- common/contracts.ts | 11 +++- contracts/Directory.sol | 135 +++++++++++++++++++++++++++++++++++++++ contracts/IDirectory.sol | 36 +++++++++++ test/Directory.test.ts | 53 +++++++++++++++ test/utils.ts | 12 ++++ 5 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 contracts/Directory.sol create mode 100644 contracts/IDirectory.sol create mode 100644 test/Directory.test.ts diff --git a/common/contracts.ts b/common/contracts.ts index f23a9bdc..d688b464 100644 --- a/common/contracts.ts +++ b/common/contracts.ts @@ -14,7 +14,8 @@ export const DeployedContractNames = { authorizedAccounts: 'AuthorizedAccounts', rewardsManager: 'RewardsManager', ticketing: 'Ticketing', - StakingOrchestrator: 'StakingOrchestrator', + stakingOrchestrator: 'StakingOrchestrator', + direcrory: 'Directory', }; export const ContractNames = { @@ -34,6 +35,7 @@ export type SyloContracts = { rewardsManager: factories.contracts.RewardsManager; ticketing: factories.contracts.Ticketing; stakingOrchestrator: factories.contracts.staking.StakingOrchestrator; + directory: factories.contracts.Directory; }; export type ContractAddresses = { @@ -48,6 +50,7 @@ export type ContractAddresses = { rewardsManager: string; ticketing: string; stakingOrchestator: string; + directory: string; }; export function connectContracts( @@ -109,6 +112,11 @@ export function connectContracts( provider, ); + const directory = factories.Directory__factory.connect( + contracts.directory, + provider, + ); + return { syloToken, syloStakingManager, @@ -121,5 +129,6 @@ export function connectContracts( rewardsManager, ticketing, stakingOrchestrator, + directory, }; } diff --git a/contracts/Directory.sol b/contracts/Directory.sol new file mode 100644 index 00000000..b0fb1679 --- /dev/null +++ b/contracts/Directory.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.18; + +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import "./staking/StakingOrchestrator.sol"; +import "./ProtocolTimeManager.sol"; + +import "./IDirectory.sol"; + +contract Directory is IDirectory, Initializable, Ownable2StepUpgradeable, ERC165 { + StakingOrchestrator public stakingOrchestrator; + ProtocolTimeManager public protocolTimeManager; + + /** + * @notice Tracks each directory, these directories are apart of + * each staking period for each reward cycle + */ + mapping(uint256 => mapping(uint256 => Directory)) public directories; + + error CannotInitialiseWithZeroStakingOrchestratorAddress(); + error CannotInitialiseWithZeroProtocolTimeManagerAddress(); + error CannotJoinDirectoryWithZeroStake(); + error StakeeAlreadyJoinedEpoch(); + + function initialize( + StakingOrchestrator _stakingOrchestrator, + ProtocolTimeManager _protocolTimeManager + ) external initializer { + Ownable2StepUpgradeable.__Ownable2Step_init(); + + if (address(_stakingOrchestrator) == address(0)) { + revert CannotInitialiseWithZeroStakingOrchestratorAddress(); + } + if (address(_protocolTimeManager) == address(0)) { + revert CannotInitialiseWithZeroProtocolTimeManagerAddress(); + } + + stakingOrchestrator = _stakingOrchestrator; + protocolTimeManager = _protocolTimeManager; + } + + /** + * @notice Returns true if the contract implements the interface defined by + * `interfaceId` from ERC165. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IDirectory).interfaceId || super.supportsInterface(interfaceId); + } + + function scan(uint128 point) external view returns (address) { + uint256 currentStakingPeriod = protocolTimeManager.getCurrentPeriod(); + return _scan(point, currentStakingPeriod); + } + + function scanWithStakingPeriod( + uint128 point, + uint256 stakingPeriodId + ) external view returns (address) { + return _scan(point, stakingPeriodId); + } + + /** + * @notice Call this to perform a stake-weighted scan to find the Node assigned + * to the given point of the requested directory (internal). + * @dev The current implementation will perform a binary search through + * the directory. This can allow gas costs to be low if this needs to be + * used in a transaction. + * @param point The point, which will usually be a hash of a public key. + * @param stakingPeriodId The epoch id associated with the directory to scan. + */ + function _scan(uint128 point, uint256 stakingPeriodId) internal view returns (address stakee) { + uint256 currentRewardCycle = protocolTimeManager.getCurrentCycle(); + uint256 entryLength = directories[currentRewardCycle][stakingPeriodId].entries.length; + + if (entryLength == 0) { + return address(0); + } + + // Staking all the Sylo would only be 94 bits, so multiplying this with + // a uint128 cannot overflow a uint256. + uint256 expectedVal = (directories[currentRewardCycle][stakingPeriodId].totalStake * + uint256(point)) >> 128; + + uint256 left; + uint256 right = entryLength - 1; + + // perform a binary search through the directory + uint256 lower; + uint256 upper; + uint256 index; + + while (left <= right) { + index = (left + right) >> 1; + + lower = index == 0 + ? 0 + : directories[currentRewardCycle][stakingPeriodId].entries[index - 1].boundary; + upper = directories[currentRewardCycle][stakingPeriodId].entries[index].boundary; + + if (expectedVal >= lower && expectedVal < upper) { + return directories[currentRewardCycle][stakingPeriodId].entries[index].stakee; + } else if (expectedVal < lower) { + right = index - 1; + } else { + // expectedVal >= upper + left = index + 1; + } + } + } + + function joinNextDirectory() external { + uint256 currentRewardCycle = protocolTimeManager.getCurrentCycle(); + uint256 currentStakingPeriod = protocolTimeManager.getCurrentPeriod(); + + uint256 nodeStake = stakingOrchestrator.getNodeCurrentStake(msg.sender); + if (nodeStake == 0) { + revert CannotJoinDirectoryWithZeroStake(); + } + + if (directories[currentRewardCycle][currentStakingPeriod].stakes[msg.sender] > 0) { + revert StakeeAlreadyJoinedEpoch(); + } + + uint256 nextBoundary = directories[currentRewardCycle][currentStakingPeriod].totalStake + + nodeStake; + + directories[currentRewardCycle][currentStakingPeriod].entries.push( + DirectoryEntry(msg.sender, nextBoundary) + ); + directories[currentRewardCycle][currentStakingPeriod].stakes[msg.sender] = nodeStake; + directories[currentRewardCycle][currentStakingPeriod].totalStake = nextBoundary; + } +} diff --git a/contracts/IDirectory.sol b/contracts/IDirectory.sol new file mode 100644 index 00000000..8f7ca8aa --- /dev/null +++ b/contracts/IDirectory.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.18; + +interface IDirectory { + /** + * @dev A DirectoryEntry will be stored for every node that joins the + * network in a specific period. The entry will contain the stakee's + * address, and a boundary value which is a sum of the current directory's + * total stake, and the current stakee's total stake. + */ + struct DirectoryEntry { + address stakee; + uint256 boundary; + } + + /** + * @dev An EpochDirectory will be stored for every period. The + * directory will be constructed piece by piece as Nodes join, + * each adding their own directory entry based on their current + * stake value. + */ + struct Directory { + DirectoryEntry[] entries; + mapping(address => uint256) stakes; + uint256 totalStake; + } + + function scan(uint128 point) external returns (address); + + function scanWithStakingPeriod( + uint128 point, + uint256 stakingPeriodId + ) external returns (address); + + function joinNextDirectory() external; +} diff --git a/test/Directory.test.ts b/test/Directory.test.ts new file mode 100644 index 00000000..1f356894 --- /dev/null +++ b/test/Directory.test.ts @@ -0,0 +1,53 @@ +import { ethers } from 'hardhat'; +import { deployContracts } from './utils'; +import { expect } from 'chai'; +import { SyloContracts } from '../common/contracts'; +import { + Directory, + StakingOrchestrator, + ProtocolTimeManager, +} from '../typechain-types'; + +describe.only('Directory', () => { + let contracts: SyloContracts; + let directory: Directory; + let stakingOrchestator: StakingOrchestrator; + let protocolTimeManager: ProtocolTimeManager; + + beforeEach(async () => { + contracts = await deployContracts(); + directory = contracts.directory; + stakingOrchestator = contracts.stakingOrchestrator; + protocolTimeManager = contracts.protocolTimeManager; + }); + + it('cannot initialize directory with empty StakingOrchestrator address', async () => { + const directoryFactory = await ethers.getContractFactory('Directory'); + const directoryTemp = await directoryFactory.deploy(); + + await expect( + directory.initialize( + ethers.ZeroAddress, + await protocolTimeManager.getAddress(), + ), + ).to.be.revertedWithCustomError( + directoryTemp, + 'CannotInitialiseWithZeroStakingOrchestratorAddress', + ); + }); + + it('cannot initialize directory with empty ProtocolTimeManager address', async () => { + const directoryFactory = await ethers.getContractFactory('Directory'); + const directoryTemp = await directoryFactory.deploy(); + + await expect( + directory.initialize( + await stakingOrchestator.getAddress(), + ethers.ZeroAddress, + ), + ).to.be.revertedWithCustomError( + directoryTemp, + 'CannotInitialiseWithZeroStakingOrchestratorAddress', + ); + }); +}); diff --git a/test/utils.ts b/test/utils.ts index 8cfcc572..486d952a 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -44,6 +44,10 @@ export async function deployContracts( 'RewardsManager', ); const ticketingFactory = await ethers.getContractFactory('Ticketing'); + const stakingOrchestratorFactory = await ethers.getContractFactory( + 'StakingOrchestrator', + ); + const directoryFactory = await ethers.getContractFactory('Directory'); // Deploy const syloToken = await syloTokenFactory.deploy(); @@ -56,6 +60,8 @@ export async function deployContracts( const authorizedAccounts = await authorizedAccountsFactory.deploy(); const rewardsManager = await rewardsManagerFactory.deploy(); const ticketing = await ticketingFactory.deploy(); + const stakingOrchestrator = await stakingOrchestratorFactory.deploy(); + const directory = await directoryFactory.deploy(); // Options const syloStakingManagerOpts = { @@ -88,6 +94,10 @@ export async function deployContracts( await authorizedAccounts.initialize(); await ticketing.initialize(await rewardsManager.getAddress()); await rewardsManager.initialize(await registries.getAddress(), ticketing); + await directory.initialize( + await stakingOrchestrator.getAddress(), + await protocolTimeManager.getAddress(), + ); await protocolTimeManager.initialize( protocolTimeManagerOpts.cycleDuration, protocolTimeManagerOpts.periodDuration, @@ -104,6 +114,8 @@ export async function deployContracts( authorizedAccounts, rewardsManager, ticketing, + stakingOrchestrator, + directory, }; }